/**
 * The shape of a `nextjs-seo-prerender --json` document, and the two readings we
 * take of it: the teaser a stranger may see, and the grouping the dashboard
 * renders.
 *
 * Typed against the tool's contract (docs/contract.md §4). Every key the
 * contract marks as omissible is optional here — the contract is explicit that
 * an absent key means "never measured", which is a different fact from zero,
 * so nothing below substitutes a default for a missing measurement.
 */

export type Severity = "info" | "warn" | "error";

type Outcome = "checked" | "redirect" | "http-error" | "not-html" | "unreachable";

type RenderMode = "static" | "isr" | "cached" | "dynamic" | "unclear";

type Finding = {
  kind: string;
  severity: Severity;
  message: string;
  detail?: string[];
};

export type Page = {
  url: string;
  outcome: Outcome;
  status?: number;
  ttfb_ms?: number;
  render_mode?: RenderMode;
  words?: number;
  internal_links?: number;
  redirects_to?: string;
  findings: Finding[];
};

type Summary = {
  checked: number;
  dynamic: number;
  unclear: number;
  noindex_header: number;
  hreflang_drift: number;
  hreflang_dead_target: number;
  empty_html: number;
  sitemap_errors: number;
  unreachable: number;
  blocking: number;
};

type Discovery = {
  source: string;
  total: number;
  checked: number;
  rebased?: string;
};

type Fatal = { code: string; message: string; detail?: string[] };

/** A run that happened. `fatal` is always present, and null here (§4.1). */
export type RunReport = {
  tool: string;
  version: string;
  checked_at_unix: number;
  interrupted: boolean;
  notes: string[];
  fatal: null;
  site: string;
  discovery: Discovery;
  pages: Page[];
  summary: Summary;
  warnings: number;
  exit_code: number;
};

/** A run that could not happen. A different key set entirely (§4.5). */
export type FatalReport = {
  tool: string;
  version: string;
  checked_at_unix: number;
  interrupted: boolean;
  notes: string[];
  site: string;
  fatal: Fatal;
  exit_code: number;
};

export type Report = RunReport | FatalReport;

/** The contract's own branch: a consumer decides on `fatal`, never on stderr. */
export function isFatal(report: Report): report is FatalReport {
  return report.fatal !== null;
}

/**
 * Parse and vet a document the binary wrote.
 *
 * Only enough is checked to know which of the two shapes we hold — the binary
 * is the authority on the rest, and inventing defaults for fields it did not
 * write would put numbers on screen that nothing measured.
 */
export function parseReport(raw: string): Report | null {
  try {
    return vetReport(JSON.parse(raw));
  } catch {
    return null;
  }
}

/** The same vetting, for a document Postgres already handed back as jsonb. */
export function vetReport(value: unknown): Report | null {
  if (typeof value !== "object" || value === null) return null;
  const doc = value as Record<string, unknown>;
  if (doc.tool !== "nextjs-seo-prerender") return null;
  if (typeof doc.exit_code !== "number") return null;
  // §4.1: the run envelope never omits `fatal`, which is exactly what makes it
  // safe to branch on.
  if (!("fatal" in doc)) return null;
  if (doc.fatal === null && typeof doc.summary !== "object") return null;
  return doc as unknown as Report;
}

export type Verdict = "clean" | "blocking" | "warned" | "unverified" | "interrupted" | "fatal";

export const VERDICT_LABEL: Record<Verdict, string> = {
  clean: "Rien de bloquant",
  warned: "Rien de bloquant, des avertissements",
  blocking: "Bloquant pour l'indexation",
  unverified: "Aucune page n'a pu être vérifiée",
  interrupted: "Interrompu",
  fatal: "Rien n'a pu être vérifié",
};

/**
 * `unverified` is distinct from `fatal`, and the distinction is the contract's.
 *
 * A run whose pages all answer 3xx or serve something other than HTML counts in
 * none of the summary counters (§4.4): zero blocking, zero warnings, and a
 * document that exits 0. Read on `blocking` alone it is indistinguishable from a
 * healthy site — which is how a domain whose sitemap points at another host came
 * out green while nothing at all had been audited.
 *
 * It comes after `blocking` on purpose. Unreachable pages verify nothing either,
 * but the tool files a blocking finding for each, and "we could not reach your
 * site" is the more useful of the two true things to say.
 */
export function verdictOf(report: Report): Verdict {
  if (isFatal(report)) return "fatal";
  if (report.interrupted) return "interrupted";
  if (report.summary.blocking > 0) return "blocking";
  if (report.summary.checked === 0) return "unverified";
  return report.warnings > 0 ? "warned" : "clean";
}

/** The same reading, from the columns we lifted out of the JSON. */
export function verdictOfCounters(counters: {
  exitCode: number;
  blocking: number;
  warnings: number;
  checked: number;
}): Verdict {
  if (counters.exitCode === 130) return "interrupted";
  if (counters.exitCode === 2) return "fatal";
  if (counters.blocking > 0) return "blocking";
  if (counters.checked === 0) return "unverified";
  return counters.warnings > 0 ? "warned" : "clean";
}

/**
 * What an unauthenticated poller is allowed to learn.
 *
 * Deliberately a hand-built object rather than a subset picked off the report:
 * a whitelist that must be extended to leak is safer than a blacklist that
 * leaks when the tool adds a field. No URL, no message, no detail — the two
 * kinds are slugs from a frozen list (§6), which name a problem without naming
 * a page. The full report is never served by an API route at all; it is read
 * server-side in the dashboard, after the session and the ownership check.
 */
export type Teaser = {
  verdict: Verdict;
  blocking: number;
  warnings: number;
  checked: number;
  total: number;
  topKinds: Array<{ kind: string; count: number }>;
};

export function teaserOf(report: Report): Teaser {
  const verdict = verdictOf(report);
  if (isFatal(report)) {
    return { verdict, blocking: 0, warnings: 0, checked: 0, total: 0, topKinds: [] };
  }
  return {
    verdict,
    blocking: report.summary.blocking,
    warnings: report.warnings,
    // `summary.checked`, never `discovery.checked`: the first counts the pages
    // the tool could read, the second every URL it fetched, redirects included.
    // Showing the second under "pages vues" is how ten 308s read as ten pages.
    checked: report.summary.checked,
    total: report.discovery.total,
    topKinds: countKinds(report)
      .filter((entry) => entry.severity !== "info")
      .slice(0, 2)
      .map(({ kind, count }) => ({ kind, count })),
  };
}

/** Finding kinds across the whole run, worst and most frequent first. */
function countKinds(
  report: RunReport,
): Array<{ kind: string; count: number; severity: Severity }> {
  const weight: Record<Severity, number> = { error: 2, warn: 1, info: 0 };
  const tally = new Map<string, { kind: string; count: number; severity: Severity }>();
  for (const page of report.pages) {
    for (const finding of page.findings) {
      const seen = tally.get(finding.kind);
      if (seen) seen.count += 1;
      else tally.set(finding.kind, { kind: finding.kind, count: 1, severity: finding.severity });
    }
  }
  return [...tally.values()].sort(
    (a, b) => weight[b.severity] - weight[a.severity] || b.count - a.count,
  );
}

/**
 * One row per (kind, severity), carrying the pages it fired on.
 *
 * The report groups by page; a reader triaging a site wants the opposite —
 * "what is wrong" first, "where" second — because one broken template produces
 * the same finding on forty pages and reading it forty times teaches nothing.
 */
export type FindingGroup = {
  kind: string;
  severity: Severity;
  message: string;
  pages: Array<{ url: string; detail?: string[] }>;
};

export function groupFindings(report: RunReport): FindingGroup[] {
  const weight: Record<Severity, number> = { error: 2, warn: 1, info: 0 };
  const groups = new Map<string, FindingGroup>();
  for (const page of report.pages) {
    for (const finding of page.findings) {
      const key = `${finding.kind}:${finding.severity}`;
      let group = groups.get(key);
      if (!group) {
        group = {
          kind: finding.kind,
          severity: finding.severity,
          message: finding.message,
          pages: [],
        };
        groups.set(key, group);
      }
      group.pages.push({ url: page.url, detail: finding.detail });
    }
  }
  return [...groups.values()].sort(
    (a, b) => weight[b.severity] - weight[a.severity] || b.pages.length - a.pages.length,
  );
}

/** Human wording for the frozen slugs (contract §6). Unknown kinds fall back. */
const KIND_LABEL: Record<string, string> = {
  "dynamic-render": "Rendu à chaque requête",
  "render-mode-unclear": "Mode de rendu indéterminable",
  "noindex-header": "noindex dans l'en-tête HTTP",
  "noindex-meta": "noindex dans le HTML",
  "indexability-conflict": "L'en-tête et le HTML se contredisent",
  "canonical-missing": "Canonique absente",
  "canonical-elsewhere": "Canonique vers une autre page",
  "hreflang-drift": "hreflang divergent entre l'en-tête et le HTML",
  "hreflang-dead-target": "Cible hreflang inexistante",
  "hreflang-one-sided": "hreflang présent d'un seul côté",
  "empty-server-html": "HTML servi quasiment vide",
  "thin-server-html": "HTML servi trop maigre",
  "no-server-text": "Aucun texte servi",
  "no-internal-links": "Aucun lien interne",
  "sitemap-url-error": "URL du sitemap en erreur",
  "sitemap-url-redirect": "URL du sitemap redirigée",
  "not-html": "Ressource non HTML",
  unreachable: "Page injoignable",
};

export function labelForKind(kind: string): string {
  return KIND_LABEL[kind] ?? kind;
}
