/**
 * The arc (åre) layer — MR-F9 «innenfra og ut» output contract (N7.5).
 *
 * Discovery ranks candidates on SENDER fit ("does this suit my pillars"). This layer inverts the
 * view: it reasons over the demand-sweep's READER-side judgment — which questions are actually asked
 * (etterspørsel), whether the sender can answer them (kan-svare), and whether they are already
 * answered elsewhere (ikke-besvart) — and groups related records into VEINS (arcs), the unit a warm
 * topic really is. Each question in a vein is one actionable edition.
 *
 * Pure + deterministic by contract (like brief.ts): no fs, no clock, no AI, no network. The reader
 * judgments (readerQuestion / painPoint / saturation / demand / verdict / actionability) are the
 * demand-spotter agent's output, persisted on the record; this module only combines + classifies +
 * ranks + renders them. GENERIC BY ARCHITECTURE: no niche calibration here — what counts as demand
 * or as a grip is the sweep's judgment, carried by the fields, never hard-coded.
 */

import type { TrendRecord, TrendVerdict, DemandSignal } from "./types.js";

/**
 * The honest market verdict for a question (§4 "ærlig markedsdom"). The asymmetry the engine MUST
 * express: a gap in the SUPPLY (many ask, none answer = gold) is not a gap in the DEMAND (none ask,
 * but the sender can answer = highest value, lowest guaranteed audience). `saturated` = answered
 * already (don't write). `unknown` = the sweep did not measure enough to judge (honest null).
 */
export type MarketGap = "supply-gap" | "demand-gap" | "saturated" | "unknown";

/** A reader-side view of the demand inputs, redeclared structurally so callers pass a plain shape. */
interface GapInputs {
  demand?: DemandSignal;
  verdict?: TrendVerdict;
  actionability?: { formulated: boolean };
}

/**
 * Classify the honest market verdict (§4). Answered ⇒ saturated. Unanswered-and-asked (strong/
 * moderate) ⇒ supply-gap. Unanswered-but-thin, yet the sender can carry it (BÆRENDE verdict or a
 * formulated grip) ⇒ demand-gap. Everything else (no demand, or thin with no way to answer) ⇒
 * unknown — never invented into a gap. Pure.
 */
export function classifyMarketGap(r: GapInputs): MarketGap {
  const d = r.demand;
  if (!d) return "unknown";
  if (d.answered) return "saturated";
  if (d.strength === "strong" || d.strength === "moderate") return "supply-gap";
  // thin, unanswered — a demand-gap only when the sender can actually answer it.
  if (r.verdict === "BÆRENDE" || r.actionability?.formulated === true) return "demand-gap";
  return "unknown";
}

/** One reader question inside a vein — the record projected onto its demand-side facets, plus the derived gap + rank. */
export interface ArcQuestion {
  id: string;
  /** The reader's OWN words (pass 3) — falls back to the record title when no readerQuestion is set. */
  question: string;
  /** The field's words (the record title) — kept alongside so the "begge, side om side" contract holds. */
  fieldQuestion: string;
  painPoint?: string;
  saturation?: string;
  verdict?: TrendVerdict;
  /** actionability.formulated — whether a reader-actionable grip is formulated. */
  gripFormulated?: boolean;
  demand?: DemandSignal;
  /** The honest market verdict (§4). */
  gap: MarketGap;
  /** Descending demand rank: `etterspørsel × kan-svare × ikke-besvart` (higher = write this first). */
  rank: number;
}

/** etterspørsel — how strongly asked. Absent (unmeasured) ⇒ 0, so an unmeasured question never floats up. */
function demandWeight(d: DemandSignal | undefined): number {
  if (!d) return 0;
  return d.strength === "strong" ? 3 : d.strength === "moderate" ? 2 : 1;
}

/** ikke-besvart — false (open) outweighs unmeasured outweighs answered (saturated ⇒ 0, sinks). */
function openWeight(d: DemandSignal | undefined): number {
  if (!d) return 1; // unmeasured openness — between answered and known-open
  return d.answered ? 0 : 2;
}

/** kan-avsenderen-svare — the verdict (BÆRENDE > STØTTE > NYHET) plus a bump for a formulated grip. Min 1. */
function answerWeight(verdict: TrendVerdict | undefined, grip: boolean | undefined): number {
  const base = verdict === "BÆRENDE" ? 3 : verdict === "STØTTE" ? 2 : 1;
  return base + (grip ? 1 : 0);
}

/**
 * Project a record onto a reader question: reader's words with a title fallback, the demand-side
 * facets, the derived gap, and the multiplicative rank. Multiplicative by design (§4's `×`): a zero
 * on any axis kills the rank — answered ⇒ saturated ⇒ 0, unmeasured demand ⇒ 0 — so saturated and
 * unmeasured questions sink beneath any genuinely-asked, unanswered one. Pure.
 */
export function toArcQuestion(t: TrendRecord): ArcQuestion {
  const grip = t.actionability?.formulated;
  const rank = demandWeight(t.demand) * openWeight(t.demand) * answerWeight(t.verdict, grip);
  return {
    id: t.id,
    question: t.readerQuestion && t.readerQuestion.trim().length > 0 ? t.readerQuestion : t.title,
    fieldQuestion: t.title,
    ...(t.painPoint !== undefined ? { painPoint: t.painPoint } : {}),
    ...(t.saturation !== undefined ? { saturation: t.saturation } : {}),
    ...(t.verdict !== undefined ? { verdict: t.verdict } : {}),
    ...(grip !== undefined ? { gripFormulated: grip } : {}),
    ...(t.demand !== undefined ? { demand: t.demand } : {}),
    gap: classifyMarketGap(t),
    rank,
  };
}

/** rank desc, then id asc — a total order (id is the store's unique dedupe hash), so the output is fixed. */
function byRankThenId(a: ArcQuestion, b: ArcQuestion): number {
  return b.rank - a.rank || a.id.localeCompare(b.id);
}

/** Rank a set of records as arc questions on `etterspørsel × kan-svare × ikke-besvart`. Pure, total order. */
export function rankArcQuestions(records: TrendRecord[]): ArcQuestion[] {
  return records.map(toArcQuestion).sort(byRankThenId);
}

/** A vein: related records clustered, their questions ranked, and split into bearing (BÆRENDE) vs support. */
export interface Arc {
  /** Stable arc id — the lexicographically-smallest member id (deterministic, independent of input order). */
  id: string;
  /** The vein's questions, ranked (rankArcQuestions order). */
  questions: ArcQuestion[];
  /** The bearing questions (verdict BÆRENDE) — what an edition is built on. */
  bearing: ArcQuestion[];
  /** The supporting questions (STØTTE / NYHET / unjudged) — context around the bearing ones. */
  support: ArcQuestion[];
  /** The vein's honest market verdict — the top-ranked question's gap (the dominant signal). */
  gap: MarketGap;
}

/**
 * Cluster records into veins by relatedIds TRANSITIVE closure (A→B, B→C ⇒ {A,B,C}), undirected: an
 * edge exists if either record names the other. A record with no link and none pointing at it is its
 * own singleton vein. Union-find over the record set; ids naming absent records are ignored. Pure.
 */
export function groupIntoArcs(records: TrendRecord[]): Arc[] {
  const index = new Map(records.map((r) => [r.id, r]));
  const parent = new Map<string, string>(records.map((r) => [r.id, r.id]));

  const find = (x: string): string => {
    let root = x;
    while (parent.get(root) !== root) root = parent.get(root)!;
    // Path-compress for stability across repeated finds.
    let cur = x;
    while (parent.get(cur) !== root) {
      const next = parent.get(cur)!;
      parent.set(cur, root);
      cur = next;
    }
    return root;
  };
  const union = (a: string, b: string): void => {
    const ra = find(a);
    const rb = find(b);
    if (ra === rb) return;
    // Attach the lexicographically-greater root under the smaller, so the component root is its min id.
    if (ra < rb) parent.set(rb, ra);
    else parent.set(ra, rb);
  };

  for (const r of records) {
    for (const rel of r.relatedIds ?? []) {
      if (index.has(rel)) union(r.id, rel);
    }
  }

  const groups = new Map<string, TrendRecord[]>();
  for (const r of records) {
    const root = find(r.id);
    const bucket = groups.get(root);
    if (bucket) bucket.push(r);
    else groups.set(root, [r]);
  }

  const arcs: Arc[] = [];
  for (const [root, members] of groups) {
    const questions = rankArcQuestions(members);
    arcs.push({
      id: root,
      questions,
      bearing: questions.filter((q) => q.verdict === "BÆRENDE"),
      support: questions.filter((q) => q.verdict !== "BÆRENDE"),
      gap: questions[0]?.gap ?? "unknown",
    });
  }

  // Veins ordered by their hottest question, then arc id — highest-demand vein first, deterministically.
  return arcs.sort((a, b) => (b.questions[0]?.rank ?? 0) - (a.questions[0]?.rank ?? 0) || a.id.localeCompare(b.id));
}

/** The human-readable market verdict line per gap (§4) — the honest "write here / don't". */
function marketVerdictLine(gap: MarketGap): string {
  switch (gap) {
    case "supply-gap":
      return "hull i tilbudet (mange spør, ingen svarer) — skriv her";
    case "demand-gap":
      return "hull i etterspørselen (få spør, men avsenderen kan svare) — høyest verdi, lavest garantert publikum";
    case "saturated":
      return "mettet (allerede besvart) — ikke skriv";
    case "unknown":
      return "tynt / umålt signal — ikke fyll ut";
  }
}

/** The compact demand token for a question line: strength + answered, when measured. */
function demandToken(q: ArcQuestion): string {
  if (!q.demand) return "umålt etterspørsel";
  return `etterspørsel: ${q.demand.strength} · besvart: ${q.demand.answered ? "ja" : "nei"}`;
}

/** Render one question's inventory entry — the reader's words lead; the field's words follow (both, side by side). */
function renderQuestion(q: ArcQuestion, n: number): string[] {
  const out = [`${n}. ${q.question} · ${demandToken(q)} · [${q.gap}] · rank ${q.rank}`];
  if (q.fieldQuestion !== q.question) out.push(`   - feltets ord: ${q.fieldQuestion}`);
  if (q.painPoint) out.push(`   - 🩹 smertepunkt: ${q.painPoint}`);
  const grip = q.gripFormulated === undefined ? "—" : q.gripFormulated ? "ja" : "nei";
  out.push(`   - 🎯 dom: ${q.verdict ?? "—"} · leser-grep: ${grip}`);
  if (q.saturation) out.push(`   - 🌡️ metning: ${q.saturation}`);
  return out;
}

/** A short, jargon-free vein label — the reader's words of its top question (never the field's title). */
function arcTitle(arc: Arc): string {
  return arc.questions[0]?.question ?? arc.id;
}

/**
 * Render the §4 output contract for a set of veins: per arc a jargon-free heading (the reader's
 * words), the honest market verdict, the ranked question inventory, and the BÆRENDE-vs-STØTTE reading
 * order. Deterministic — same arcs → byte-identical output (pure, no clock/fs). An empty set renders
 * an explicit marker rather than nothing.
 */
export function renderArcs(arcs: Arc[]): string {
  const lines: string[] = ["# Årer — etterspørsels-kart (innenfra og ut)", ""];
  if (arcs.length === 0) {
    lines.push("_Ingen årer — tynt signal i denne kjøringen._", "");
    return lines.join("\n") + "\n";
  }
  arcs.forEach((arc, i) => {
    lines.push(`## Åre ${i + 1}: ${arcTitle(arc)}`);
    lines.push(`- **Ærlig markedsdom:** ${marketVerdictLine(arc.gap)}`);
    lines.push("");
    lines.push("### Spørsmålsinventar (rangert: etterspørsel × kan-svare × ikke-besvart)");
    arc.questions.forEach((q, n) => lines.push(...renderQuestion(q, n + 1)));
    lines.push("");
    lines.push("### Arc-forslag (leserekkefølge)");
    lines.push(`- BÆRENDE: ${arc.bearing.length > 0 ? arc.bearing.map((q) => q.question).join(" · ") : "—"}`);
    lines.push(`- STØTTE: ${arc.support.length > 0 ? arc.support.map((q) => q.question).join(" · ") : "—"}`);
    lines.push("");
  });
  return lines.join("\n") + "\n";
}
