import * as vscode from "vscode";
import * as path from "node:path";
import {
  EDMApi,
  NotAuthenticatedError,
  EDM_WORKFLOW_STATES,
  EDM_WORKFLOW_LABELS,
  type AssetEntry,
  type EDMFolderAsset,
  type EDMFolderTemplate,
  type EDMWorkflowState,
} from "./api";
import { EDMTreeProvider, type EDMNode } from "./tree";

/**
 * Karma EDM — author email templates in the editor.
 *
 * The point of this extension over the console's upload wizard is the feedback
 * loop. A relative <img src> that has no file behind it is not a warning, it is
 * a hole in every inbox that receives the email — and today you only find out
 * after uploading a zip. Here it is a squiggle under the path, on save.
 */

const CONTENT_TYPES: Record<string, string> = {
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".svg": "image/svg+xml",
  ".ico": "image/x-icon",
};

let diagnostics: vscode.DiagnosticCollection;

/**
 * Open change requests, kept apart from preflight's diagnostics.
 *
 * One collection per source, because they are cleared independently: a preflight
 * run that finds nothing must not erase the review comments sitting on the same
 * file, and vice versa.
 */
let reviewDiagnostics: vscode.DiagnosticCollection;

/** Approval state of the open template, in the status bar. */
let workflowStatusItem: vscode.StatusBarItem;

/**
 * Remembers which template a local file came from, keyed by absolute path.
 *
 * Without this, Push has to ask "new or existing?" and then make you pick from
 * a list on every single save — for the common case, editing a template you
 * just opened, that is two prompts to re-state something the extension already
 * knew. Stored in workspaceState so it survives a window reload but does not
 * leak between projects.
 */
const LINK_KEY = "karmaEdm.links";
type Link = { templateId: string; name: string };

const getLinks = (ctx: vscode.ExtensionContext): Record<string, Link> =>
  ctx.workspaceState.get<Record<string, Link>>(LINK_KEY, {});

const linkFile = async (ctx: vscode.ExtensionContext, fsPath: string, link: Link) => {
  await ctx.workspaceState.update(LINK_KEY, { ...getLinks(ctx), [fsPath]: link });
};

const linkFor = (ctx: vscode.ExtensionContext, fsPath: string): Link | undefined =>
  getLinks(ctx)[fsPath];

let extContext: vscode.ExtensionContext;

export function activate(context: vscode.ExtensionContext) {
  extContext = context;
  const api = new EDMApi(context.secrets);
  diagnostics = vscode.languages.createDiagnosticCollection("karma-edm");
  reviewDiagnostics = vscode.languages.createDiagnosticCollection("karma-edm-review");
  workflowStatusItem = vscode.window.createStatusBarItem(
    vscode.StatusBarAlignment.Right,
    100,
  );
  workflowStatusItem.command = "karmaEdm.workflowStatus";
  context.subscriptions.push(diagnostics, reviewDiagnostics, workflowStatusItem);

  const guard = (fn: () => Promise<void>) => async () => {
    try {
      await fn();
    } catch (err) {
      if (err instanceof NotAuthenticatedError) {
        const choice = await vscode.window.showErrorMessage(
          err.message,
          "Set session",
        );
        if (choice === "Set session") await promptForCookie(api);
        return;
      }
      vscode.window.showErrorMessage(`Karma EDM: ${(err as Error).message}`);
    }
  };

  context.subscriptions.push(
    vscode.commands.registerCommand(
      "karmaEdm.signIn",
      guard(() => promptForCookie(api)),
    ),
    vscode.commands.registerCommand(
      "karmaEdm.signOut",
      guard(async () => {
        await api.clearCookie();
        vscode.window.showInformationMessage("Karma EDM: session cleared.");
      }),
    ),
    vscode.commands.registerCommand(
      "karmaEdm.preflight",
      guard(() => preflight(api)),
    ),
    vscode.commands.registerCommand("karmaEdm.push", guard(() => push(api))),
    vscode.commands.registerCommand(
      "karmaEdm.workflowStatus",
      guard(() => changeWorkflowStatus(api)),
    ),
    vscode.commands.registerCommand(
      "karmaEdm.requestChanges",
      guard(() => requestChangesAtCursor(api)),
    ),
    vscode.commands.registerCommand(
      "karmaEdm.resolveChangeRequest",
      guard(() => resolveChangeRequestHere(api)),
    ),
    vscode.commands.registerCommand("karmaEdm.pull", guard(() => pull(api))),
    // Escape hatch: a linked file always pushes to the same template, so there
    // has to be a way to break that when you want to fork it into a new one.
    vscode.commands.registerCommand(
      "karmaEdm.unlink",
      guard(async () => {
        const doc = activeHtmlDocument();
        const links = { ...getLinks(context) };
        const existing = links[doc.uri.fsPath];
        if (!existing) {
          vscode.window.showInformationMessage("Karma EDM: this file is not linked to a template.");
          return;
        }
        delete links[doc.uri.fsPath];
        await context.workspaceState.update(LINK_KEY, links);
        vscode.window.showInformationMessage(
          `Karma EDM: unlinked from ${existing.name}. Push will ask again.`,
        );
      }),
    ),
  );

  // ── sidebar tree ──────────────────────────────────────────────────────────

  const tree = new EDMTreeProvider(api);
  context.subscriptions.push(
    vscode.window.registerTreeDataProvider("karmaEdmExplorer", tree),
    vscode.commands.registerCommand("karmaEdm.refreshTree", () => tree.refresh()),
    vscode.commands.registerCommand(
      "karmaEdm.openTemplate",
      guard(async (...args: unknown[]) => {
        await openTemplate(api, args[0] as EDMFolderTemplate);
      }) as (...a: unknown[]) => void,
    ),
    vscode.commands.registerCommand(
      "karmaEdm.openAsset",
      guard(async (...args: unknown[]) => {
        const asset = args[0] as EDMFolderAsset;
        await vscode.env.openExternal(vscode.Uri.parse(asset.url));
      }) as (...a: unknown[]) => void,
    ),
    vscode.commands.registerCommand(
      "karmaEdm.pullFolder",
      guard(async (...args: unknown[]) => {
        await pullFolder(api, args[0] as EDMNode | undefined);
      }) as (...a: unknown[]) => void,
    ),
  );

  // The tree shows "not signed in" until there is a session, so refresh it as
  // soon as one is set rather than making the user hit refresh themselves.
  context.subscriptions.push(
    vscode.commands.registerCommand("karmaEdm.internal.sessionChanged", () =>
      tree.refresh(),
    ),
  );

  /**
   * Approval state and review comments follow the active editor.
   *
   * Failures are swallowed throughout: switching tabs must never produce an
   * error toast, and the console being unreachable is a normal state for someone
   * working offline on the html.
   */
  const syncWorkflow = async () => {
    const editor = vscode.window.activeTextEditor;
    const link = editor ? linkFor(context, editor.document.uri.fsPath) : undefined;

    if (!editor || !link) {
      workflowStatusItem.hide();
      return;
    }
    if ((await api.authMode()) !== "cookie") {
      // Approval needs a user session; saying so beats an empty status bar.
      workflowStatusItem.text = "$(circle-slash) EDM: sign in with session";
      workflowStatusItem.tooltip =
        "Approval state is attributed to a console user, so it needs a session cookie rather than the API key.";
      workflowStatusItem.show();
      return;
    }

    try {
      const task = await api.getWorkflowTask(link.templateId);
      workflowStatusItem.text = `$(git-branch) EDM: ${
        EDM_WORKFLOW_LABELS[task.status] ?? task.status
      }`;
      workflowStatusItem.tooltip = `${link.name} — click to change the approval state`;
      workflowStatusItem.show();

      await refreshReviewDiagnostics(api, editor.document, link.templateId);
    } catch {
      workflowStatusItem.hide();
    }
  };

  context.subscriptions.push(
    vscode.window.onDidChangeActiveTextEditor(() => void syncWorkflow()),
    vscode.workspace.onDidSaveTextDocument(() => void syncWorkflow()),
  );
  void syncWorkflow();

  // Preflight on save. Failures are swallowed: an unreachable console or an
  // expired session must not throw an error toast on every Ctrl+S.
  context.subscriptions.push(
    vscode.workspace.onDidSaveTextDocument(async (doc) => {
      if (doc.languageId !== "html") return;
      if (!vscode.workspace.getConfiguration("karmaEdm").get("preflightOnSave", true)) {
        return;
      }
      if (!(await api.hasCookie())) return;
      try {
        await runPreflight(api, doc, { silent: true });
      } catch {
        /* silent by design */
      }
    }),
  );
}

export function deactivate() {
  diagnostics?.dispose();
  reviewDiagnostics?.dispose();
  workflowStatusItem?.dispose();
}

// ── approval workflow ───────────────────────────────────────────────────────

/**
 * The linked template for the open file, or a readable error.
 *
 * Every workflow action needs one: approval is tracked against the template in
 * the console, not against a path on disk.
 */
function requireLink(): { doc: vscode.TextDocument; link: Link } {
  const doc = activeHtmlDocument();
  const link = linkFor(extContext, doc.uri.fsPath);
  if (!link) {
    throw new Error(
      "This file is not linked to a template yet. Push it first, or open it from the Karma EDM explorer.",
    );
  }
  return { doc, link };
}

async function changeWorkflowStatus(api: EDMApi): Promise<void> {
  const { link } = requireLink();
  const task = await api.getWorkflowTask(link.templateId);

  const picked = await vscode.window.showQuickPick(
    EDM_WORKFLOW_STATES.map((state) => ({
      label: EDM_WORKFLOW_LABELS[state] ?? state,
      description: state === task.status ? "current" : undefined,
      state,
    })),
    {
      title: `${link.name} — approval state`,
      placeHolder: `Currently ${EDM_WORKFLOW_LABELS[task.status] ?? task.status}`,
    },
  );
  if (!picked || picked.state === task.status) return;

  await api.setWorkflowStatus(link.templateId, picked.state as EDMWorkflowState);
  workflowStatusItem.text = `$(git-branch) EDM: ${picked.label}`;
  workflowStatusItem.show();
  vscode.window.showInformationMessage(
    `Karma EDM: ${link.name} moved to ${picked.label}.`,
  );
}

async function requestChangesAtCursor(api: EDMApi): Promise<void> {
  const { link } = requireLink();
  const editor = vscode.window.activeTextEditor;
  // 1-based, because that is what the editor's own gutter shows and what the
  // reviewer will quote.
  const line = (editor?.selection.active.line ?? 0) + 1;

  const description = await vscode.window.showInputBox({
    title: `Request changes — ${link.name}:${line}`,
    prompt: "What needs to change on this line?",
    ignoreFocusOut: true,
  });
  if (!description?.trim()) return;

  const task = await api.getWorkflowTask(link.templateId);
  let taskId = task.id;

  if (!taskId) {
    // Nothing has been tracked for this template yet. Creating the task first
    // is what gives the change request something to hang off.
    const created = await api.setWorkflowStatus(
      link.templateId,
      task.status as EDMWorkflowState,
    );
    taskId = created.id;
  }
  if (!taskId) {
    throw new Error("Could not open an approval task for this template.");
  }

  await api.createChangeRequest(taskId, {
    description: description.trim(),
    targetRef: link.templateId,
    targetLabel: link.name,
    lineNumber: line,
  });

  vscode.window.showInformationMessage(
    `Karma EDM: change request filed on ${link.name}:${line}.`,
  );
  const doc = vscode.window.activeTextEditor?.document;
  if (doc) await refreshReviewDiagnostics(api, doc, link.templateId);
}

async function resolveChangeRequestHere(api: EDMApi): Promise<void> {
  const { doc, link } = requireLink();
  const open = await api.listOpenChangeRequests([link.templateId]);

  if (open.length === 0) {
    vscode.window.showInformationMessage(
      `Karma EDM: no open change requests on ${link.name}.`,
    );
    return;
  }

  const picked = await vscode.window.showQuickPick(
    open.map((cr) => ({
      label: cr.line_number ? `Line ${cr.line_number}` : "Whole file",
      description: cr.description,
      detail: cr.requester_first_name ? `from ${cr.requester_first_name}` : undefined,
      id: cr.id,
    })),
    { title: `${link.name} — resolve a change request` },
  );
  if (!picked) return;

  await api.resolveChangeRequest(picked.id);
  vscode.window.showInformationMessage("Karma EDM: change request resolved.");
  await refreshReviewDiagnostics(api, doc, link.templateId);
}

/**
 * Show open change requests as diagnostics on the lines they point at.
 *
 * Diagnostics rather than decorations: they survive scrolling, appear in the
 * Problems panel, and are navigable with F8 — a reviewer's comment behaves like
 * any other thing to fix, which is the point.
 */
async function refreshReviewDiagnostics(
  api: EDMApi,
  doc: vscode.TextDocument,
  templateId: string,
): Promise<void> {
  let open;
  try {
    open = await api.listOpenChangeRequests([templateId]);
  } catch {
    // Leave whatever is already shown rather than clearing it — a failed fetch
    // is not evidence that the comments were resolved.
    return;
  }

  const items: vscode.Diagnostic[] = open.map((cr) => {
    // A request can outlive the line it pointed at: someone deletes the block it
    // was about. Clamp rather than drop, so the comment is still visible.
    const lineIndex = Math.min(
      Math.max((cr.line_number ?? 1) - 1, 0),
      Math.max(doc.lineCount - 1, 0),
    );
    const range = doc.lineAt(lineIndex).range;

    const who = cr.requester_first_name ? ` (${cr.requester_first_name})` : "";
    const diag = new vscode.Diagnostic(
      range,
      `Change requested${who}: ${cr.description}`,
      vscode.DiagnosticSeverity.Warning,
    );
    diag.source = "Karma EDM review";
    return diag;
  });

  reviewDiagnostics.set(doc.uri, items);
}

async function promptForCookie(api: EDMApi): Promise<void> {
  const choice = await vscode.window.showQuickPick(
    [
      {
        label: "$(key) API key",
        detail:
          "INTERNAL_SERVER_API_KEY from the server. Does not expire. Grants full trust and has no per-user revocation.",
        id: "apiKey" as const,
      },
      {
        label: "$(browser) Console session cookie",
        detail:
          "Scoped to your own console user and its RBAC. Expires with your session, so you re-paste occasionally.",
        id: "cookie" as const,
      },
    ],
    { title: "Karma EDM — how should the extension authenticate?", ignoreFocusOut: true },
  );
  if (!choice) return;

  if (choice.id === "apiKey") {
    const key = await vscode.window.showInputBox({
      title: "Karma EDM — API key",
      prompt:
        "Paste INTERNAL_SERVER_API_KEY. Requests go to /v1/internal/edm and are signed per-request (AES-256-GCM, 60s validity).",
      password: true,
      ignoreFocusOut: true,
      // apiKeyAuth uses the raw string as an AES-256 key, so anything other
      // than 32 bytes throws inside createCipheriv rather than returning 401 —
      // an error that reads like a bug rather than a typo.
      validateInput: (v) => {
        const t = v.trim();
        if (!t) return "Paste the key.";
        if (Buffer.byteLength(t, "utf-8") !== 32) {
          return `Key must be exactly 32 bytes for AES-256 (this is ${Buffer.byteLength(t, "utf-8")}).`;
        }
        return null;
      },
    });
    if (!key?.trim()) return;
    await api.setApiKey(key);
  } else {
    const value = await vscode.window.showInputBox({
      title: "Karma EDM — console session",
      prompt:
        "Paste the whole Cookie header from a logged-in admin console request " +
        "(DevTools → Network → any /api/proxy request → Request Headers → Cookie). " +
        "It must include k_session and k_session_id.",
      placeHolder: "k_session=...; k_session_id=...",
      password: true,
      ignoreFocusOut: true,
      validateInput: (v) => {
        const t = v.trim();
        if (!t) return "Paste the Cookie header value.";
        if (!t.includes("k_session"))
          return "That does not contain k_session — copy the full Cookie header, not just one cookie.";
        return null;
      },
    });
    if (!value?.trim()) return;
    await api.setCookie(value);
  }

  await vscode.commands.executeCommand("karmaEdm.internal.sessionChanged");
  vscode.window.showInformationMessage(
    `Karma EDM: ${choice.id === "apiKey" ? "API key" : "session"} stored in your OS keychain.`,
  );
}

function activeHtmlDocument(): vscode.TextDocument {
  const editor = vscode.window.activeTextEditor;
  if (!editor || editor.document.languageId !== "html") {
    throw new Error("Open the .html template you want to work on first.");
  }
  return editor.document;
}

// ── preflight ───────────────────────────────────────────────────────────────

async function preflight(api: EDMApi): Promise<void> {
  await runPreflight(api, activeHtmlDocument(), { silent: false });
}

async function runPreflight(
  api: EDMApi,
  doc: vscode.TextDocument,
  opts: { silent: boolean },
): Promise<void> {
  const text = doc.getText();
  const report = await api.scanImages(text);

  const found: vscode.Diagnostic[] = [];

  const add = (needle: string, message: string, severity: vscode.DiagnosticSeverity) => {
    // Every occurrence, not just the first — the same broken path is often
    // referenced from both an <img> and a VML fallback, and fixing one while
    // the other stays broken is exactly the mistake worth surfacing.
    let from = 0;
    for (;;) {
      const at = text.indexOf(needle, from);
      if (at === -1) break;
      found.push(
        new vscode.Diagnostic(
          new vscode.Range(doc.positionAt(at), doc.positionAt(at + needle.length)),
          message,
          severity,
        ),
      );
      from = at + needle.length;
    }
  };

  for (const ref of report.unresolved) {
    add(
      ref.url,
      `Image not resolved (${ref.kind}). This path will not load in any mail client — upload the file or use an absolute https URL.`,
      vscode.DiagnosticSeverity.Error,
    );
  }
  for (const ref of report.insecure) {
    add(
      ref.url,
      `Image loads over http (${ref.kind}). Most mail clients block or warn on mixed content.`,
      vscode.DiagnosticSeverity.Error,
    );
  }
  for (const ref of report.localhost) {
    add(
      ref.url,
      `Points at localhost (${ref.kind}) — unreachable from a recipient's device.`,
      vscode.DiagnosticSeverity.Error,
    );
  }

  // Handlebars helpers we do not implement. Positioned on the opening tag so
  // the squiggle lands on `{{#formatDate …}}` rather than the whole block.
  for (const helper of report.helpers ?? []) {
    const re = new RegExp(`\\{\\{#\\s*${escapeRegExp(helper.name)}(?![\\w$.-])[^}]*\\}\\}`, "g");
    for (const match of text.matchAll(re)) {
      const at = match.index ?? 0;
      found.push(
        new vscode.Diagnostic(
          new vscode.Range(doc.positionAt(at), doc.positionAt(at + match[0].length)),
          helper.hasArguments
            ? `"${helper.name}" takes arguments, so it can only be a helper — and it is not one we implement. Sending this template will fail outright.`
            : `"${helper.name}" is not a helper we implement. If it is a field in your template data this is fine; if you meant a helper, this block renders as nothing, silently.`,
          helper.hasArguments
            ? vscode.DiagnosticSeverity.Error
            : vscode.DiagnosticSeverity.Warning,
        ),
      );
      if (match[0].length === 0) break; // guard against a zero-width match looping
    }
  }

  diagnostics.set(doc.uri, found);

  if (opts.silent) return;

  const bytes = Buffer.byteLength(text, "utf8");
  const gmailClip =
    bytes > 102 * 1024
      ? ` ⚠ ${(bytes / 1024).toFixed(0)} KB — over Gmail's 102 KB clip threshold.`
      : "";

  // report.ok covers images only, so it is not sufficient on its own — a
  // template with clean images but an unimplemented helper is still broken.
  if (found.length === 0) {
    vscode.window.showInformationMessage(
      `Karma EDM: ${report.total} image reference(s), all resolvable. No unknown helpers.${gmailClip}`,
    );
  } else {
    vscode.window.showWarningMessage(
      `Karma EDM: ${found.length} problem(s) — see the Problems panel.${gmailClip}`,
    );
  }
}

// ── push ────────────────────────────────────────────────────────────────────

/**
 * Upload local images, rewrite their references, then create or update.
 *
 * Resolution is relative to the html file, exactly as a browser would, so the
 * folder layout a designer already works in needs no rearranging.
 */
async function push(api: EDMApi): Promise<void> {
  const doc = activeHtmlDocument();
  if (doc.isDirty) {
    const save = await vscode.window.showWarningMessage(
      "This file has unsaved changes. Save before pushing?",
      "Save and push",
      "Cancel",
    );
    if (save !== "Save and push") return;
    await doc.save();
  }

  await vscode.window.withProgress(
    { location: vscode.ProgressLocation.Notification, title: "Karma EDM", cancellable: false },
    async (progress) => {
      progress.report({ message: "Checking images…" });

      let html = doc.getText();
      const report = await api.scanImages(html);
      const baseDir = path.dirname(doc.uri.fsPath);

      const uploaded: AssetEntry[] = [];
      const missing: string[] = [];

      for (const [i, ref] of report.unresolved.entries()) {
        progress.report({
          message: `Uploading images (${i + 1}/${report.unresolved.length})…`,
        });

        const rel = ref.url.split(/[?#]/)[0] ?? "";
        const abs = path.resolve(baseDir, rel);
        const ext = path.extname(abs).toLowerCase();

        try {
          const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(abs));
          const entry = await api.uploadAsset(
            path.basename(abs),
            CONTENT_TYPES[ext] ?? "application/octet-stream",
            bytes,
          );
          uploaded.push(entry);
          html = html.split(ref.url).join(entry.url);
        } catch {
          // Referenced but absent on disk — report rather than push a template
          // with a hole in it.
          missing.push(ref.url);
        }
      }

      if (missing.length > 0) {
        throw new Error(
          `${missing.length} image(s) referenced but not found on disk:\n- ${missing.join("\n- ")}`,
        );
      }

      // A file opened from the tree already knows its template. Re-asking would
      // make the common case — edit what you just opened, push it back — two
      // prompts long for no information gained.
      const link = linkFor(extContext, doc.uri.fsPath);
      if (link) {
        progress.report({ message: `Updating ${link.name}…` });
        await api.updateTemplate(link.templateId, { html_content: html });
        vscode.window.showInformationMessage(
          `Karma EDM: updated ${link.name} (${uploaded.length} image(s) uploaded).`,
        );
        return;
      }

      progress.report({ message: "Choosing destination…" });

      const target = await vscode.window.showQuickPick(
        [
          { label: "$(add) Create a new template", id: "new" as const },
          { label: "$(cloud-upload) Update an existing template", id: "existing" as const },
        ],
        { title: "Push to", ignoreFocusOut: true },
      );
      if (!target) return;

      const suggestedName = path
        .basename(doc.uri.fsPath, path.extname(doc.uri.fsPath))
        .replace(/[-_]+/g, " ")
        .trim();

      if (target.id === "new") {
        const name = await vscode.window.showInputBox({
          title: "Template name",
          value: suggestedName,
          ignoreFocusOut: true,
        });
        if (!name?.trim()) return;

        const subject = await vscode.window.showInputBox({
          title: "Subject line",
          prompt: "Handlebars allowed, e.g. Welcome, {{full_name}}",
          value: extractTitle(html) || name,
          ignoreFocusOut: true,
        });
        if (!subject?.trim()) return;

        const created = await api.createTemplate({
          name: name.trim(),
          subject: subject.trim(),
          html_content: html,
        });
        await linkFile(extContext, doc.uri.fsPath, {
          templateId: created.id,
          name: name.trim(),
        });
        vscode.window.showInformationMessage(
          `Karma EDM: created ${created.id} (${uploaded.length} image(s) uploaded).`,
        );
        return;
      }

      progress.report({ message: "Loading templates…" });
      const templates = await api.listTemplates();
      const picked = await vscode.window.showQuickPick(
        templates.map((t) => ({
          label: t.name,
          description: t.id,
          detail: t.versions.find((v) => v.active === 1)?.subject ?? "",
          id: t.id,
        })),
        { title: "Update which template?", matchOnDescription: true, ignoreFocusOut: true },
      );
      if (!picked) return;

      await api.updateTemplate(picked.id, { html_content: html });
      await linkFile(extContext, doc.uri.fsPath, {
        templateId: picked.id,
        name: picked.label,
      });
      vscode.window.showInformationMessage(
        `Karma EDM: updated ${picked.label} (${uploaded.length} image(s) uploaded).`,
      );
    },
  );
}

// ── pull ────────────────────────────────────────────────────────────────────

async function pull(api: EDMApi): Promise<void> {
  const folder = vscode.workspace.workspaceFolders?.[0];
  if (!folder) throw new Error("Open a folder in VS Code first.");

  const templates = await api.listTemplates();
  const picked = await vscode.window.showQuickPick(
    templates.map((t) => ({
      label: t.name,
      description: t.id,
      detail: t.versions.find((v) => v.active === 1)?.subject ?? "",
      id: t.id,
    })),
    { title: "Pull which template?", matchOnDescription: true, ignoreFocusOut: true },
  );
  if (!picked) return;

  // The list response omits html_content by design (it would mean a storage
  // read per template), so fetch the full record for the chosen one.
  const full = await api.getTemplate(picked.id);
  const version = full.versions.find((v) => v.active === 1) ?? full.versions[0];
  if (!version?.html_content) {
    throw new Error("That template has no published version with html.");
  }

  const safeName =
    picked.label.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() ||
    "template";
  const target = vscode.Uri.joinPath(folder.uri, `${safeName}.html`);

  await vscode.workspace.fs.writeFile(target, Buffer.from(version.html_content, "utf8"));
  const doc = await vscode.workspace.openTextDocument(target);
  await vscode.window.showTextDocument(doc);
}

// ── open / pull from the tree ───────────────────────────────────────────────

/**
 * Open one template as a real file in the workspace.
 *
 * Written to disk rather than shown as an untitled buffer so the normal edit
 * loop works: save, preflight-on-save fires, then Push. An untitled document
 * has no directory, which would also break relative image resolution on push.
 */
async function openTemplate(api: EDMApi, template: EDMFolderTemplate): Promise<void> {
  const root = requireWorkspace();

  const full = await api.getTemplate(template.id);
  const version = full.versions.find((v) => v.active === 1) ?? full.versions[0];
  if (!version?.html_content) {
    throw new Error(
      `"${template.name}" has no published version with html — nothing to open.`,
    );
  }

  const dir = vscode.Uri.joinPath(root, "karma-edm");
  await vscode.workspace.fs.createDirectory(dir);

  const target = vscode.Uri.joinPath(dir, `${slug(template.name)}.html`);
  await vscode.workspace.fs.writeFile(target, Buffer.from(version.html_content, "utf8"));

  await linkFile(extContext, target.fsPath, {
    templateId: template.id,
    name: template.name,
  });

  const doc = await vscode.workspace.openTextDocument(target);
  await vscode.window.showTextDocument(doc, { preview: false });

  vscode.window.setStatusBarMessage(
    `Karma EDM: linked to ${template.name} — Push updates it directly`,
    6000,
  );
}

/**
 * Pull a whole folder to disk in the layout designers actually work in:
 *
 *   karma-edm/<folder>/<template>.html
 *   karma-edm/<folder>/images/<file>
 *
 * Images are rewritten from their public URLs back to `images/<file>` so the
 * files are editable and previewable locally. Push re-uploads them and rewrites
 * forward again — content addressing means an unchanged image is deduped rather
 * than duplicated, so the round trip costs nothing.
 */
async function pullFolder(api: EDMApi, node: EDMNode | undefined): Promise<void> {
  const root = requireWorkspace();

  const folderId = node && node.kind === "folder" ? node.id : null;
  const folderName = node && node.kind === "folder" ? node.label : "all-templates";

  await vscode.window.withProgress(
    { location: vscode.ProgressLocation.Notification, title: "Karma EDM", cancellable: false },
    async (progress) => {
      progress.report({ message: `Reading ${folderName}…` });
      const data = await api.browse(folderId);

      if (data.templates.length === 0 && data.assets.length === 0) {
        vscode.window.showInformationMessage(
          `Karma EDM: "${folderName}" has no templates or images to pull.`,
        );
        return;
      }

      const dir = vscode.Uri.joinPath(root, "karma-edm", slug(folderName));
      const imagesDir = vscode.Uri.joinPath(dir, "images");
      await vscode.workspace.fs.createDirectory(dir);

      // url → local filename, so html rewriting below is a plain lookup.
      const localNames = new Map<string, string>();

      if (data.assets.length > 0) {
        await vscode.workspace.fs.createDirectory(imagesDir);
      }

      for (const [i, asset] of data.assets.entries()) {
        progress.report({ message: `Images (${i + 1}/${data.assets.length})…` });
        const filename = asset.path.split("/").pop() ?? `image-${i}`;
        try {
          const bytes = await api.downloadAsset(asset.url);
          await vscode.workspace.fs.writeFile(
            vscode.Uri.joinPath(imagesDir, filename),
            bytes,
          );
          localNames.set(asset.url, filename);
        } catch {
          // A single unreachable image must not abort the whole pull; the html
          // simply keeps its absolute URL for that one.
        }
      }

      for (const [i, t] of data.templates.entries()) {
        progress.report({ message: `Templates (${i + 1}/${data.templates.length})…` });
        const full = await api.getTemplate(t.id);
        const version = full.versions.find((v) => v.active === 1) ?? full.versions[0];
        if (!version?.html_content) continue;

        let html = version.html_content;
        for (const [url, filename] of localNames) {
          html = html.split(url).join(`images/${filename}`);
        }

        const target = vscode.Uri.joinPath(dir, `${slug(t.name)}.html`);
        await vscode.workspace.fs.writeFile(target, Buffer.from(html, "utf8"));
        await linkFile(extContext, target.fsPath, { templateId: t.id, name: t.name });
      }

      vscode.window.showInformationMessage(
        `Karma EDM: pulled ${data.templates.length} template(s) and ${localNames.size} image(s) into karma-edm/${slug(folderName)}/`,
      );
    },
  );
}

function requireWorkspace(): vscode.Uri {
  const folder = vscode.workspace.workspaceFolders?.[0];
  if (!folder) throw new Error("Open a folder in VS Code first.");
  return folder.uri;
}

const slug = (name: string): string =>
  name.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "untitled";

const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

const extractTitle = (html: string): string =>
  (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "").replace(/\s+/g, " ").trim();
