import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
  type CalculatedDeadline,
  type DeadlineResponse,
  bucketDeadlinesIntoColumns,
  calculateDeadlines,
  deadlineCardHtml,
  formatDurationLabel,
  renderColumnsBody,
  stripLeadingDurationFromNotes,
} from "./verfahrensablauf-core";

// Regression tests for the editable→click-to-edit wiring on timeline date
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
// date` on the date span. Pages then attach a delegated click handler that
// resolves that selector to swap in an inline `<input type="date">`. If a
// future refactor drops the attrs, /tools/verfahrensablauf and
// /tools/fristenrechner both silently lose click-to-edit (no script error,
// nothing happens on click). These tests pin the contract.
//
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
// in plain Node without jsdom).

const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
  code: "upc-rop-12",
  name: "Klageerwiderung",
  nameEN: "Statement of Defence",
  party: "defendant",
  priority: "mandatory",
  ruleRef: "",
  dueDate: "2026-07-15",
  originalDate: "2026-07-15",
  wasAdjusted: false,
  isRootEvent: false,
  isCourtSet: false,
  ...overrides,
});

describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
  test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
    const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
    expect(html).toContain('class="timeline-date frist-date-edit"');
    expect(html).toContain('data-rule-code="upc-rop-12"');
    expect(html).toContain('data-current-date="2026-07-15"');
    expect(html).toContain('role="button"');
    expect(html).toContain('tabindex="0"');
  });

  test("editable=false (default) emits the date span without click-to-edit attrs", () => {
    const html = deadlineCardHtml(dl(), { showParty: true });
    expect(html).toContain("timeline-date");
    expect(html).not.toContain("data-rule-code=");
    expect(html).not.toContain('role="button"');
  });

  test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
    const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
    expect(html).not.toContain("data-rule-code=");
  });

  test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
    const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
    expect(html).toContain("timeline-court-set frist-date-edit");
    expect(html).toContain('data-rule-code="upc-rop-12"');
  });

  test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
    const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
    expect(html).not.toContain("data-rule-code=");
  });
});

// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
// moved from an inline chip in the card header into the caret popover
// to fix horizontal-scroll on narrow viewports (the long German label
// pushed the card past its column width). The renderer now signals
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
// can surface the prominent "Wieder einblenden" popover entry when
// the user opens the menu. The legacy `.event-card-choices-unhide`
// inline chip class must NOT appear in the output.
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
  test("isHidden=true emits the hidden state-icon", () => {
    const html = deadlineCardHtml(
      dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
      { showParty: true },
    );
    expect(html).toContain("timeline-state-icon--hidden");
  });

  test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
    const html = deadlineCardHtml(
      dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
      { showParty: true },
    );
    expect(html).toContain('data-is-hidden="1"');
    expect(html).toContain("event-card-choices-caret");
  });

  test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
    const html = deadlineCardHtml(
      dl({ choicesOffered: { skip: [true, false] } }),
      { showParty: true },
    );
    expect(html).not.toContain("timeline-state-icon--hidden");
    expect(html).toContain('data-is-hidden="0"');
  });

  test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
    // Edge case: admin edits the rule's choices_offered after a user
    // has already saved a `skip=true` choice. Without the fallback
    // the card would re-surface as hidden with no popover entrypoint
    // — the user would have no way to un-hide it. The renderer
    // synthesizes a `{skip:[true,false]}` offer so the prominent
    // "Wieder einblenden" button still renders in the popover.
    const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
    expect(html).toContain("event-card-choices-caret");
    expect(html).toContain('data-is-hidden="1"');
    expect(html).toContain("data-choices-offered=\"{&quot;skip&quot;:[true,false]}\"");
  });

  test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
    const html = deadlineCardHtml(dl(), { showParty: true });
    expect(html).not.toContain("event-card-choices-caret");
  });

  test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
    // Pinned to catch a regression that would re-introduce the
    // horizontal-scroll surface that motivated the move. The popover
    // now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
    // inside the body-attached popover dom node — never in the card
    // header HTML the renderer returns.
    const html = deadlineCardHtml(
      dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
      { showParty: true },
    );
    expect(html).not.toContain('class="event-card-choices-unhide"');
    expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
  });
});

// t-paliad-293: the `optional` priority used to render an inline text
// badge in the card title. The overhaul replaces it with a ⊙ state
// icon so the title row stays compact on narrow viewports. Tooltip is
// driven by the `state.optional.tooltip` i18n key.
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
  test("priority='optional' emits the timeline-state-icon--optional marker", () => {
    const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
    expect(html).toContain("timeline-state-icon--optional");
    expect(html).not.toContain("optional-badge");
  });

  test("priority='mandatory' (default) omits the optional marker", () => {
    const html = deadlineCardHtml(dl(), { showParty: true });
    expect(html).not.toContain("timeline-state-icon--optional");
  });
});

// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
// chip in place of the date column, and the chip keeps the click-to-edit
// affordance so the user can pin a real date once the upstream anchor
// resolves (oral hearing scheduled, opposing party's motion received, …).
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
// the issue.
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
  test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
    const html = deadlineCardHtml(
      dl({
        code: "upc.inf.cfi.translation_request",
        isConditional: true,
        parentRuleCode: "upc.inf.cfi.oral",
        parentRuleName: "Mündliche Verhandlung",
      }),
      { showParty: true, editable: true },
    );
    expect(html).toContain("timeline-conditional");
    expect(html).toContain("abhängig von Mündliche Verhandlung");
    expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
    expect(html).toContain('role="button"');
    expect(html).not.toContain("timeline-court-set");
  });

  test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
    const html = deadlineCardHtml(
      dl({ isConditional: true }),
      { showParty: true, editable: true },
    );
    expect(html).toContain("timeline-conditional");
    expect(html).toContain("abhängig von vorgelagertem Ereignis");
  });

  test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
    // Court-set ancestor without override sets BOTH isCourtSet=true AND
    // isConditional=true on the wire. The renderer must pick the
    // conditional chip; otherwise the row keeps the legacy "wird vom
    // Gericht bestimmt" label and the user can't see WHICH upstream
    // event blocks them.
    const html = deadlineCardHtml(
      dl({
        isConditional: true,
        isCourtSet: true,
        isCourtSetIndirect: true,
        parentRuleName: "Entscheidung",
      }),
      { showParty: true, editable: true },
    );
    expect(html).toContain("abhängig von Entscheidung");
    expect(html).not.toContain("timeline-court-set");
  });

  test("isConditional=false keeps the normal date span (regression guard)", () => {
    const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
    expect(html).toContain("timeline-date");
    expect(html).not.toContain("timeline-conditional");
  });
});


// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
// left") instead of the misleading Proaktiv/Reaktiv pair.
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
// in pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across both columns.
// With ?appellant= set, the duplicate must collapse to a single
// row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
  const both = (name: string, due: string): CalculatedDeadline => ({
    code: name,
    name,
    nameEN: name,
    party: "both",
    priority: "mandatory",
    ruleRef: "",
    dueDate: due,
    originalDate: due,
    wasAdjusted: false,
    isRootEvent: false,
    isCourtSet: false,
  });
  const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
    ...both(name, due),
    party,
  });

  test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
    const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
    expect(rows).toHaveLength(1);
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
    expect(rows[0].court).toHaveLength(0);
  });

  test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
    const rows = bucketDeadlinesIntoColumns([
      partySpecific("claimant", "Klageschrift", "2026-01-01"),
      partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
    ]);
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
    expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
  });

  test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
    const rows = bucketDeadlinesIntoColumns(
      [both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
      { appellant: "claimant" },
    );
    expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
      ["Notice of Appeal"],
      ["Statement of Grounds"],
    ]);
    rows.forEach((r) => expect(r.opponent).toHaveLength(0));
  });

  test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
    const rows = bucketDeadlinesIntoColumns(
      [both("Notice of Appeal", "2026-07-23")],
      { appellant: "defendant" },
    );
    expect(rows[0].ours).toHaveLength(0);
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
  });

  test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
    // User is on the defendant side: defendant filings land in 'ours'
    // (left), claimant filings land in 'opponent' (right). Court rules
    // stay in court regardless of side.
    const rows = bucketDeadlinesIntoColumns(
      [
        partySpecific("claimant", "Klageschrift", "2026-01-01"),
        partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
        partySpecific("court", "Urteil", "2026-10-01"),
      ],
      { side: "defendant" },
    );
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
    expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
    expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
  });

  test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
    // The user is the defendant AND the appellant, so the appellant's
    // column == the user's own column == ours after the swap.
    const rows = bucketDeadlinesIntoColumns(
      [both("Notice of Appeal", "2026-07-23")],
      { side: "defendant", appellant: "defendant" },
    );
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
    expect(rows[0].opponent).toHaveLength(0);
  });

  test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
    // Side flip + appellant axis combined: the claimant is the appellant
    // but NOT us, so the collapsed 'both' row lands in the opponent
    // column (right). This is the UPC Appeal "they appealed, we
    // respond" scenario.
    const rows = bucketDeadlinesIntoColumns(
      [both("Notice of Appeal", "2026-07-23")],
      { side: "defendant", appellant: "claimant" },
    );
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
    expect(rows[0].ours).toHaveLength(0);
  });

  test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
    // When the user has committed to a perspective via `?side=`, the
    // mirror is visual noise: the same card renders twice on one row,
    // once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
    // '↔ beide Seiten' indicator already conveys the both-parties
    // semantic, so collapsing into ours is sufficient.
    const rows = bucketDeadlinesIntoColumns(
      [both("Antrag auf Simultanübersetzung", "2026-04-27")],
      { side: "defendant" },
    );
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
    expect(rows[0].opponent).toHaveLength(0);
  });

  test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
    const rows = bucketDeadlinesIntoColumns(
      [both("Antrag auf Simultanübersetzung", "2026-04-27")],
      { side: "claimant" },
    );
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
    expect(rows[0].opponent).toHaveLength(0);
  });

  test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
    const sameDate = "2026-07-23";
    const rows = bucketDeadlinesIntoColumns([
      partySpecific("claimant", "A", sameDate),
      partySpecific("defendant", "B", sameDate),
      partySpecific("court", "C", sameDate),
    ]);
    expect(rows).toHaveLength(1);
    expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
    expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
  });

  test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
    // A per-decision pick stamps AppellantContext on descendants of
    // that decision. The bucketer prefers it over the page-level
    // appellant: if a "both" row carries appellantContext='defendant',
    // it collapses to defendant's column regardless of the global
    // appellant opt.
    const dl: CalculatedDeadline = {
      ...both("Notice of Appeal", "2026-07-23"),
      appellantContext: "defendant",
    };
    const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
    expect(rows[0].ours).toHaveLength(0);
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
  });

  test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
    // The user is on the defendant side; per-card pick says the
    // claimant appealed. The "both" row collapses to the claimant's
    // column, which after the side-swap is opponent (right).
    const dl: CalculatedDeadline = {
      ...both("Notice of Appeal", "2026-07-23"),
      appellantContext: "claimant",
    };
    const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
    expect(rows[0].ours).toHaveLength(0);
    expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
  });

  test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
    // 'both' and 'none' aren't side-collapse values — they're
    // statements about who appealed but don't pick a column. The
    // bucketer treats them as no override, so the page-level
    // appellant (or default mirror) applies.
    const both1: CalculatedDeadline = {
      ...both("Notice of Appeal", "2026-07-23"),
      appellantContext: "both",
    };
    const rowsBoth = bucketDeadlinesIntoColumns([both1]);
    expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
    expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
  });

  test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
    const rows = bucketDeadlinesIntoColumns([
      partySpecific("court", "Oral Hearing", ""),
      partySpecific("claimant", "Statement of Claim", "2026-01-01"),
      partySpecific("court", "Decision", ""),
    ]);
    expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
      ["Statement of Claim"],
      ["Oral Hearing"],
      ["Decision"],
    ]);
  });

});

// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
// only make sense once the user has picked a side. While the side is
// still "Nicht festgelegt" (side === null — the default after #120) the
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
// Picking a side re-enables the #88 labels. The bucketing primitive
// itself is unchanged — only the column-header text differs.
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
  const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
    code: name,
    name,
    nameEN: name,
    party,
    priority: "mandatory",
    ruleRef: "",
    dueDate: due,
    originalDate: due,
    wasAdjusted: false,
    isRootEvent: false,
    isCourtSet: false,
  });
  const data: DeadlineResponse = {
    proceedingType: "upc.inf.cfi",
    proceedingName: "UPC Verletzungsverfahren",
    triggerDate: "2026-01-01",
    deadlines: [
      dlFix("claimant", "Klageschrift", "2026-01-01"),
      dlFix("defendant", "Klageerwiderung", "2026-04-01"),
    ],
  };

  test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
    const html = renderColumnsBody(data, { side: null });
    expect(html).toContain(">Proaktiv<");
    expect(html).toContain(">Gericht<");
    expect(html).toContain(">Reaktiv<");
    expect(html).not.toContain(">Unsere Seite<");
    expect(html).not.toContain(">Gegnerseite<");
  });

  test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
    const html = renderColumnsBody(data);
    expect(html).toContain(">Proaktiv<");
    expect(html).toContain(">Reaktiv<");
  });

  test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
    const html = renderColumnsBody(data, { side: "claimant" });
    expect(html).toContain(">Unsere Seite<");
    expect(html).toContain(">Gericht<");
    expect(html).toContain(">Gegnerseite<");
    expect(html).not.toContain(">Proaktiv<");
    expect(html).not.toContain(">Reaktiv<");
  });

  test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
    // The user-perspective labels are picked once a side is set; the
    // bucketer still routes defendant filings into the `ours` column when
    // side=defendant, so the left column's header truthfully reads
    // "Unsere Seite" regardless of which underlying party occupies it.
    const html = renderColumnsBody(data, { side: "defendant" });
    expect(html).toContain(">Unsere Seite<");
    expect(html).toContain(">Gegnerseite<");
    expect(html).not.toContain(">Proaktiv<");
    expect(html).not.toContain(">Reaktiv<");
  });
});

// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
// All appeal rules carry party='both' (either side could be the
// appellant). With appealAware=true + dl.appealRole set, the bucketer
// routes by (filer matches user) instead of collapsing every 'both'
// row into the user's column. Without a side picked, the bucketer
// keeps the legacy mirror so every appeal rule is visible.
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
  const appeal = (
    name: string,
    role: "appellant" | "appellee",
    due: string,
  ): CalculatedDeadline => ({
    code: name,
    name,
    nameEN: name,
    party: "both",
    priority: "mandatory",
    ruleRef: "",
    dueDate: due,
    originalDate: due,
    wasAdjusted: false,
    isRootEvent: false,
    isCourtSet: false,
    appealRole: role,
  });

  const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
  const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
  const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");

  test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
    const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
      side: "claimant",
      appealAware: true,
    });
    const byKey = new Map(rows.map((r) => [r.key, r]));
    expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
    expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
    expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
    expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
  });

  test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
    const rows = bucketDeadlinesIntoColumns([notice, response], {
      side: "defendant",
      appealAware: true,
    });
    const byKey = new Map(rows.map((r) => [r.key, r]));
    expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
    expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
    expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
    expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
  });

  test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
    const rows = bucketDeadlinesIntoColumns([notice, response], {
      side: null,
      appealAware: true,
    });
    const byKey = new Map(rows.map((r) => [r.key, r]));
    expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
    expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
    expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
    expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
  });

  test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
    // Regression guard: a stale frontend that drops `appealAware: true`
    // must not silently route via appealRole — the side selector
    // would visibly change behaviour without a UI control to opt in.
    const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
    // Legacy "side without appellant" collapse → both rows into ours.
    const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
    expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
    rows.forEach((r) => expect(r.opponent).toHaveLength(0));
  });

  test("appealAware respects court party — court rows always route to court column", () => {
    const decision: CalculatedDeadline = {
      ...notice,
      name: "Entscheidung",
      party: "court",
      appealRole: "", // court events deliberately stay empty
      dueDate: "",
    };
    const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
    expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
    expect(rows[0].ours).toHaveLength(0);
    expect(rows[0].opponent).toHaveLength(0);
  });

  test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
    // A future appeal rule we forgot to map: appealRole='' falls
    // through the appealAware branch and lands in the legacy
    // side-collapse path → ours.
    const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
    const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
    expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
    expect(rows[0].opponent).toHaveLength(0);
  });
});

// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
// parent rule name (or the proceeding's trigger event label for
// root rules) so the chip reads "4 Monate nach Endentscheidung"
// instead of the dangling "4 Monate nach".
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
  const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
    code: "x",
    name: "x",
    nameEN: "x",
    party: "both",
    priority: "mandatory",
    ruleRef: "",
    dueDate: "",
    originalDate: "",
    wasAdjusted: false,
    isRootEvent: false,
    isCourtSet: false,
    durationValue: 4,
    durationUnit: "months",
    timing: "after",
    ...overrides,
  });

  test("with parent label: appends to head", () => {
    expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
      .toBe("4 Monate nach Endentscheidung (R.118)");
  });

  test("without parent label: bare head — caller decides whether to render", () => {
    expect(formatDurationLabel(dl())).toBe("4 Monate nach");
  });

  test("without timing: parent is not appended (degenerate phrasing)", () => {
    // No timing == we can't form "4 Monate <timing> <parent>" cleanly,
    // so the bare "4 Monate" head stays. Pinned to catch a future
    // edit that would emit "4 Monate Endentscheidung" without a
    // preposition.
    expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
  });

  test("singular value: switches to .one unit key", () => {
    expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
  });

  test("zero / missing duration: empty string", () => {
    expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
    expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
  });
});

describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
  test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
    // upc.apl.merits.notice has no parent_id but a 2-month duration
    // off the trigger event (the appealed decision). The duration
    // tooltip must read the appeal-target label, not just "2 Monate
    // nach".
    const dl: CalculatedDeadline = {
      code: "upc.apl.merits.notice",
      name: "Berufungseinlegung",
      nameEN: "Notice of Appeal",
      party: "both",
      priority: "mandatory",
      ruleRef: "",
      dueDate: "2026-07-26",
      originalDate: "2026-07-26",
      wasAdjusted: false,
      isRootEvent: false,
      isCourtSet: false,
      durationValue: 2,
      durationUnit: "months",
      timing: "after",
    };
    const html = deadlineCardHtml(dl, {
      showParty: false,
      editable: true,
      triggerEventLabel: "Endentscheidung (R.118)",
    });
    expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
  });

  test("non-root rule prefers parent rule name over triggerEventLabel", () => {
    // merits.response chains off merits.grounds; the duration label
    // should read "3 Monate nach Berufungsbegründung", not the
    // appeal-target fallback.
    const dl: CalculatedDeadline = {
      code: "upc.apl.merits.response",
      name: "Berufungserwiderung",
      nameEN: "Response to Appeal",
      party: "both",
      priority: "mandatory",
      ruleRef: "",
      dueDate: "2026-12-26",
      originalDate: "2026-12-26",
      wasAdjusted: false,
      isRootEvent: false,
      isCourtSet: false,
      durationValue: 3,
      durationUnit: "months",
      timing: "after",
      parentRuleCode: "upc.apl.merits.grounds",
      parentRuleName: "Berufungsbegründung",
      parentRuleNameEN: "Statement of Grounds",
    };
    const html = deadlineCardHtml(dl, {
      showParty: false,
      editable: true,
      triggerEventLabel: "Endentscheidung (R.118)",
    });
    expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
  });
});

// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
// substring is stripped before deadline_notes renders so the new
// duration affordance and the legacy free-text don't duplicate.
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
  test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
    const out = stripLeadingDurationFromNotes(
      "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
      "de",
    );
    expect(out).toBe("Antrag auf Simultanübersetzung.");
  });

  test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
    const out = stripLeadingDurationFromNotes(
      "Frist 15 Tage ab Zustellung der Kostenentscheidung",
      "de",
    );
    expect(out).toBe("");
  });

  test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
    const out = stripLeadingDurationFromNotes(
      "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
      "de",
    );
    expect(out).toBe("Spätestens 1 Jahr.");
  });

  test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
    const composite =
      "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
    expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
  });

  test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
    const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
    expect(out).toBe("Frist vom Gericht bestimmt");
  });

  test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
    const out = stripLeadingDurationFromNotes(
      "1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
      "en",
    );
    expect(out).toBe("Request for simultaneous interpretation.");
  });

  test("EN: strips '15-day period from …'", () => {
    const out = stripLeadingDurationFromNotes(
      "15-day period from service of the cost decision",
      "en",
    );
    expect(out).toBe("");
  });

  test("EN: strips 'Period is N <unit> from …'", () => {
    const out = stripLeadingDurationFromNotes(
      "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
      "en",
    );
    expect(out).toBe("Latest 12 months.");
  });

  test("EN: empty / non-matching notes pass through unchanged", () => {
    expect(stripLeadingDurationFromNotes("", "en")).toBe("");
    expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
      .toBe("Time limit set by the court");
  });
});

// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
// calculateDeadlines must forward `includeOptional` and
// `triggerEventAnchors` straight into the POST body so the Go handler
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
// future refactor drops the fields, the Builder triplet silently
// reverts to "engine emits optional rules" and the unified
// /tools/procedures page loses its naked-proceeding default.
describe("calculateDeadlines — forwards engine options into request body", () => {
  type CapturedRequest = { url: string; body: Record<string, unknown> };
  let captured: CapturedRequest | null;
  let originalFetch: typeof globalThis.fetch;

  beforeEach(() => {
    captured = null;
    originalFetch = globalThis.fetch;
    globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
      const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
      captured = { url: String(input), body };
      return new Response(JSON.stringify({
        proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
      }), { status: 200, headers: { "Content-Type": "application/json" } });
    }) as typeof globalThis.fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  test("default call omits includeOptional and triggerEventAnchors", async () => {
    await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
    expect(captured).not.toBeNull();
    expect(captured!.body.includeOptional).toBeUndefined();
    expect(captured!.body.triggerEventAnchors).toBeUndefined();
  });

  test("includeOptional=true sends includeOptional: true", async () => {
    await calculateDeadlines({
      proceedingType: "upc.inf.cfi",
      triggerDate: "2026-05-26",
      includeOptional: true,
    });
    expect(captured!.body.includeOptional).toBe(true);
  });

  test("includeOptional=false is omitted (matches engine default)", async () => {
    await calculateDeadlines({
      proceedingType: "upc.inf.cfi",
      triggerDate: "2026-05-26",
      includeOptional: false,
    });
    expect(captured!.body.includeOptional).toBeUndefined();
  });

  test("triggerEventAnchors forwarded as object", async () => {
    await calculateDeadlines({
      proceedingType: "upc.inf.cfi",
      triggerDate: "2026-05-26",
      triggerEventAnchors: {
        "upc.inf.cfi.oral": "2026-09-01",
        "upc.inf.cfi.decision": "2026-12-15",
      },
    });
    expect(captured!.body.triggerEventAnchors).toEqual({
      "upc.inf.cfi.oral": "2026-09-01",
      "upc.inf.cfi.decision": "2026-12-15",
    });
  });

  test("empty triggerEventAnchors is omitted", async () => {
    await calculateDeadlines({
      proceedingType: "upc.inf.cfi",
      triggerDate: "2026-05-26",
      triggerEventAnchors: {},
    });
    expect(captured!.body.triggerEventAnchors).toBeUndefined();
  });
});
