import Handlebars from "handlebars";
import { createHash } from "node:crypto";
import { convert as htmlToText } from "html-to-text";
import type { EDMTemplateObject } from "@/lib/storage/edm-storage";

/**
 * Handlebars rendering for locally-stored EDM templates.
 *
 * ── Why this file is the risky part of the migration ─────────────────────────
 *
 * Today we do not render anything: the SMTP worker hands SendGrid a template id
 * plus a data bag and SendGrid's own Handlebars engine merges them server-side.
 * Moving templates to GCS means we stop renting that engine and have to be it.
 *
 * Stock Handlebars is NOT a drop-in for SendGrid's dialect — SendGrid registers
 * extra helpers that stock Handlebars has never heard of. How that fails was
 * measured, not assumed:
 *
 *   {{#greaterThan a 1}}…{{/greaterThan}}   → THROWS Missing helper
 *   {{formatDate d 'YYYY'}}                 → THROWS Missing helper
 *   {{#hasPoints}}…{{/hasPoints}}           → renders "" — SILENT
 *
 * Handlebars throws whenever the block carries arguments, because nothing but a
 * helper call can take arguments. Every SendGrid-specific helper takes
 * arguments, so a missing one fails LOUDLY and renderEDM's throw lets the
 * worker's fallback ladder drop to the SendGrid path rather than send a mangled
 * email.
 *
 * The silent case is the argument-free `{{#name}}`, which is genuinely
 * ambiguous with a legitimate truthiness section over a data field. That one
 * cannot be caught at render time — see findUnknownBlockHelpers below, which
 * surfaces it as a preflight warning instead.
 *
 * Treat the helper set here as *unverified against production SendGrid output*
 * until the golden-fixture corpus runs green. It is written from SendGrid's
 * documented behaviour, and documented behaviour is not the same as observed
 * behaviour. Diff real templates before trusting it.
 *
 * Do not use the main core's replaceTemplatePlaceholders() as a reference — it
 * is a naive /\{\{(\w+)\}\}/g regex on a logging side-path that handles no
 * conditionals, no loops, and no dot notation.
 */

/**
 * Isolated environment — helpers registered on the global Handlebars singleton
 * would leak into any other consumer in this process (and into tests, where the
 * ordering would make failures non-deterministic).
 */
const hb = Handlebars.create();

/** Loose equality on purpose: SendGrid compares "1" and 1 as equal. */
const looseEquals = (a: unknown, b: unknown): boolean =>
  // eslint-disable-next-line eqeqeq
  a == b;

const toNumber = (v: unknown): number => {
  if (typeof v === "number") return v;
  if (typeof v === "string" && v.trim() !== "") return Number(v);
  return Number.NaN;
};

/**
 * Block helpers take (…args, options) and must support {{else}}, so each one
 * returns options.fn(this) or options.inverse(this).
 */
type BlockOptions = Handlebars.HelperOptions;

const registerBlock = (
  name: string,
  predicate: (...args: unknown[]) => boolean,
) => {
  hb.registerHelper(name, function (this: unknown, ...args: unknown[]) {
    const options = args.pop() as BlockOptions;
    // Used as a subexpression — {{#if (and a b)}} — there is no fn to call, so
    // return the boolean itself rather than rendered output.
    if (!options || typeof options.fn !== "function") {
      return predicate(...args) as unknown as string;
    }
    return predicate(...args) ? options.fn(this) : options.inverse(this);
  });
};

registerBlock("equals", (a, b) => looseEquals(a, b));
registerBlock("notEquals", (a, b) => !looseEquals(a, b));
registerBlock("greaterThan", (a, b) => toNumber(a) > toNumber(b));
registerBlock("lessThan", (a, b) => toNumber(a) < toNumber(b));
registerBlock("and", (...args) => args.every(Boolean));
registerBlock("or", (...args) => args.some(Boolean));

/**
 * {{insert var "default"}} — emit the value, or the fallback when it is missing
 * or empty. Non-block, and escaped like a normal {{ }} expression.
 */
hb.registerHelper("insert", function (value: unknown, fallback: unknown) {
  const resolved =
    value === undefined || value === null || value === "" ? fallback : value;
  // The trailing options object arrives as `fallback` when no default was given.
  if (typeof resolved === "object") return "";
  return resolved ?? "";
});

// ── compiled-template cache ─────────────────────────────────────────────────
//
// Compiling a 100 KB email template costs ~10-50ms; rendering a compiled one
// costs ~1-3ms. A blast reuses one template across every recipient, so without
// this cache the render cost is an order of magnitude higher than it needs to
// be. Keyed by content hash, so an edited template simply misses.

const CACHE_MAX = 200;
const compiledCache = new Map<string, HandlebarsTemplateDelegate>();

const hashOf = (source: string): string =>
  createHash("sha256").update(source).digest("hex");

const compile = (source: string): HandlebarsTemplateDelegate => {
  const key = hashOf(source);
  const hit = compiledCache.get(key);
  if (hit) {
    // Refresh recency — Map preserves insertion order, so re-inserting moves
    // this entry to the newest position for the eviction below.
    compiledCache.delete(key);
    compiledCache.set(key, hit);
    return hit;
  }

  const compiled = hb.compile(source, {
    // A missing variable renders empty, matching SendGrid, rather than throwing.
    strict: false,
    // Do not let a template reach into prototypes via {{constructor.…}}.
    noEscape: false,
  });

  compiledCache.set(key, compiled);
  if (compiledCache.size > CACHE_MAX) {
    const oldest = compiledCache.keys().next().value;
    if (oldest !== undefined) compiledCache.delete(oldest);
  }
  return compiled;
};

export type RenderedEDM = {
  subject: string;
  html: string;
  plain: string;
};

export class EDMRenderError extends Error {
  constructor(
    message: string,
    readonly part: "subject" | "html",
    readonly cause?: unknown,
  ) {
    super(message);
    this.name = "EDMRenderError";
  }
}

/**
 * Merge a stored template with a recipient's data.
 *
 * Throws rather than returning partial output: a half-rendered marketing email
 * is worse than a deferred one, and the worker's fallback ladder needs a clear
 * signal to drop to the SendGrid path instead of sending something broken.
 */
export const renderEDM = (
  template: EDMTemplateObject,
  data: Record<string, unknown> = {},
): RenderedEDM => {
  let subject: string;
  try {
    // Rendered, not passed through: subject lines carry Handlebars too, and
    // nothing upstream of the worker supplies one.
    subject = compile(template.subject ?? "")(data);
  } catch (err) {
    throw new EDMRenderError(
      `Failed rendering EDM subject: ${(err as Error).message}`,
      "subject",
      err,
    );
  }

  let html: string;
  try {
    html = compile(template.html ?? "")(data);
  } catch (err) {
    throw new EDMRenderError(
      `Failed rendering EDM html: ${(err as Error).message}`,
      "html",
      err,
    );
  }

  /**
   * A stored plain body always wins, even when generatePlain is set.
   *
   * For every backfilled template that stored text IS SendGrid's own
   * plain_content, and it is materially better than anything derived here:
   * measured against a real template, the closest html-to-text configuration
   * reproduced only 55% of SendGrid's lines and invented 9 of its own. Their
   * renderer makes judgement calls we cannot reverse-engineer from options —
   * dropping decorative image alts while keeping an alt that is a link's only
   * content, emitting *bold* markers, choosing where hrefs appear.
   *
   * Regenerating would throw that away and silently downgrade the plain-text
   * half of every migrated email. So html-to-text is the fallback for templates
   * authored here with no plain body, not the default path.
   *
   * Rendered as a template either way — the stored text carries the same
   * Handlebars placeholders as the html.
   */
  const stored = (template.plain ?? "").trim();
  const plain = stored
    ? compile(template.plain ?? "")(data)
    : template.generatePlain
      ? toPlainText(html)
      : "";

  return { subject, html, plain };
};

/**
 * Stand-in for SendGrid's generate_plain_content.
 *
 * Close, not identical — SendGrid derives plain text server-side and we cannot
 * see their algorithm. Anywhere the exact bytes matter (the main core stores
 * this string in Viewpoint activity logs via retrieveTemplate), expect drift.
 */
export const toPlainText = (html: string): string =>
  htmlToText(html, {
    wordwrap: false,
    selectors: [
      // Tracking pixels, spacer gifs and rule images would otherwise become
      // stray "[image]" or noise like "line" and "maps".
      { selector: "img", format: "skip" },
      // SendGrid prints the href even when it equals the link text
      // ("a@b.com ( a@b.com )"), so match that rather than hiding it.
      { selector: "a", options: { hideLinkHrefIfSameAsText: false } },
    ],
  });

/** Exposed for the preflight checks and the backfill's parity diffing. */
export const clearRenderCache = (): void => {
  compiledCache.clear();
};

/** Every block helper this renderer knows, including Handlebars' built-ins. */
export const KNOWN_BLOCK_HELPERS = new Set([
  "if",
  "unless",
  "each",
  "with",
  "equals",
  "notEquals",
  "greaterThan",
  "lessThan",
  "and",
  "or",
]);

/**
 * Find `{{#name}}` blocks whose helper we do not implement.
 *
 * Covers the one case the renderer cannot fail loudly on. An unregistered block
 * WITH arguments throws at render time; an argument-free `{{#name}}` does not,
 * because it is indistinguishable from a legitimate truthiness section over a
 * data field — Handlebars looks the name up in the context, finds nothing, and
 * renders the inverse. The block's contents vanish with nothing in any log.
 *
 * Making that throw would break templates that correctly use `{{#has_points}}`
 * over their data. So it is surfaced here as a preflight warning instead, where
 * a human can look at the name and say whether it is a data field or a helper
 * we forgot to shim.
 *
 * Regex rather than an AST walk on purpose — this runs on unvalidated,
 * frequently malformed email html, where the parser would throw before it could
 * report anything useful.
 */
export const findUnknownBlockHelpers = (
  html: string,
): { name: string; count: number; hasArguments: boolean }[] => {
  const found = new Map<string, { count: number; hasArguments: boolean }>();

  for (const match of html.matchAll(/\{\{#\s*([a-zA-Z_$][\w$.-]*)([^}]*)\}\}/g)) {
    const name = match[1] ?? "";
    if (!name || KNOWN_BLOCK_HELPERS.has(name)) continue;

    // Arguments are the tell. `{{#foo}}` is plausibly a context section, but
    // `{{#foo a b}}` can only be a helper call — nothing else takes arguments.
    const hasArguments = (match[2] ?? "").trim().length > 0;
    const prev = found.get(name);
    found.set(name, {
      count: (prev?.count ?? 0) + 1,
      hasArguments: (prev?.hasArguments ?? false) || hasArguments,
    });
  }

  return [...found.entries()]
    .map(([name, v]) => ({ name, ...v }))
    // Argument-taking names first: those are near-certainly missing helpers,
    // not data fields.
    .sort((a, b) => Number(b.hasArguments) - Number(a.hasArguments));
};
