import { randomBytes, randomUUID } from "node:crypto";
import type { Kysely } from "kysely";
import { env } from "@/lib/env";
import { logError } from "@/lib/logger";
import { SMTP_FROM, transporter } from "@/lib/mailer";
import type { DB } from "@/internal/datastore/db";
import { EDMRepository } from "@/internal/edm/edm.repository";
import { renderEDM, toPlainText } from "@/internal/edm/edm-render";
import type { EDMTemplateObject } from "@/lib/storage/edm-storage";
import {
  buildVersionObjectPath,
  EDMStorage,
} from "@/lib/storage/edm-storage";

const SENDGRID_BASE = "https://api.sendgrid.com/v3";

// ── Public shapes (unchanged) ────────────────────────────────────────────────
//
// Still named for SendGrid because that is the contract the admin console was
// written against. Templates now come from our own store, but the console's
// types.ts, list page, detail page and the activity-log differ all consume this
// exact shape, so it is preserved verbatim.

export interface SendGridTemplateVersion {
  id: string;
  template_id: string;
  active: 0 | 1;
  name: string;
  html_content: string;
  plain_content: string;
  subject: string;
  updated_at: string;
  editor?: string;
  thumbnail_url?: string;
  generate_plain_content?: boolean;
}

export interface SendGridTemplate {
  id: string;
  name: string;
  generation: "dynamic" | "legacy";
  updated_at: string;
  versions: SendGridTemplateVersion[];
  /** Local-store only. Absent for templates still owned by SendGrid. */
  folder_id?: string | null;
  /**
   * Which store actually holds this template's bytes, and therefore where an
   * edit or a delete goes.
   *
   * Distinct from `source`, which records where it came FROM: a backfilled
   * template keeps source "sendgrid" while living in GCS, so routing or
   * labelling on `source` would send its edits back to SendGrid and undo the
   * migration one save at a time.
   */
  store?: "gcs" | "sendgrid";
  /** 'local' | 'sendgrid' — lets the console show migration progress. */
  source?: string;
}

export interface CreateEDMInput {
  name: string;
  subject: string;
  html_content: string;
  plain_content?: string;
  active?: 0 | 1;
  generate_plain_content?: boolean;
  folder_id?: string | null;
}

export interface UpdateEDMInput {
  name?: string;
  subject?: string;
  html_content?: string;
  plain_content?: string;
  active?: 0 | 1;
  generate_plain_content?: boolean;
  folder_id?: string | null;
}

type SGResult<T> =
  | { success: true; data: T }
  | { success: false; message: string; status?: number; step?: string; templateId?: string };

/**
 * New template ids mirror SendGrid's "d-<32 hex>" shape but with a "k-" prefix,
 * so the origin of any id in the estate is readable at a glance. Backfilled
 * templates keep their original "d-" id — see the migration for why that is
 * load-bearing.
 */
const mintTemplateId = (): string => `k-${randomBytes(16).toString("hex")}`;

/**
 * Master switch for anything that MUTATES SendGrid or sends real mail through
 * it. Default OFF, and deliberately opt-in rather than opt-out.
 *
 * Reads and the local store are unaffected — listing, fetching and the backfill
 * are GET-only and always allowed.
 *
 * Two fall-through paths would otherwise reach production SendGrid from a
 * developer's machine, because apps/core/.env points at prod:
 *
 *   deleteTemplate  → DELETE /v3/templates/{id}   destroys a live template
 *   sendTestEmail   → POST   /v3/mail/send        sends real email to real people
 *
 * Both only trigger for templates that are not in the local store yet, which is
 * every template until the backfill runs — so during local testing they are the
 * likeliest paths to hit, not the rarest.
 */
const sendGridWritesAllowed = (target?: EDMStorageTarget): boolean =>
  ["1", "true", "yes"].includes(
    (process.env.EDM_SENDGRID_WRITES ?? "").trim().toLowerCase(),
  ) ||
  // Choosing SendGrid as the target IS the opt-in to writing there.
  (target ?? envStorageMode()) === "sendgrid";

/**
 * Where a newly created or edited template is STORED.
 *
 *   EDM_STORAGE_MODE=gcs       (default) → our own store: GCS bytes + coredb rows
 *   EDM_STORAGE_MODE=sendgrid            → SendGrid, exactly as before this migration
 *
 * This is the rollback lever for the whole project. While the new path is being
 * tested, flipping to "sendgrid" restores the original behaviour end to end
 * without a deploy or a revert — the console, the routes and the response shapes
 * are identical either way.
 *
 * READS are always local-first with a SendGrid fallback regardless of mode, so a
 * template stays visible and editable no matter which store it was created in.
 * That is what makes the switch safe to flip back and forth mid-test: neither
 * direction strands templates created under the other setting.
 *
 * Note "sendgrid" necessarily writes to SendGrid, so choosing it IS the opt-in
 * to SendGrid writes.
 *
 * Since the console setting exists, this env var is only the default: a super
 * admin's saved choice wins. See resolveStorageTarget.
 */
export type EDMStorageTarget = "gcs" | "sendgrid";

/** The env default, used until a super admin saves a choice in the console. */
const envStorageMode = (): EDMStorageTarget =>
  (process.env.EDM_STORAGE_MODE ?? "").trim().toLowerCase() === "sendgrid"
    ? "sendgrid"
    : "gcs";

/** console_settings key holding the console-set choice. */
export const EDM_STORAGE_SETTING_KEY = "edm-storage";

/**
 * Where the NEXT new template goes.
 *
 * The console setting wins over the env var so a super admin can switch without
 * a deploy; the env var remains the default and the answer when the settings
 * table cannot be read. A failed lookup must not block creating a template, so
 * it degrades to the env default rather than throwing.
 */
export const resolveStorageTarget = async (
  db: Kysely<DB>,
): Promise<{ target: EDMStorageTarget; source: "console" | "env" }> => {
  try {
    const row = await (db as unknown as Kysely<any>)
      .selectFrom("console_settings")
      .select("value")
      .where("key", "=", EDM_STORAGE_SETTING_KEY)
      .executeTakeFirst();

    // jsonb comes back parsed on some drivers and as a string on others.
    const raw = row?.value;
    const parsed =
      typeof raw === "string" ? JSON.parse(raw) : (raw as { target?: string } | undefined);

    if (parsed?.target === "sendgrid") return { target: "sendgrid", source: "console" };
    if (parsed?.target === "gcs") return { target: "gcs", source: "console" };
  } catch {
    /* fall through to the env default */
  }
  return { target: envStorageMode(), source: "env" };
};

const SENDGRID_WRITES_BLOCKED =
  "Blocked: this would change data in SendGrid (or send real mail through it), " +
  "and SendGrid writes are disabled. Set EDM_SENDGRID_WRITES=1 to allow it.";

const iso = (d: Date | string): string =>
  d instanceof Date ? d.toISOString() : new Date(d).toISOString();

export class EDMService {
  private static instance: EDMService;
  private storage = EDMStorage();

  static getInstance(): EDMService {
    if (!EDMService.instance) {
      EDMService.instance = new EDMService();
    }
    return EDMService.instance;
  }

  // ── SendGrid fallback ─────────────────────────────────────────────────────
  //
  // Retained for the transition only. Until the backfill has mirrored every
  // template, some ids resolve locally and some still only exist in SendGrid;
  // reads fall through so the console keeps working throughout. Once the
  // backfill is complete and SendGrid's copies are deleted, this path goes
  // permanently cold and can be removed.

  private get headers(): Record<string, string> {
    return {
      Authorization: `Bearer ${env.GetString("SENDGRID_API_KEY")}`,
      "Content-Type": "application/json",
    };
  }

  private async sgFetch<T>(path: string, init?: RequestInit): Promise<SGResult<T>> {
    try {
      const res = await fetch(`${SENDGRID_BASE}${path}`, {
        ...init,
        headers: { ...this.headers, ...(init?.headers as Record<string, string> ?? {}) },
      });

      if (res.status === 204) return { success: true, data: null as unknown as T };

      const json = await res.json().catch(() => null);

      if (!res.ok) {
        const message =
          json?.errors?.[0]?.message ?? json?.message ?? `SendGrid error ${res.status}`;
        return { success: false, message, status: res.status };
      }

      return { success: true, data: json as T };
    } catch (err: any) {
      logError(err, "[EDMService] SendGrid network error");
      return { success: false, message: err?.message ?? "Network error", status: 500 };
    }
  }

  private async listSendGridTemplates(): Promise<SendGridTemplate[]> {
    const all: SendGridTemplate[] = [];
    let pageToken: string | undefined;

    do {
      const query = new URLSearchParams({ generations: "dynamic", page_size: "200" });
      if (pageToken) query.set("page_token", pageToken);

      const result = await this.sgFetch<{
        result: SendGridTemplate[];
        _metadata?: { next?: string };
      }>(`/templates?${query.toString()}`);

      // A SendGrid outage must not blank the console's list of local templates.
      if (!result.success) return all;

      all.push(...(result.data.result ?? []));
      pageToken = result.data._metadata?.next
        ? (new URL(result.data._metadata.next).searchParams.get("page_token") ?? undefined)
        : undefined;
    } while (pageToken);

    return all;
  }

  // ── reads ─────────────────────────────────────────────────────────────────

  /**
   * List every template, local store first.
   *
   * Deliberately does NOT read GCS: the console's list page only touches
   * name / id / subject / active / updated_at, and fetching an object per
   * template would turn one query into a thousand network round trips.
   * html_content is therefore "" here, exactly as SendGrid's own list endpoint
   * omits it.
   */
  async listTemplates(db: Kysely<DB>): Promise<SGResult<SendGridTemplate[]>> {
    try {
      const rows = await EDMRepository.ListTemplatesWithVersions(db);

      const local: SendGridTemplate[] = rows.map((row: any) => ({
        id: row.id,
        name: row.name,
        generation: (row.generation ?? "dynamic") as "dynamic" | "legacy",
        updated_at: iso(row.updated_at),
        folder_id: row.folder_id ?? null,
        source: row.source ?? "local",
        // A local row means the bytes are ours, whatever the origin was.
        store: "gcs" as const,
        versions: (row.versions ?? []).map((v: any) => ({
          id: v.id,
          template_id: v.template_id,
          active: (v.active === 1 ? 1 : 0) as 0 | 1,
          name: v.name,
          html_content: "",
          plain_content: "",
          subject: v.subject ?? "",
          updated_at: iso(v.created_at),
          generate_plain_content: v.generate_plain_content ?? true,
        })),
      }));

      // Templates SendGrid still owns and the backfill has not mirrored yet.
      const localIds = new Set(local.map((t) => t.id));
      const remoteOnly = (await this.listSendGridTemplates())
        .filter((t) => !localIds.has(t.id))
        .map((t) => ({ ...t, store: "sendgrid" as const }));

      return { success: true, data: [...local, ...remoteOnly] };
    } catch (err: any) {
      logError(err, "[EDMService] listTemplates failed");
      return { success: false, message: err?.message ?? "Failed listing templates", status: 500 };
    }
  }

  /**
   * One template with its versions. Unlike the list, this DOES read GCS —
   * the detail page and the preview route need the actual html.
   */
  async getTemplate(db: Kysely<DB>, templateId: string): Promise<SGResult<SendGridTemplate>> {
    try {
      const template = await EDMRepository.FindTemplateById(db, templateId);

      if (!template) {
        // Not migrated yet — fall through to SendGrid, and say so, since that
        // is where an edit to it would land.
        const remote = await this.sgFetch<SendGridTemplate>(`/templates/${templateId}`);
        return remote.success
          ? { ...remote, data: { ...remote.data, store: "sendgrid" as const } }
          : remote;
      }

      const versions = await EDMRepository.ListVersions(db, templateId);

      const hydrated = await Promise.all(
        versions.map(async (v) => {
          let object: EDMTemplateObject | null = null;
          try {
            object = await this.storage.getTemplateObject(v.storage_path);
          } catch (err) {
            // A missing object must not blank the whole page — surface the
            // version with empty content rather than failing the request.
            logError(err, `[EDMService] Unreadable version object ${v.storage_path}`);
          }

          return {
            id: v.id,
            template_id: v.template_id,
            active: (v.active === 1 ? 1 : 0) as 0 | 1,
            name: v.name,
            html_content: object?.html ?? "",
            plain_content: object?.plain ?? "",
            subject: object?.subject ?? v.subject ?? "",
            updated_at: iso(v.created_at),
            generate_plain_content: v.generate_plain_content ?? true,
          } satisfies SendGridTemplateVersion;
        }),
      );

      return {
        success: true,
        data: {
          id: template.id,
          name: template.name,
          generation: (template.generation ?? "dynamic") as "dynamic" | "legacy",
          updated_at: iso(template.updated_at as unknown as Date),
          folder_id: template.folder_id ?? null,
          source: template.source ?? "local",
          store: "gcs" as const,
          versions: hydrated,
        },
      };
    } catch (err: any) {
      logError(err, "[EDMService] getTemplate failed");
      return { success: false, message: err?.message ?? "Failed getting template", status: 500 };
    }
  }

  // ── writes ────────────────────────────────────────────────────────────────

  /**
   * Create a template and its first version.
   *
   * Order matters: GCS is written before the row that points at it, so a crash
   * leaves an unreferenced object (harmless, invisible) rather than a database
   * row pointing at bytes that do not exist (a template that renders blank in
   * production).
   */
  /**
   * Original pre-migration create: a template shell in SendGrid, then its first
   * version. Kept verbatim so EDM_STORAGE_MODE=sendgrid is a true rollback and
   * not a re-implementation that might behave subtly differently.
   */
  private async createInSendGrid(
    input: CreateEDMInput,
  ): Promise<SGResult<SendGridTemplate>> {
    const templateResult = await this.sgFetch<{ id: string; name: string }>(
      "/templates",
      {
        method: "POST",
        body: JSON.stringify({ name: input.name, generation: "dynamic" }),
      },
    );

    if (!templateResult.success) {
      return {
        success: false,
        message: `Template creation failed: ${templateResult.message}`,
        step: "template",
      };
    }

    const templateId = templateResult.data.id;

    const versionResult = await this.sgFetch<SendGridTemplateVersion>(
      `/templates/${templateId}/versions`,
      {
        method: "POST",
        body: JSON.stringify({
          name: input.name,
          subject: input.subject,
          html_content: input.html_content,
          plain_content: input.plain_content ?? "",
          active: input.active ?? 1,
          generate_plain_content: input.generate_plain_content ?? true,
        }),
      },
    );

    if (!versionResult.success) {
      // The shell exists but has no version — surfaced so the console can show
      // the orphaned id rather than losing it.
      return {
        success: false,
        message: `Template created (ID: ${templateId}) but version creation failed: ${versionResult.message}`,
        step: "version",
        templateId,
      };
    }

    return this.sgFetch<SendGridTemplate>(`/templates/${templateId}`);
  }

  /** Original pre-migration update: patch the template name and active version. */
  private async updateInSendGrid(
    templateId: string,
    input: UpdateEDMInput,
  ): Promise<SGResult<SendGridTemplate>> {
    const current = await this.sgFetch<SendGridTemplate>(`/templates/${templateId}`);
    if (!current.success) return current;

    if (input.name) {
      const nameResult = await this.sgFetch<SendGridTemplate>(`/templates/${templateId}`, {
        method: "PATCH",
        body: JSON.stringify({ name: input.name }),
      });
      if (!nameResult.success) {
        return { success: false, message: `Failed to update template name: ${nameResult.message}` };
      }
    }

    const activeVersion =
      current.data.versions.find((v) => v.active === 1) ?? current.data.versions[0];

    if (activeVersion) {
      const versionBody: Record<string, unknown> = {};
      if (input.name !== undefined) versionBody.name = input.name;
      if (input.subject !== undefined) versionBody.subject = input.subject;
      if (input.html_content !== undefined) versionBody.html_content = input.html_content;
      if (input.plain_content !== undefined) versionBody.plain_content = input.plain_content;
      if (input.active !== undefined) versionBody.active = input.active;
      if (input.generate_plain_content !== undefined) {
        versionBody.generate_plain_content = input.generate_plain_content;
      }

      if (Object.keys(versionBody).length > 0) {
        const versionResult = await this.sgFetch<SendGridTemplateVersion>(
          `/templates/${templateId}/versions/${activeVersion.id}`,
          { method: "PATCH", body: JSON.stringify(versionBody) },
        );
        if (!versionResult.success) {
          return { success: false, message: `Failed to update version: ${versionResult.message}` };
        }
      }
    }

    return this.sgFetch<SendGridTemplate>(`/templates/${templateId}`);
  }

  async createTemplate(
    db: Kysely<DB>,
    input: CreateEDMInput,
    createdBy?: string | null,
  ): Promise<SGResult<SendGridTemplate>> {
    // Only NEW templates follow the configured target. Everything that already
    // exists keeps being edited where it lives — see updateTemplate.
    const { target } = await resolveStorageTarget(db);
    if (target === "sendgrid") return this.createInSendGrid(input);

    const templateId = mintTemplateId();

    try {
      const generatePlain = input.generate_plain_content ?? true;
      const object: EDMTemplateObject = {
        subject: input.subject,
        html: input.html_content,
        plain: generatePlain
          ? toPlainText(input.html_content)
          : (input.plain_content ?? ""),
        generatePlain,
      };

      await EDMRepository.CreateTemplate(db, {
        id: templateId,
        name: input.name,
        folderId: input.folder_id ?? null,
        source: "local",
        createdBy: createdBy ?? null,
      });

      const version = await this.saveVersion(db, {
        templateId,
        name: input.name,
        object,
        createdBy,
        publish: (input.active ?? 1) === 1,
        folderId: input.folder_id ?? null,
      });

      if (!version.success) {
        return {
          success: false,
          message: version.message,
          step: "version",
          templateId,
        };
      }

      return this.getTemplate(db, templateId);
    } catch (err: any) {
      logError(err, "[EDMService] createTemplate failed");
      return {
        success: false,
        message: err?.message ?? "Failed creating template",
        step: "template",
      };
    }
  }

  /**
   * Update = append a new immutable version, never mutate an existing one, so
   * what was sent stays reconstructable. Fields the caller omits are inherited
   * from the current active version.
   */
  async updateTemplate(
    db: Kysely<DB>,
    templateId: string,
    input: UpdateEDMInput,
    updatedBy?: string | null,
  ): Promise<SGResult<SendGridTemplate>> {
    try {
      const template = await EDMRepository.FindTemplateById(db, templateId);

      /**
       * An edit goes wherever the template already is, never where the setting
       * points.
       *
       * No local row means the only copy is SendGrid's, and that copy is what
       * production mail still renders — so the edit belongs there. A local row
       * means the bytes are in GCS, including for templates the backfill
       * mirrored out of SendGrid: those keep their d- id but are ours now, and
       * sending the edit back to SendGrid would undo the migration one save at
       * a time.
       *
       * Routing on the setting instead would strand every template created
       * under the other setting the moment someone flipped it.
       */
      if (!template) {
        const { target } = await resolveStorageTarget(db);
        if (!sendGridWritesAllowed(target)) {
          return { success: false, message: SENDGRID_WRITES_BLOCKED, status: 403 };
        }
        return this.updateInSendGrid(templateId, input);
      }

      const versions = await EDMRepository.ListVersions(db, templateId);
      const current = versions.find((v) => v.active === 1) ?? versions[0];

      let currentObject: EDMTemplateObject | null = null;
      if (current) {
        currentObject = await this.storage.getTemplateObject(current.storage_path);
      }

      if (input.name !== undefined || input.folder_id !== undefined) {
        await EDMRepository.UpdateTemplate(db, templateId, {
          name: input.name,
          folderId: input.folder_id,
        });
      }

      const contentChanged =
        input.subject !== undefined ||
        input.html_content !== undefined ||
        input.plain_content !== undefined ||
        input.generate_plain_content !== undefined;

      if (contentChanged) {
        const generatePlain =
          input.generate_plain_content ?? currentObject?.generatePlain ?? true;
        const html = input.html_content ?? currentObject?.html ?? "";

        const object: EDMTemplateObject = {
          subject: input.subject ?? currentObject?.subject ?? "",
          html,
          plain: generatePlain
            ? toPlainText(html)
            : (input.plain_content ?? currentObject?.plain ?? ""),
          generatePlain,
        };

        const version = await this.saveVersion(db, {
          templateId,
          name: input.name ?? template.name,
          object,
          createdBy: updatedBy,
          publish: (input.active ?? 1) === 1,
          // The update may be moving it; fall back to where it is now.
          folderId: input.folder_id ?? template.folder_id ?? null,
        });

        if (!version.success) return version;
      }

      return this.getTemplate(db, templateId);
    } catch (err: any) {
      logError(err, "[EDMService] updateTemplate failed");
      return { success: false, message: err?.message ?? "Failed updating template", status: 500 };
    }
  }

  /**
   * Write one immutable version object to GCS, record it, and optionally make
   * it the version the SMTP worker will render.
   *
   * Ordering is deliberate:
   *
   *   1. Mint the version uuid locally. The storage path is derived from it, so
   *      it has to exist before either the object or the row is written — and a
   *      locally-minted uuid is never shared, so two concurrent saves provably
   *      cannot target the same prefix.
   *   2. Write the GCS object BEFORE the row that points at it. A crash here
   *      leaves an unreferenced object (invisible, harmless) instead of a row
   *      pointing at bytes that do not exist (a template that renders blank).
   *   3. Allocate the version number and insert inside ONE transaction. The
   *      number is a max()+1 read followed by a write, so without a transaction
   *      holding the parent row lock, two concurrent saves both read the same
   *      max and the second one dies on the (template_id, version_number)
   *      unique constraint. The GCS write stays outside so no lock is held
   *      across network I/O.
   */
  private async saveVersion(
    db: Kysely<DB>,
    args: {
      templateId: string;
      name: string;
      object: EDMTemplateObject;
      createdBy?: string | null;
      publish: boolean;
      /**
       * Recorded as object metadata so the bucket says where a template belongs.
       * A hint only: the database is the source of truth and a folder rename
       * deliberately does not rewrite stored objects.
       */
      folderId?: string | null;
    },
  ): Promise<SGResult<{ versionId: string }>> {
    try {
      const versionId = randomUUID();
      const storagePath = buildVersionObjectPath(args.templateId, versionId);

      // Root-first list of folder names, joined the way the console shows it.
      // Never fatal: metadata is a convenience, and failing a save because a
      // label could not be looked up would be absurd.
      const folderPath = args.folderId
        ? await EDMRepository.FolderNamePath(db, args.folderId)
            .then((names) => names.join("/"))
            .catch(() => "")
        : "";

      await this.storage.putTemplateVersion(
        args.templateId,
        versionId,
        args.object,
        { folderPath: folderPath || "", name: args.name },
      );

      await db.transaction().execute(async (trx) => {
        const versionNumber = await EDMRepository.NextVersionNumber(
          trx,
          args.templateId,
        );

        await trx
          .insertInto("edm_template_versions")
          .values({
            id: versionId,
            template_id: args.templateId,
            version_number: versionNumber,
            name: args.name,
            subject: args.object.subject,
            storage_path: storagePath,
            generate_plain_content: args.object.generatePlain,
            active: args.publish ? 1 : 0,
            created_by: args.createdBy ?? null,
          })
          .execute();

        if (args.publish) {
          await EDMRepository.SetActiveVersion(trx, args.templateId, versionId);
        }
      });

      // After the commit: if this copy fails the row is already correct, and a
      // re-save republishes. The reverse order could leave the worker rendering
      // a version the database does not consider active.
      if (args.publish) {
        await this.storage.publishVersion(args.templateId, versionId);
      }

      return { success: true, data: { versionId } };
    } catch (err: any) {
      logError(err, "[EDMService] saveVersion failed");
      return { success: false, message: err?.message ?? "Failed saving version", status: 500 };
    }
  }

  /**
   * Delete a template.
   *
   * Removes metadata and template html only. Images are NEVER deleted — mail
   * already in inboxes still references them, and a delete here would put holes
   * in email sent years ago. See edm-storage.putAsset.
   */
  async deleteTemplate(db: Kysely<DB>, templateId: string): Promise<SGResult<null>> {
    try {
      const template = await EDMRepository.FindTemplateById(db, templateId);

      if (!template) {
        // Not migrated, so "delete" would mean destroying SendGrid's copy —
        // irreversible, and the copy is still the one production mail renders.
        const { target } = await resolveStorageTarget(db);
        if (!sendGridWritesAllowed(target)) {
          return { success: false, message: SENDGRID_WRITES_BLOCKED, status: 403 };
        }
        return this.sgFetch<null>(`/templates/${templateId}`, { method: "DELETE" });
      }

      await EDMRepository.DeleteTemplate(db, templateId);
      await this.storage.deleteTemplate(templateId);

      return { success: true, data: null };
    } catch (err: any) {
      logError(err, "[EDMService] deleteTemplate failed");
      return { success: false, message: err?.message ?? "Failed deleting template", status: 500 };
    }
  }

  // ── test send ─────────────────────────────────────────────────────────────

  /**
   * Send the rendered template to one or more addresses.
   *
   * Goes out over SMTP with real html rather than SendGrid's template_id — this
   * is the same path production mail will take once the worker switches, so a
   * passing test send actually exercises our renderer rather than SendGrid's.
   *
   * One message per recipient, so testers never see each other's addresses.
   */
  async sendTestEmail(
    db: Kysely<DB>,
    templateId: string,
    to: string | string[],
    templateData: Record<string, unknown> = {},
  ): Promise<SGResult<null>> {
    const recipients = Array.isArray(to) ? to : [to];

    try {
      const template = await EDMRepository.FindTemplateById(db, templateId);

      // Not migrated — testing it would mean asking SendGrid to send real mail
      // to real addresses, from a prod API key, over a template we do not own.
      if (!template) {
        const { target } = await resolveStorageTarget(db);
        if (!sendGridWritesAllowed(target)) {
          return {
            success: false,
            message:
              "This template is not in the local store yet, so a test send would go " +
              "out through SendGrid using the production key. Run the backfill first, " +
              "or set EDM_SENDGRID_WRITES=1 to allow it.",
            status: 403,
          };
        }
        return this.sgFetch<null>("/mail/send", {
          method: "POST",
          body: JSON.stringify({
            personalizations: recipients.map((email) => ({ to: [{ email }] })),
            from: { email: SMTP_FROM },
            template_id: templateId,
          }),
        });
      }

      const object = await this.storage.getActiveTemplate(templateId);
      if (!object) {
        return {
          success: false,
          message:
            "This template has no published version yet — save it before sending a test.",
          status: 422,
        };
      }

      const rendered = renderEDM(object, templateData);

      for (const email of recipients) {
        await transporter.sendMail({
          from: SMTP_FROM,
          to: email,
          subject: rendered.subject,
          html: rendered.html,
          text: rendered.plain,
        });
      }

      return { success: true, data: null };
    } catch (err: any) {
      logError(err, "[EDMService] sendTestEmail failed");
      return { success: false, message: err?.message ?? "Failed sending test email", status: 500 };
    }
  }

  /** Used by the internal API the SMTP worker calls. */
  async getRenderableTemplate(
    db: Kysely<DB>,
    templateId: string,
  ): Promise<
    | { success: true; data: EDMTemplateObject & { templateId: string; versionId: string | null } }
    | { success: false; message: string; status: number }
  > {
    try {
      const template = await EDMRepository.FindTemplateById(db, templateId);
      if (!template) {
        return { success: false, message: "Template not found", status: 404 };
      }

      const object = await this.storage.getActiveTemplate(templateId);
      if (!object) {
        return { success: false, message: "Template has no published version", status: 404 };
      }

      return {
        success: true,
        data: { ...object, templateId, versionId: template.active_version_id ?? null },
      };
    } catch (err: any) {
      logError(err, "[EDMService] getRenderableTemplate failed");
      return { success: false, message: err?.message ?? "Failed reading template", status: 500 };
    }
  }
}
