// Fristenrechner client-side logic
// 3-step wizard: select proceeding -> enter date -> view timeline
//
// Rendering primitives (renderTimelineBody / renderColumnsBody /
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
// date editor) live in `./views/verfahrensablauf-core` and are shared
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
// wants.

import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
import {
  type CalculatedDeadline,
  type DeadlineResponse,
  calculateDeadlines,
  escAttr,
  escHtml,
  formatDate,
  populateCourtPicker as populateCourtPickerCore,
  priorityRendering,
  renderColumnsBody,
  renderTimelineBody,
  wireDateEditClicks,
} from "./views/verfahrensablauf-core";

let lastResponse: DeadlineResponse | null = null;

// User overrides for individual rule due-dates (rule.code → YYYY-MM-DD).
// Set by the click-to-edit affordance on each timeline / column row;
// posted as `anchorOverrides` to /api/tools/fristenrechner so downstream
// rules re-anchor on the user's date instead of the calculator's
// projection. Cleared whenever the trigger changes (proceeding type,
// trigger date, flag toggle) so a fresh calc starts unanchored.
const anchorOverrides = new Map<string, string>();

function clearAnchorOverrides() {
  anchorOverrides.clear();
}

// Auto-calc plumbing: a sequence counter prevents stale fetches from clobbering
// fresher results, and a single timer debounces rapid input changes.
let procCalcSeq = 0;
let procCalcTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleProcCalc(delayMs = 200) {
  if (procCalcTimer !== null) clearTimeout(procCalcTimer);
  procCalcTimer = setTimeout(() => {
    procCalcTimer = null;
    void calculate();
  }, delayMs);
}

type ProcedureView = "timeline" | "columns";
// m's 2026-05-08 18:26 ask: Column view by default, Timeline opt-in.
// The proactive / court / reactive grid reads more naturally for the
// HLC team than the single vertical line.
let procedureView: ProcedureView = "columns";

// Notes toggle — off by default; per-rule notes render as a compact
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
// localStorage-persisted (paliad.fristen.notes-show key shared with
// /tools/verfahrensablauf so the preference carries across both).
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
  try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
  try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();

onLangChange(() => {
  if (lastResponse) renderProcedureResults(lastResponse);
  // Update trigger event name if a proceeding is selected
  const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
  if (activeBtn) {
    const name = activeBtn.querySelector("strong")?.textContent || "";
    document.getElementById("trigger-event")!.textContent = name;
  }
});

// formatDate / partyBadge / formatDateSpan / localizeVacationName /
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
// (t-paliad-179 Slice 1)


// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
// the vacation adjustment label, where the explicit weekday + year would
// just be noise — the surrounding sentence carries the full year via the
// dueDate / originalDate that the note brackets.

// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). The Fristenrechner doesn't translate them: they're proper
// names of court-set closures, not generic strings, and rotating them via
// i18n.ts duplicates state that should live in the DB. Rename in the seed
// if the wording needs to change.

// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.

// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
// structured reason — keeps older API responses readable.

let selectedType = "";

function showStep(n: number) {
  for (let i = 1; i <= 3; i++) {
    const el = document.getElementById(`step-${i}`);
    if (el) el.style.display = i <= n ? "block" : "none";
  }
  const resetBtn = document.getElementById("reset-btn")!;
  resetBtn.style.display = n > 1 ? "block" : "none";
}

async function calculate() {
  const seq = ++procCalcSeq;
  const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
  const triggerDate = dateInput.value;
  if (!triggerDate || !selectedType) return;

  // Priority date — only meaningful for epa.grant.exa (Art. 93 EPÜ publish-anchor).
  const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
  const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";

  // Flags — proceeding-specific checkboxes:
  //   upc.inf.cfi: with_ccr (always available); with_amend (nested under
  //                with_ccr — R.30 application is only available with a CCR).
  //   upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
  //                independent gates; both can be on simultaneously.
  // R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
  // an always-available optional submission, surfaced as priority='optional'
  // without a separate checkbox.
  const ccrFlag      = document.getElementById("ccr-flag")        as HTMLInputElement | null;
  const infAmendFlag = document.getElementById("inf-amend-flag")  as HTMLInputElement | null;
  const revAmendFlag = document.getElementById("rev-amend-flag")  as HTMLInputElement | null;
  const revCciFlag   = document.getElementById("rev-cci-flag")    as HTMLInputElement | null;
  const flags: string[] = [];
  if (selectedType === "upc.inf.cfi") {
    if (ccrFlag?.checked)                          flags.push("with_ccr");
    if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
  }
  if (selectedType === "upc.rev.cfi") {
    if (revAmendFlag?.checked) flags.push("with_amend");
    if (revCciFlag?.checked)   flags.push("with_cci");
  }

  // Forward any user-set per-rule date overrides so downstream rules
  // re-anchor off them. Empty map → omitted from the payload.
  const overrides: Record<string, string> = {};
  for (const [code, date] of anchorOverrides) overrides[code] = date;

  // Court picker — only meaningful when the picker row is visible
  // (multi-court proceeding types). When hidden, server resolves the
  // default for the proceeding's jurisdiction.
  const courtPickerRow = document.getElementById("court-picker-row");
  const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
  const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
    ? courtPicker.value
    : "";

  const data = await calculateDeadlines({
    proceedingType: selectedType,
    triggerDate,
    priorityDate,
    flags,
    anchorOverrides: overrides,
    courtId,
  });
  if (seq !== procCalcSeq) return;
  if (!data) return;
  lastResponse = data;
  renderProcedureResults(data);
  showStep(3);
}

interface ProjectOption {
  id: string;
  reference?: string | null;
  title: string;
  path: string;
  // proceeding_type_id is on every paliad.projects row; the JSON
  // already includes it. We just declare the field so the Determinator
  // (Slice 3b) can scope the cascade by the project's jurisdiction
  // without an extra fetch.
  proceeding_type_id?: number | null;
  // our_side carries which side the firm represents on this project
  // (t-paliad-164). When a user selects an Akte, the perspective chip
  // pre-locks to this value; a small hint above the strip flags the
  // pre-selection and the user can still click another chip to
  // override. NULL/undefined leaves the chip unset (free-pick).
  our_side?: "claimant" | "defendant" | "court" | "both" | null;
}

async function fetchProjects(): Promise<ProjectOption[]> {
  try {
    const resp = await fetch("/api/projects");
    if (!resp.ok) return [];
    return (await resp.json()) as ProjectOption[];
  } catch {
    return [];
  }
}

function ensureSaveModal() {
  if (document.getElementById("frist-save-modal")) return;
  const modal = document.createElement("div");
  modal.id = "frist-save-modal";
  modal.className = "modal-overlay";
  modal.style.display = "none";
  modal.innerHTML = `
    <div class="modal-card">
      <div class="modal-header">
        <h2 data-i18n="deadlines.save.modal.title">${escHtml(t("deadlines.save.modal.title"))}</h2>
        <button class="modal-close" id="frist-save-modal-close" type="button">&times;</button>
      </div>
      <div class="form-field">
        <label for="frist-save-project" data-i18n="deadlines.save.modal.akte">${escHtml(t("deadlines.save.modal.akte"))}</label>
        <select id="frist-save-project"></select>
        <p class="form-hint" id="frist-save-no-akten" style="display:none">
          <span data-i18n="deadlines.save.modal.no_akten">${escHtml(t("deadlines.save.modal.no_akten"))}</span>
          <a href="/projects/new" data-i18n="deadlines.save.modal.no_akten.link">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>
        </p>
      </div>
      <div class="form-field">
        <p data-i18n="deadlines.save.modal.choose">${escHtml(t("deadlines.save.modal.choose"))}</p>
        <ul class="frist-save-list" id="frist-save-list"></ul>
        <p class="form-hint" data-i18n="deadlines.save.skip_court_set">${escHtml(t("deadlines.save.skip_court_set"))}</p>
      </div>
      <p class="form-msg" id="frist-save-msg"></p>
      <div class="form-actions">
        <button type="button" class="btn-cancel" id="frist-save-cancel" data-i18n="deadlines.save.modal.cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
        <button type="button" class="btn-primary btn-cta-lime" id="frist-save-submit" data-i18n="deadlines.save.modal.submit">${escHtml(t("deadlines.save.modal.submit"))}</button>
      </div>
    </div>`;
  document.body.appendChild(modal);

  modal.addEventListener("click", (e) => {
    if (e.target === e.currentTarget) closeSaveModal();
  });
  document.getElementById("frist-save-modal-close")!.addEventListener("click", closeSaveModal);
  document.getElementById("frist-save-cancel")!.addEventListener("click", closeSaveModal);
  document.getElementById("frist-save-submit")!.addEventListener("click", submitSave);
}

function closeSaveModal() {
  const modal = document.getElementById("frist-save-modal");
  if (modal) modal.style.display = "none";
}

// preselectedProjectId returns the project the user picked in Step 1
// (if any) so the various save/add flows can default their project
// pickers to it. Carries through anywhere a "save to Akte" pop-out
// renders \u2014 preselection is *only* a default; the picker still
// renders every available project and the user can override.
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
// project should be pre-selected" on Add.
function preselectedProjectId(): string {
  return currentStep1Context.kind === "project" && currentStep1Context.projectId
    ? currentStep1Context.projectId
    : "";
}

async function openSaveModal() {
  if (!lastResponse) return;
  ensureSaveModal();
  const projects = await fetchProjects();
  const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
  const noProjects = document.getElementById("frist-save-no-akten")!;
  const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;

  if (projects.length === 0) {
    sel.style.display = "none";
    noProjects.style.display = "";
    submit.disabled = true;
  } else {
    sel.style.display = "";
    noProjects.style.display = "none";
    submit.disabled = false;
    const preselected = preselectedProjectId();
    sel.innerHTML = projects
      .map((p) => {
        const ref = (p.reference || "").trim();
        const indent = projectIndent(p.path);
        const label = ref
          ? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
          : `${indent}${escHtml(p.title)}`;
        const selected = p.id === preselected ? " selected" : "";
        return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
      })
      .join("");
    if (preselected) sel.value = preselected;
  }

  const list = document.getElementById("frist-save-list")!;
  list.innerHTML = lastResponse.deadlines
    .map((dl, idx) => {
      const dlName = getLang() === "en" ? dl.nameEN : dl.name;
      // Court-determined entries (interim conference, oral hearing, decision,
      // any party=court row) have no calculable date — disable + pre-uncheck
      // so users don't save the trigger-date placeholder as a real deadline.
      const isCourtDetermined = dl.isCourtSet || dl.party === "court";
      // Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority drives
      // the save-modal pre-check + the "no save action" notice-card
      // render. priorityRendering falls back to the legacy
      // (isMandatory, isOptional) pair semantic for pre-Slice-8
      // responses; new responses carry `priority` directly.
      const pr = priorityRendering(dl);
      if (pr.hideSave) {
        // informational rules render as notice cards — no checkbox, no
        // save button, distinct visual tier. The 18 F/F filing rules
        // (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
        // currently fall here once they're flipped to 'informational' by
        // editorial review; today they're 'recommended' so this branch
        // remains exercised only by future rule edits.
        return `<li class="frist-save-row frist-save-row--notice">
          <span class="frist-save-notice-label">${escHtml(t("deadlines.priority.informational.notice_label"))}</span>
          <span class="frist-save-title">${escHtml(dlName)}</span>
          <span class="frist-save-meta">${escHtml(t("deadlines.priority.informational"))}</span>
        </li>`;
      }
      const disabled = isCourtDetermined || !dl.dueDate;
      const checked = !disabled && pr.preChecked;
      // Same direct-vs-indirect split as the timeline date cell —
      // chained court-set rules read as "unbestimmt" rather than
      // "wird vom Gericht bestimmt".
      const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
      const optionalBadge = dl.priority === "optional" && !isCourtDetermined
        ? `<span class="frist-save-optional">${escHtml(t("deadlines.optional.badge"))}</span>`
        : "";
      const meta = isCourtDetermined
        ? `<span class="frist-save-meta">${escHtml(t(courtLabelKey))}</span>`
        : `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}${optionalBadge}</span>`;
      return `<li class="frist-save-row">
        <label>
          <input type="checkbox" data-idx="${idx}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
          <span class="frist-save-title">${escHtml(dlName)}</span>
          ${meta}
        </label>
      </li>`;
    })
    .join("");

  document.getElementById("frist-save-msg")!.textContent = "";
  document.getElementById("frist-save-modal")!.style.display = "flex";
}

async function submitSave() {
  if (!lastResponse) return;
  const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
  const projectID = sel.value;
  const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
  const msg = document.getElementById("frist-save-msg")!;
  if (!projectID) return;

  const checks = document.querySelectorAll<HTMLInputElement>("#frist-save-list input[type=checkbox]");
  const deadlinesPayload: Array<Record<string, unknown>> = [];
  checks.forEach((cb) => {
    if (!cb.checked || cb.disabled) return;
    const idx = Number(cb.dataset.idx);
    const dl = lastResponse!.deadlines[idx];
    if (!dl || !dl.dueDate) return;
    const isEN = getLang() === "en";
    const dlName = isEN ? dl.nameEN : dl.name;
    const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
    deadlinesPayload.push({
      title: dlName,
      rule_code: dl.ruleRef || undefined,
      due_date: dl.dueDate,
      original_due_date: dl.originalDate || undefined,
      source: "fristenrechner",
      notes: dlNotes || undefined,
    });
  });
  if (deadlinesPayload.length === 0) return;

  submit.disabled = true;
  try {
    const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ deadlines: deadlinesPayload }),
    });
    if (!resp.ok) {
      const data = await resp.json().catch(() => ({}) as { error?: string });
      msg.textContent = data.error || t("deadlines.save.error");
      msg.className = "form-msg form-msg-error";
      submit.disabled = false;
      return;
    }
    msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
    msg.className = "form-msg form-msg-ok";
    // Re-enable after a short delay so user can read it; modal stays open with the link.
    setTimeout(() => {
      submit.disabled = false;
    }, 1500);
  } catch {
    msg.textContent = t("deadlines.save.error");
    msg.className = "form-msg form-msg-error";
    submit.disabled = false;
  }
}

// Render the result panel using whichever view is currently active. The
// timeline view is the historical default; the columns view (t-paliad-127)
// arranges deadlines into Proactive / Court / Reactive vertical lanes so the
// reader can see who acts when across the whole proceeding.
function renderProcedureResults(data: DeadlineResponse) {
  const container = document.getElementById("timeline-container")!;
  const printBtn = document.getElementById("fristen-print-btn")!;
  const saveBtn = document.getElementById("fristen-save-cta") as HTMLButtonElement | null;
  const toggle = document.getElementById("fristen-view-toggle");

  const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
  const headerHtml = `<div class="timeline-header">
    <strong>${procName}</strong>
    <span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
  </div>`;

  const bodyHtml = procedureView === "columns"
    ? renderColumnsBody(data, { editable: true, showNotes })
    : renderTimelineBody(data, { showParty: true, editable: true, showNotes });

  container.innerHTML = headerHtml + bodyHtml;
  printBtn.style.display = "block";
  if (saveBtn) {
    // Ad-hoc explore-mode has no project to save against — show the
    // CTA disabled with a hint so the user understands why the action
    // is blocked (m's 2026-05-08 Slice 1 lock #2). Hiding it would
    // leave the user wondering where the save went.
    saveBtn.style.display = "block";
    if (isAdhocMode()) {
      saveBtn.disabled = true;
      saveBtn.title = t("deadlines.save.cta.adhoc.hint");
      saveBtn.dataset.adhocDisabled = "true";
    } else {
      saveBtn.disabled = false;
      saveBtn.removeAttribute("title");
      delete saveBtn.dataset.adhocDisabled;
    }
  }
  if (toggle) toggle.style.display = "";

  applyPendingFocus();
}

// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
function onDateEditCommit(ruleCode: string, newValue: string) {
  if (newValue === "") {
    anchorOverrides.delete(ruleCode);
  } else {
    anchorOverrides.set(ruleCode, newValue);
  }
  void calculate();
}

// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
// openInlineDateEditor / wireDateEditClicks moved to
// ./views/verfahrensablauf-core.

function reset() {
  selectedType = "";
  lastResponse = null;
  clearAnchorOverrides();
  document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
  document.getElementById("timeline-container")!.innerHTML = "";
  document.getElementById("fristen-print-btn")!.style.display = "none";
  const saveBtn = document.getElementById("fristen-save-cta");
  if (saveBtn) saveBtn.style.display = "none";
  const toggle = document.getElementById("fristen-view-toggle");
  if (toggle) toggle.style.display = "none";
  setProceedingPickerCollapsed(false);
  showStep(1);
}

// Collapses / expands the four-group proceeding-picker block. Once the
// user picks, the picker collapses to a one-line summary with a
// Reselect button so the wizard's vertical real-estate goes to the
// trigger date + flags + result. m's 2026-05-08 18:26 ask.
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
  const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
  const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
  const summaryName = document.getElementById("proceeding-summary-name");
  groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
  if (summary) summary.style.display = collapsed ? "" : "none";
  if (summaryName && displayName) summaryName.textContent = displayName;
}

function selectProceeding(btn: HTMLButtonElement) {
  document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
  btn.classList.add("active");
  // Different proceeding tree → previous overrides reference codes that
  // don't exist in the new tree. Clear before the next calc.
  if (selectedType !== btn.dataset.code) clearAnchorOverrides();
  selectedType = btn.dataset.code!;

  // Update trigger event name
  const name = btn.querySelector("strong")?.textContent || "";
  document.getElementById("trigger-event")!.textContent = name;

  // Conditional inputs:
  //   priority-date  → epa.grant.exa
  //   ccr-flag       → upc.inf.cfi only
  //   inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on
  //                     (R.30 amend only available with a CCR)
  //   rev-amend-flag → upc.rev.cfi only
  //   rev-cci-flag   → upc.rev.cfi only
  const priorityRow = document.getElementById("priority-date-row");
  if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none";
  const ccrRow = document.getElementById("ccr-flag-row");
  if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
  const infAmendRow = document.getElementById("inf-amend-flag-row");
  if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
  const revAmendRow = document.getElementById("rev-amend-flag-row");
  if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
  const revCciRow = document.getElementById("rev-cci-flag-row");
  if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";

  syncInfAmendEnabled();
  populateCourtPickerCore("court-picker-row", "court-picker", selectedType);

  // Hide the four group blocks; show the compact summary in their place.
  setProceedingPickerCollapsed(true, name);

  showStep(2);
  scheduleProcCalc(0);
}

// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
// is filed within the Defence to CCR). When ccr-flag flips off, also
// untick inf-amend-flag so the calc payload stays coherent.
function syncInfAmendEnabled() {
  const ccr      = document.getElementById("ccr-flag")       as HTMLInputElement | null;
  const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
  if (!ccr || !infAmend) return;
  infAmend.disabled = !ccr.checked;
  if (!ccr.checked) infAmend.checked = false;
}

// View toggle wiring. Persist the choice in `?view=…` so reload / share-link
// restores the same layout.
function initViewToggle() {
  const toggle = document.getElementById("fristen-view-toggle");
  if (!toggle) return;

  // Read initial state from URL. Default flipped (m 2026-05-08 18:26):
  // Column is the default; ?view=timeline opts back into the legacy
  // single-column layout. URL only carries the value when it differs
  // from the default so share links stay clean.
  const initial = new URLSearchParams(window.location.search).get("view");
  if (initial === "timeline") procedureView = "timeline";

  toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
    input.checked = input.value === procedureView;
    input.addEventListener("change", () => {
      if (!input.checked) return;
      procedureView = input.value === "columns" ? "columns" : "timeline";
      const url = new URL(window.location.href);
      if (procedureView === "columns") {
        url.searchParams.delete("view");
      } else {
        url.searchParams.set("view", procedureView);
      }
      history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
      if (lastResponse) renderProcedureResults(lastResponse);
    });
  });

  // Hidden until step 3 renders.
  toggle.style.display = "none";
}

document.addEventListener("DOMContentLoaded", () => {
  initI18n();
  initSidebar();

  // Proceeding type selection
  document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
    btn.addEventListener("click", () => selectProceeding(btn));
  });

  // Reselect: re-expand the picker without throwing away the rest of
  // the wizard state. Trigger date / flags / result stay visible until
  // the user actually picks a new proceeding type.
  document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
    setProceedingPickerCollapsed(false);
  });

  // Calculate button — manual force-recalc affordance.
  document.getElementById("calculate-btn")!.addEventListener("click", () => scheduleProcCalc(0));

  // Auto-recalc on input changes. Date `change` covers picker and blur;
  // `input` covers manual typing. The Enter key on the date field bypasses
  // debounce for keyboard-savvy users.
  const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
  dateInput.addEventListener("change", () => scheduleProcCalc());
  dateInput.addEventListener("input", () => scheduleProcCalc());
  dateInput.addEventListener("keydown", (e) => {
    if ((e as KeyboardEvent).key === "Enter") scheduleProcCalc(0);
  });

  const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
  if (priorityInput) {
    priorityInput.addEventListener("change", () => scheduleProcCalc());
    priorityInput.addEventListener("input", () => scheduleProcCalc());
  }

  const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
  if (ccrFlag) ccrFlag.addEventListener("change", () => {
    syncInfAmendEnabled();
    scheduleProcCalc(0);
  });

  const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
  if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
  const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
  if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
  const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
  if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
  const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
  if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));

  // Click-to-edit on timeline / column dates: open an inline date input
  // and persist the user's choice as an anchor override so downstream
  // rules re-anchor on the user's date. Delegated on the container so
  // it survives renderProcedureResults() innerHTML rewrites.
  const timelineContainer = document.getElementById("timeline-container");
  if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);

  // Reset button
  document.getElementById("reset-btn")!.addEventListener("click", reset);

  // Print button
  document.getElementById("fristen-print-btn")!.addEventListener("click", () => window.print());

  // Save-to-Project CTA (Phase E)
  const saveBtn = document.getElementById("fristen-save-cta");
  if (saveBtn) saveBtn.addEventListener("click", openSaveModal);

  // Notes toggle — restores last preference on load + re-renders when
  // the user flips it. Lives in the same toggle bar as the view picker.
  const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
  if (notesShowCb) {
    notesShowCb.checked = showNotes;
    notesShowCb.addEventListener("change", () => {
      showNotes = notesShowCb.checked;
      writeNotesPref(showNotes);
      if (lastResponse) renderProcedureResults(lastResponse);
    });
  }

  // View toggle (timeline vs. columns layout) for procedure mode.
  initViewToggle();

  // Tab switching between "Verfahrensablauf" and "Was kommt nach…" modes.
  initModeTabs();

  // Event-mode wiring (PR-2: youpc-parity trigger-event lookup)
  initEventMode();

  // Pre-select the first proceeding button so the deadline list renders
  // immediately on page load — no click on "Fristen berechnen" required.
  // This also fires the first auto-calc via scheduleProcCalc().
  const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
  if (firstBtn) selectProceeding(firstBtn);
});

// ============================================================================
// Mode tabs (procedure vs event)
// ============================================================================

function initModeTabs() {
  const tabs = document.querySelectorAll<HTMLButtonElement>(".mode-tab");
  if (tabs.length === 0) return;

  tabs.forEach((tab) => {
    tab.addEventListener("click", () => {
      const mode = tab.dataset.mode;
      if (!mode) return;
      tabs.forEach((t) => {
        const isActive = t === tab;
        t.classList.toggle("is-active", isActive);
        t.setAttribute("aria-selected", String(isActive));
      });
      document.querySelectorAll<HTMLElement>(".mode-panel").forEach((panel) => {
        panel.hidden = panel.dataset.mode !== mode;
      });
      // Auto-calc on tab activation. Procedure mode self-bootstraps via the
      // pre-selected proceeding button on init, so the only special case is
      // event mode: pick a default trigger event the first time the tab is
      // shown so step 3 isn't empty.
      if (mode === "event") ensureDefaultTriggerEvent();
    });
  });
}

// ============================================================================
// Event mode: pick a trigger event, see all deadlines that flow from it.
// Mirrors youpc.org's deadline calculator. Uses /api/tools/trigger-events
// (list) and /api/tools/event-deadlines (compute).
// ============================================================================

interface TriggerEventSummary {
  id: number;
  code: string;
  name: string;
  name_de: string;
}

interface EventDeadlineResult {
  id: number;
  title: string;
  titleDE: string;
  durationValue: number;
  durationUnit: string;
  timing: string;
  notes?: string;
  notesEN?: string;
  ruleCodes: string[];
  dueDate: string;
  originalDueDate: string;
  wasAdjusted: boolean;
  isComposite?: boolean;
  compositeNote?: string;
}

interface EventCalculateResponse {
  triggerEvent: TriggerEventSummary;
  triggerDate: string;
  deadlines: EventDeadlineResult[];
}

let triggerEvents: TriggerEventSummary[] = [];
let selectedTrigger: TriggerEventSummary | null = null;
let lastEventResponse: EventCalculateResponse | null = null;

// Auto-calc plumbing for event mode — same shape as procedure mode.
let eventCalcSeq = 0;
let eventCalcTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleEventCalc(delayMs = 200) {
  if (eventCalcTimer !== null) clearTimeout(eventCalcTimer);
  eventCalcTimer = setTimeout(() => {
    eventCalcTimer = null;
    void calculateEvent();
  }, delayMs);
}

function showEventStep(n: number) {
  for (let i = 1; i <= 3; i++) {
    const el = document.getElementById(`event-step-${i}`);
    if (el) el.style.display = i <= n ? "block" : "none";
  }
  const resetBtn = document.getElementById("event-reset-btn");
  if (resetBtn) resetBtn.style.display = n > 1 ? "block" : "none";
}

function eventName(ev: TriggerEventSummary): string {
  // Fall back to English when name_de is empty (default seed has empty DE).
  return getLang() === "de" && ev.name_de ? ev.name_de : ev.name;
}

function deadlineTitle(d: EventDeadlineResult): string {
  return getLang() === "de" && d.titleDE ? d.titleDE : d.title;
}

function unitLabel(unit: string, value: number): string {
  const key = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
  return tDyn(key);
}

function timingLabel(timing: string): string {
  return tDyn(`deadlines.event.timing.${timing}`);
}

function renderEventList(query: string) {
  const list = document.getElementById("event-list");
  if (!list) return;
  const q = query.trim().toLowerCase();
  const matches = q
    ? triggerEvents.filter(
        (ev) =>
          ev.name.toLowerCase().includes(q) ||
          (ev.name_de && ev.name_de.toLowerCase().includes(q)) ||
          ev.code.toLowerCase().includes(q),
      )
    : triggerEvents;

  if (matches.length === 0) {
    list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.empty"))}</li>`;
    return;
  }

  list.innerHTML = matches
    .map(
      (ev) =>
        `<li class="event-list-item" role="option" data-id="${ev.id}" tabindex="0">${escHtml(eventName(ev))}</li>`,
    )
    .join("");
}

async function loadTriggerEvents() {
  const list = document.getElementById("event-list");
  if (!list) return;
  list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.loading"))}</li>`;
  try {
    const resp = await fetch("/api/tools/trigger-events");
    if (!resp.ok) {
      list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
      return;
    }
    triggerEvents = (await resp.json()) as TriggerEventSummary[];
    renderEventList("");
    // If the user already switched to the event tab while the list was
    // loading, pre-select now so they don't see an empty step 1.
    const eventTab = document.getElementById("mode-event-tab");
    if (eventTab?.classList.contains("is-active")) ensureDefaultTriggerEvent();
  } catch {
    list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
  }
}

function selectTriggerEvent(id: number) {
  const ev = triggerEvents.find((e) => e.id === id);
  if (!ev) return;
  selectedTrigger = ev;
  const nameEl = document.getElementById("event-selected-name");
  if (nameEl) nameEl.textContent = eventName(ev);
  showEventStep(2);
  scheduleEventCalc(0);
}

async function calculateEvent() {
  const seq = ++eventCalcSeq;
  if (!selectedTrigger) return;
  const dateInput = document.getElementById("event-date") as HTMLInputElement | null;
  const triggerDate = dateInput?.value;
  if (!triggerDate) return;

  try {
    const resp = await fetch("/api/tools/event-deadlines", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ triggerEventId: selectedTrigger.id, triggerDate }),
    });
    if (seq !== eventCalcSeq) return;
    if (!resp.ok) {
      const err = await resp.json().catch(() => ({}));
      console.error("event API error:", err);
      return;
    }
    const data = (await resp.json()) as EventCalculateResponse;
    if (seq !== eventCalcSeq) return;
    lastEventResponse = data;
    renderEventResults(data);
    showEventStep(3);
  } catch (e) {
    console.error("event fetch error:", e);
  }
}

function renderEventResults(data: EventCalculateResponse) {
  const container = document.getElementById("event-results-container");
  const printBtn = document.getElementById("event-print-btn");
  if (!container) return;

  if (data.deadlines.length === 0) {
    container.innerHTML = `<p class="event-results-empty">${escHtml(t("deadlines.event.noresults"))}</p>`;
    if (printBtn) printBtn.style.display = "none";
    return;
  }

  const triggerName = eventName(data.triggerEvent);
  const triggerDate = formatDate(data.triggerDate);

  const rowsHtml = data.deadlines
    .map((d) => {
      const codes = d.ruleCodes
        .map((c) => `<span class="event-rule-code">${escHtml(c)}</span>`)
        .join(" ");
      const original = d.wasAdjusted
        ? `<div class="event-result-adjusted">${escHtml(
            tDyn("deadlines.event.adjusted") + " " + formatDate(d.originalDueDate),
          )}</div>`
        : "";
      const composite = d.isComposite && d.compositeNote
        ? `<div class="event-result-composite" title="${escAttr(d.compositeNote)}">${escHtml(t("deadlines.event.composite.label"))} ${escHtml(d.compositeNote)}</div>`
        : "";
      const noteText = getLang() === "en" ? (d.notesEN || d.notes) : d.notes;
      const notes = noteText
        ? `<div class="event-result-notes">${escHtml(noteText)}</div>`
        : "";
      return `<li class="event-result-row">
        <div class="event-result-header">
          <span class="event-result-title">${escHtml(deadlineTitle(d))}</span>
          <span class="event-result-date">${escHtml(formatDate(d.dueDate))}</span>
        </div>
        <div class="event-result-meta">
          <span class="event-result-duration">${d.durationValue} ${escHtml(unitLabel(d.durationUnit, d.durationValue))} ${escHtml(timingLabel(d.timing))}</span>
          ${codes}
        </div>
        ${composite}
        ${original}
        ${notes}
      </li>`;
    })
    .join("");

  container.innerHTML = `
    <div class="event-results-header">
      <div><strong>${escHtml(t("deadlines.event.results.trigger"))}</strong> ${escHtml(triggerName)}</div>
      <div><strong>${escHtml(t("deadlines.event.results.date"))}</strong> ${escHtml(triggerDate)}</div>
    </div>
    <ul class="event-result-list">${rowsHtml}</ul>`;

  if (printBtn) printBtn.style.display = "block";
}

function resetEventMode() {
  selectedTrigger = null;
  lastEventResponse = null;
  const search = document.getElementById("event-search") as HTMLInputElement | null;
  if (search) search.value = "";
  renderEventList("");
  showEventStep(1);
}

function initEventMode() {
  const search = document.getElementById("event-search") as HTMLInputElement | null;
  if (!search) return; // page didn't render the event mode (older bundle)

  loadTriggerEvents();

  search.addEventListener("input", () => renderEventList(search.value));

  const list = document.getElementById("event-list");
  if (list) {
    list.addEventListener("click", (e) => {
      const target = (e.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
      if (!target) return;
      const id = Number(target.dataset.id);
      if (!Number.isFinite(id)) return;
      selectTriggerEvent(id);
    });
    list.addEventListener("keydown", (e) => {
      const ke = e as KeyboardEvent;
      if (ke.key !== "Enter" && ke.key !== " ") return;
      const target = (ke.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
      if (!target) return;
      ke.preventDefault();
      const id = Number(target.dataset.id);
      if (Number.isFinite(id)) selectTriggerEvent(id);
    });
  }

  // Event-tab calculate button — manual force-recalc affordance.
  document.getElementById("event-calculate-btn")?.addEventListener("click", () => scheduleEventCalc(0));

  // Auto-recalc when the user changes the event date.
  const eventDate = document.getElementById("event-date") as HTMLInputElement | null;
  if (eventDate) {
    eventDate.addEventListener("change", () => scheduleEventCalc());
    eventDate.addEventListener("input", () => scheduleEventCalc());
    eventDate.addEventListener("keydown", (e) => {
      if ((e as KeyboardEvent).key === "Enter") scheduleEventCalc(0);
    });
  }

  document.getElementById("event-reset-btn")?.addEventListener("click", resetEventMode);
  document.getElementById("event-print-btn")?.addEventListener("click", () => window.print());
}

// Pre-select the first trigger event so the "Was kommt nach" tab renders
// immediately with default selection + today's date. Idempotent — only fires
// when there's no existing selection. Called both after the event list loads
// and when the user first activates the event tab, since either one can be
// the trigger depending on which finishes first.
function ensureDefaultTriggerEvent() {
  if (selectedTrigger || triggerEvents.length === 0) return;
  selectTriggerEvent(triggerEvents[0].id);
}

// Re-render event results when language flips (titles/notes are bilingual).
onLangChange(() => {
  if (lastEventResponse) renderEventResults(lastEventResponse);
  if (selectedTrigger) {
    const nameEl = document.getElementById("event-selected-name");
    if (nameEl) nameEl.textContent = eventName(selectedTrigger);
  }
  if (triggerEvents.length > 0) {
    const search = document.getElementById("event-search") as HTMLInputElement | null;
    renderEventList(search?.value || "");
  }
});

// ============================================================================
// Search bar (t-paliad-131 Phase D) — concept-card UI on top of /api/tools/
// fristenrechner/search. Augments the proceeding tile grid: type a phrase
// (Klageerwiderung, RoP 23, § 82, Wiedereinsetzung), see ranked concept
// cards with one pill per (proceeding × rule) or per cross-cutting trigger.
// Click a pill → drill into the right calculator mode pre-selected.
// ============================================================================

interface SearchProceeding {
  code: string;
  name_de: string;
  name_en: string;
  jurisdiction: string;
}

interface SearchPillDuration {
  value: number;
  unit: string;
  timing?: string;
}

interface SearchPill {
  kind: "rule" | "trigger";
  rule_id?: string;
  trigger_event_id?: number;
  proceeding?: SearchProceeding;
  rule_local_code: string;
  rule_name_de: string;
  rule_name_en: string;
  legal_source?: string;
  legal_source_display?: string;
  duration?: SearchPillDuration;
  party: string;
  drill_url: string;
}

interface SearchConcept {
  id: string;
  slug: string;
  name_de: string;
  name_en: string;
  description?: string;
  party?: string;
  category: string;
}

interface SearchCard {
  concept: SearchConcept;
  matched_aliases?: string[];
  score: number;
  pills: SearchPill[];
}

interface SearchResponse {
  query: string;
  filters: { party: string | null; proc: string | null; source: string | null };
  cards: SearchCard[];
  total_cards: number;
  total_pills: number;
}

// Debounced search dispatch — see scheduleProcCalc / scheduleEventCalc for
// the same shape used by the existing modes.
let searchSeq = 0;
let searchDebounce: number | undefined;
function scheduleSearch(delayMs = 180) {
  if (searchDebounce !== undefined) clearTimeout(searchDebounce);
  searchDebounce = window.setTimeout(runSearch, delayMs);
}

async function runSearch() {
  const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
  const results = document.getElementById("fristen-search-results") as HTMLDivElement | null;
  const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
  if (!input || !results) return;

  const q = input.value.trim();
  // URL state — single source of truth for shareable searches. Strip the
  // param when the input is empty so the URL stays clean.
  syncSearchURL(q);
  if (clearBtn) clearBtn.hidden = q.length === 0;

  if (q === "") {
    results.innerHTML = "";
    results.classList.remove("is-loading", "is-empty", "is-no-hits");
    return;
  }

  results.classList.remove("is-empty", "is-no-hits");
  results.classList.add("is-loading");
  results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;

  const seq = ++searchSeq;
  let resp: Response;
  try {
    {
      const searchURL = new URL("/api/tools/fristenrechner/search", window.location.origin);
      searchURL.searchParams.set("q", q);
      searchURL.searchParams.set("limit", "12");
      const forums = getActiveForumsParam();
      if (forums) searchURL.searchParams.set("forum", forums);
      resp = await fetch(searchURL.toString(), { credentials: "same-origin" });
    }
  } catch {
    if (seq !== searchSeq) return;
    results.classList.remove("is-loading");
    results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
    return;
  }
  if (seq !== searchSeq) return;
  results.classList.remove("is-loading");
  if (!resp.ok) {
    results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
    return;
  }
  const data = (await resp.json()) as SearchResponse;
  if (seq !== searchSeq) return;
  renderSearchResults(data);
}

function renderSearchResults(data: SearchResponse) {
  renderSearchResultsInto("fristen-search-results", data);
}

// renderSearchResultsInto writes a SearchResponse into the named
// container. Used both by the B2 search bar (target: fristen-search-results)
// and by the B1 decision tree (target: fristen-b1-results, t-paliad-134).
function renderSearchResultsInto(containerId: string, data: SearchResponse) {
  const results = document.getElementById(containerId);
  if (!results) return;

  if (data.cards.length === 0) {
    results.classList.add("is-no-hits");
    results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
    return;
  }
  results.classList.remove("is-no-hits");

  const lang = getLang();
  const countLabel = data.total_cards === 1
    ? t("deadlines.search.results.count_one")
    : t("deadlines.search.results.count").replace("{n}", String(data.total_cards));

  const cardsHtml = data.cards.map((c) => renderConceptCard(c, lang)).join("");

  results.innerHTML = `
    <div class="fristen-search-summary">${escHtml(countLabel)}</div>
    <div class="fristen-search-cards">${cardsHtml}</div>`;
}

// wirePillClicks attaches the v4 card-click → inline calc-panel handler
// to a results container. Idempotent across re-renders because the
// listener lives on the container, not on individual pill anchors.
//
// v4 (t-paliad-136 Phase B): the primary click action on ANY card —
// header, pill, or body — expands the card with an inline calc panel
// (trigger-date input + flag checkboxes + computed deadline + add-to-
// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve
// the legacy drill-to-Pathway-A semantics via the `<a href="…">`
// fallback (browser default new-tab behaviour). Trigger pills still
// drill to the youpc-style trigger-event picker on plain click — those
// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single
// rule to compute against, so the inline calc panel doesn't apply.
function wirePillClicks(container: HTMLElement) {
  if (container.dataset.pillClicksWired === "1") return;
  container.dataset.pillClicksWired = "1";
  container.addEventListener("click", (e) => {
    // Clicks inside an open calc panel are handled by their own
    // listeners — do not re-trigger the expand/collapse logic.
    if ((e.target as HTMLElement).closest(".fristen-card-calc")) return;

    const pill = (e.target as HTMLElement).closest<HTMLAnchorElement>(".fristen-pill");
    const card = (e.target as HTMLElement).closest<HTMLElement>(".fristen-card");
    if (!card) return;

    const me = e as MouseEvent;
    // Modifier-key fallback: let `<a href>` open in a new tab / new
    // window for users who want to deep-link into Pathway A. Don't
    // interfere with normal text selection (no expand on shift-drag).
    if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;

    // Trigger pills drill to the trigger-event picker (legacy youpc
    // pathway). The inline calc panel only applies to rule pills.
    if (pill && pill.dataset.kind === "trigger") {
      e.preventDefault();
      const id = Number(pill.dataset.triggerId);
      if (Number.isFinite(id)) drillToTrigger(id);
      return;
    }

    e.preventDefault();
    expandCardCalc(card, pill);
  });
}

// ============================================================================
// v4 card-click → inline calc panel (t-paliad-136 Phase B)
// ============================================================================
// Click a result card → expand inline → user enters trigger date + flags →
// server computes one deadline → user can add it to a project.
//
// Only one card may be expanded at a time (multiple panels would confuse
// "which trigger date am I looking at?"). Collapsing happens automatically
// when another card is clicked, when × is pressed, or when the user clicks
// outside the panel.

interface RuleCalcResponse {
  rule: {
    id: string;
    localCode?: string;
    nameDE: string;
    nameEN: string;
    ruleRef?: string;
    legalSource?: string;
    legalSourceDisplay?: string;
    durationValue: number;
    durationUnit: string;
    party?: string;
    isMandatory: boolean;
    notesDE?: string;
    notesEN?: string;
  };
  proceeding: { code: string; nameDE: string; nameEN: string };
  triggerDate: string;
  originalDate: string;
  dueDate: string;
  wasAdjusted: boolean;
  adjustmentReason?: {
    holidays?: Array<{ name: string; date: string }>;
    upcVacation?: boolean;
    moveToNextWorkday?: boolean;
  };
  isCourtSet: boolean;
  flagsApplied?: string[];
  flagsRequired?: string[];
}

let lastCalcByCard: WeakMap<HTMLElement, RuleCalcResponse> = new WeakMap();
let calcDebounce: number | undefined;
let calcSeq = 0;

function collapseAnyExpandedCard() {
  document.querySelectorAll<HTMLElement>(".fristen-card.is-expanded").forEach((c) => {
    c.classList.remove("is-expanded");
    c.setAttribute("aria-expanded", "false");
    const panel = c.querySelector<HTMLElement>(".fristen-card-calc");
    if (panel) panel.remove();
  });
}

function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
  // Click a different card → collapse the current one first.
  if (!card.classList.contains("is-expanded")) {
    collapseAnyExpandedCard();
  } else {
    // Already expanded; if the user clicked a different pill, switch
    // selection. If they clicked the body again, do nothing.
    if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
    return;
  }

  const payload = card.dataset.cardPayload;
  if (!payload) return;
  let cardData: SearchCard;
  try {
    cardData = JSON.parse(payload) as SearchCard;
  } catch {
    return;
  }
  // Only rule pills are computable. Drop trigger pills from the picker.
  const rulePills = cardData.pills.filter((p) => p.kind === "rule");
  if (rulePills.length === 0) return;

  card.classList.add("is-expanded");
  card.setAttribute("aria-expanded", "true");

  // m/paliad#57 part 4: when the user clicked a specific rule pill, the
  // context is already known — the calc panel renders with that pill
  // locked in and no "Which context?" picker. The card's pill list is
  // hidden via CSS while is-expanded so the rules aren't listed twice.
  // When the user clicked the card body (no autoSelectPill), the picker
  // is the primary surface — still no duplicate pill list above it.
  const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
    ? rulePills.find((p) =>
        p.proceeding?.code === autoSelectPill.dataset.proc
        && (autoSelectPill.dataset.focus
            ? p.rule_local_code === autoSelectPill.dataset.focus
            : true))
    : undefined;

  const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
  card.appendChild(panel);

  scheduleCardCalc(card);
}

function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
  const panel = document.createElement("div");
  panel.className = "fristen-card-calc";
  // stopPropagation so clicks inside the panel don't bubble to the
  // card-level expand handler.
  panel.addEventListener("click", (e) => e.stopPropagation());
  panel.addEventListener("keydown", (e) => e.stopPropagation());

  const lang = getLang();
  const today = new Date().toISOString().split("T")[0];

  // Picker semantics (m/paliad#57 part 4):
  //   - lockedPill set        → context known (user clicked a specific
  //                             rule pill on the card). Render as a
  //                             hidden input only; the calc panel shows
  //                             no "Which context?" question. A small
  //                             "ändern" link reopens the picker fieldset.
  //   - rulePills.length <= 1 → only one possible context, never a
  //                             picker (hidden input carries the data).
  //   - otherwise             → show the picker as primary surface; the
  //                             card's pill list is hidden via CSS while
  //                             the panel is open, so the user isn't
  //                             asked the same thing twice.
  let pickerHtml: string;
  if (lockedPill) {
    const procName = lockedPill.proceeding
      ? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
      : "";
    const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
    const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
    const reopenLabel = t("deadlines.card.calc.pill_picker.change");
    pickerHtml = `<div class="fristen-card-calc-pill-locked">
      <span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
      <span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
      <span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
      ${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
      ${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
      <input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
    </div>`;
  } else if (rulePills.length <= 1) {
    pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
  } else {
    pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
         <legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
         ${rulePills.map((p, i) => {
           const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
           const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
           const src = p.legal_source_display || p.legal_source || "";
           return `<label class="fristen-card-calc-pill-option">
             <input type="radio" name="fristen-card-calc-pill" value="${i}" ${i === 0 ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
             <span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
             <span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
             ${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
           </label>`;
         }).join("")}
       </fieldset>`;
  }

  panel.innerHTML = `
    <button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
    ${pickerHtml}
    <div class="fristen-card-calc-inputs">
      <label class="fristen-card-calc-trigger">
        <span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.trigger.label"))}</span>
        <input type="date" class="fristen-card-calc-trigger-input" value="${escAttr(today)}" />
      </label>
      <div class="fristen-card-calc-flags" hidden></div>
    </div>
    <div class="fristen-card-calc-result" aria-live="polite">
      <div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>
    </div>
    <div class="fristen-card-calc-actions">
      <button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add" disabled>${escHtml(t("deadlines.card.calc.add_to_project"))}</button>
    </div>
    <div class="fristen-card-calc-msg" aria-live="polite"></div>
  `;

  // Wire interactions.
  const close = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-close")!;
  close.addEventListener("click", () => {
    const card = panel.closest<HTMLElement>(".fristen-card");
    if (card) {
      card.classList.remove("is-expanded");
      card.setAttribute("aria-expanded", "false");
      panel.remove();
    }
  });

  const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
  dateInput.addEventListener("input", () => {
    const card = panel.closest<HTMLElement>(".fristen-card");
    if (card) scheduleCardCalc(card);
  });

  panel.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
    r.addEventListener("change", () => {
      const card = panel.closest<HTMLElement>(".fristen-card");
      if (card) scheduleCardCalc(card, 0);
    });
  });

  panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!.addEventListener("click", () => {
    const card = panel.closest<HTMLElement>(".fristen-card");
    if (!card) return;
    const last = lastCalcByCard.get(card);
    if (!last) return;
    void addCalcToProject(card, last);
  });

  // "ändern" — swap the locked-context caption for the full radio
  // picker so the user can change context without collapsing the panel.
  panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
    const card = panel.closest<HTMLElement>(".fristen-card");
    const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
    if (!card || !locked) return;
    const fieldset = document.createElement("fieldset");
    fieldset.className = "fristen-card-calc-pill-picker";
    fieldset.setAttribute("role", "radiogroup");
    const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
    const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
    fieldset.innerHTML = `
      <legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
      ${rulePills.map((p, i) => {
        const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
        const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
        const src = p.legal_source_display || p.legal_source || "";
        const isChecked = (p.proceeding?.code || "") === lockedProc
          && (p.rule_local_code || "") === lockedFocus;
        return `<label class="fristen-card-calc-pill-option">
          <input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
          <span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
          <span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
          ${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
        </label>`;
      }).join("")}`;
    locked.replaceWith(fieldset);
    fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
      r.addEventListener("change", () => scheduleCardCalc(card, 0));
    });
  });

  return panel;
}

function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) {
  if (!proc) return;
  const radios = card.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]');
  radios.forEach((r) => {
    if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) {
      r.checked = true;
      r.dispatchEvent(new Event("change", { bubbles: true }));
    }
  });
}

function scheduleCardCalc(card: HTMLElement, delayMs = 200) {
  if (calcDebounce !== undefined) clearTimeout(calcDebounce);
  calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs);
}

async function runCardCalc(card: HTMLElement) {
  const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
  if (!panel) return;
  const result = panel.querySelector<HTMLElement>(".fristen-card-calc-result")!;
  const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
  const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;

  const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
  const triggerDate = dateInput.value;
  if (!triggerDate) return;

  // Resolve currently-selected pill (proc + ruleLocalCode).
  let proc = "";
  let focus = "";
  const checked = panel.querySelector<HTMLInputElement>('input[name="fristen-card-calc-pill"]:checked');
  if (checked) {
    proc = checked.dataset.proc || "";
    focus = checked.dataset.focus || "";
  } else {
    const hidden = panel.querySelector<HTMLInputElement>(".fristen-card-calc-pill-picker");
    if (hidden) {
      proc = hidden.dataset.proc || "";
      focus = hidden.dataset.focus || "";
    }
  }
  if (!proc || !focus) return;

  // Read flag checkboxes.
  const flags: string[] = [];
  panel.querySelectorAll<HTMLInputElement>('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => {
    if (cb.value) flags.push(cb.value);
  });

  result.innerHTML = `<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>`;
  msgEl.textContent = "";
  addBtn.disabled = true;

  const seq = ++calcSeq;
  let resp: Response;
  try {
    resp = await fetch("/api/tools/fristenrechner/calculate-rule", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        proceedingCode: proc,
        ruleLocalCode: focus,
        triggerDate,
        flags,
      }),
    });
  } catch {
    if (seq !== calcSeq) return;
    result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(t("deadlines.card.calc.result.error"))}</div>`;
    return;
  }
  if (seq !== calcSeq) return;
  if (!resp.ok) {
    const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string };
    result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(data.error || t("deadlines.card.calc.result.error"))}</div>`;
    return;
  }
  const calc = (await resp.json()) as RuleCalcResponse;
  if (seq !== calcSeq) return;

  lastCalcByCard.set(card, calc);
  renderCalcResult(card, calc);
  syncFlagCheckboxes(card, calc);
  addBtn.disabled = calc.isCourtSet || !calc.dueDate;
  if (calc.isCourtSet) {
    addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled");
  } else {
    addBtn.textContent = t("deadlines.card.calc.add_to_project");
  }
}

function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) {
  const flagsEl = card.querySelector<HTMLElement>(".fristen-card-calc-flags");
  if (!flagsEl) return;
  const required = calc.flagsRequired || [];
  if (required.length === 0) {
    flagsEl.hidden = true;
    flagsEl.innerHTML = "";
    return;
  }
  flagsEl.hidden = false;
  // Preserve current state when re-rendering: check current DOM for
  // existing checkbox values so user input survives a recalc.
  const existing = new Map<string, boolean>();
  flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
    existing.set(cb.value, cb.checked);
  });
  const labelKey = (flag: string) => {
    const k = `deadlines.card.calc.flag.${flag}`;
    const localised = tDyn(k);
    return localised === k ? flag : localised;
  };
  flagsEl.innerHTML = `
    <span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.flags.label"))}</span>
    ${required.map((f) => {
      const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f);
      return `<label class="fristen-card-calc-flag">
        <input type="checkbox" value="${escAttr(f)}" ${checked ? "checked" : ""} />
        <span>${escHtml(labelKey(f))}</span>
      </label>`;
    }).join("")}
  `;
  flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
    cb.addEventListener("change", () => scheduleCardCalc(card, 0));
  });
}

function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) {
  const result = card.querySelector<HTMLElement>(".fristen-card-calc-result");
  if (!result) return;
  if (calc.isCourtSet) {
    result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-court">${escHtml(t("deadlines.card.calc.result.court_set"))}</div>`;
    return;
  }
  const lang = getLang();
  const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
  const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`;
  const dueLabel = formatDate(calc.dueDate);
  const fromLabel = formatDate(calc.triggerDate);
  const adjustmentChip = calc.wasAdjusted
    ? renderAdjustmentChip(calc, lang)
    : "";
  result.innerHTML = `
    <div class="fristen-card-calc-result-row">
      <span class="fristen-card-calc-result-arrow" aria-hidden="true">►</span>
      <span class="fristen-card-calc-result-due"><strong>${escHtml(dueLabel)}</strong></span>
      <span class="fristen-card-calc-result-detail">(${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)})</span>
    </div>
    ${adjustmentChip}
    <div class="fristen-card-calc-result-rule">${escHtml(ruleName)}</div>
  `;
}

function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string {
  const reason = calc.adjustmentReason;
  let why = "";
  if (reason && reason.upcVacation) {
    why = "UPC-Sommerferien (27.7.–28.8.)";
  } else if (reason && reason.holidays && reason.holidays.length > 0) {
    why = reason.holidays.map((h) => h.name).join(", ");
  } else {
    why = "Wochenende / Feiertag";
  }
  return `<div class="fristen-card-calc-result-shift">
    ⚠ ${escHtml(t("deadlines.card.calc.result.shifted_from"))} <strong>${escHtml(formatDate(calc.originalDate))}</strong>
    ${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}.
  </div>`;
}

function formatDurationUnit(unit: string, lang: "de" | "en"): string {
  const map: Record<string, { de: string; en: string }> = {
    days:         { de: "Tage",        en: "days" },
    working_days: { de: "Arbeitstage", en: "working days" },
    weeks:        { de: "Wochen",      en: "weeks" },
    months:       { de: "Monate",      en: "months" },
    years:        { de: "Jahre",       en: "years" },
  };
  return map[unit] ? map[unit][lang] : unit;
}

async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
  const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
  if (!panel) return;
  const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
  const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
  msgEl.textContent = "";
  addBtn.disabled = true;

  const projects = await fetchProjects();
  if (projects.length === 0) {
    addBtn.disabled = false;
    msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.modal.no_akten"))}</span> <a href="/projects/new">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>`;
    return;
  }

  // Inline picker — render a compact <select> + Confirm button under
  // the result. Keeps the user inside the card; no full modal needed.
  const lang = getLang();
  const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
  const dueLabel = formatDate(calc.dueDate);
  const preselected = preselectedProjectId();
  msgEl.innerHTML = `
    <div class="fristen-card-calc-add-picker">
      <label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
        <select class="fristen-card-calc-add-select">
          ${projects.map((p) => {
            const ref = (p.reference || "").trim();
            const indent = projectIndent(p.path);
            const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`;
            const selected = p.id === preselected ? " selected" : "";
            return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
          }).join("")}
        </select>
      </label>
      <button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add-confirm">${escHtml(t("deadlines.save.modal.submit"))}</button>
      <button type="button" class="btn-cancel fristen-card-calc-add-cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
    </div>
  `;

  const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
  if (preselected) sel.value = preselected;
  msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
    msgEl.innerHTML = "";
    addBtn.disabled = false;
  });
  msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => {
    const projectID = sel.value;
    if (!projectID) return;
    const dlNotes = lang === "en"
      ? (calc.rule.notesEN || calc.rule.notesDE)
      : calc.rule.notesDE;
    const payload = {
      deadlines: [{
        title: ruleName,
        rule_code: calc.rule.ruleRef || undefined,
        due_date: calc.dueDate,
        original_due_date: calc.originalDate || undefined,
        // m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not
        // 'fristenrechner_card'. Audit-log differentiation is not needed.
        source: "fristenrechner",
        notes: dlNotes || undefined,
      }],
    };
    const confirm = msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!;
    confirm.disabled = true;
    try {
      const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!r.ok) {
        const data = (await r.json().catch(() => ({}))) as { error?: string };
        msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(data.error || t("deadlines.save.error"))}</span>`;
        addBtn.disabled = false;
        return;
      }
      msgEl.innerHTML = `<span class="form-msg form-msg-ok">${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a></span>`;
      addBtn.disabled = false;
    } catch {
      msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.error"))}</span>`;
      addBtn.disabled = false;
    }
  });
}

// Collapse the open card on Escape key for quick keyboard exit.
document.addEventListener("keydown", (e) => {
  if (e.key !== "Escape") return;
  const open = document.querySelector<HTMLElement>(".fristen-card.is-expanded");
  if (open) {
    open.classList.remove("is-expanded");
    open.setAttribute("aria-expanded", "false");
    open.querySelector<HTMLElement>(".fristen-card-calc")?.remove();
  }
});

function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
  const name = lang === "en" ? card.concept.name_en : card.concept.name_de;
  const altName = lang === "en" ? card.concept.name_de : card.concept.name_en;
  const aliasLine = card.matched_aliases && card.matched_aliases.length > 0
    ? `<div class="fristen-card-aliases"><span class="fristen-card-aliases-label">${escHtml(t("deadlines.search.aliases"))}</span> ${card.matched_aliases.map(escHtml).join(" · ")}</div>`
    : "";
  const desc = card.concept.description ? `<p class="fristen-card-desc">${escHtml(card.concept.description)}</p>` : "";

  // Split rule pills (have a proceeding) from cross-cutting trigger pills.
  const rulePills = card.pills.filter((p) => p.kind === "rule");
  const triggerPills = card.pills.filter((p) => p.kind === "trigger");

  const ruleSection = rulePills.length === 0 ? "" : `
    <div class="fristen-card-pills-section fristen-card-pills-section--rules">
      <h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
      <div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
    </div>`;
  const triggerSection = triggerPills.length === 0 ? "" : `
    <div class="fristen-card-pills-section fristen-card-pills-section--cross">
      <h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
      <div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
    </div>`;

  // v4 (t-paliad-136 Phase B): stash the card payload on the article so
  // expandCardCalc() can read pills + concept name without re-querying.
  // JSON.stringify → escAttr survives htmlentity round-trip.
  const cardPayload = escAttr(JSON.stringify(card));

  return `
    <article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}" data-card-payload="${cardPayload}" tabindex="0" role="button" aria-expanded="false" title="${escAttr(t("deadlines.card.calc.expand_hint"))}">
      <header class="fristen-card-header">
        <h3 class="fristen-card-title">${escHtml(name)}</h3>
        <span class="fristen-card-altname">${escHtml(altName)}</span>
      </header>
      ${desc}
      ${aliasLine}
      ${ruleSection}
      ${triggerSection}
    </article>`;
}

function renderPill(pill: SearchPill, lang: "de" | "en"): string {
  const procLabel = pill.proceeding
    ? (lang === "en" && pill.proceeding.name_en ? pill.proceeding.name_en : pill.proceeding.name_de)
    : "";
  // t-paliad-134: never fall back to rule_local_code — that's an
  // internal slug like "rev.defence" / "inf.decision" and leaks
  // implementation detail to the UI when legal_source is unset.
  const sourceLabel = pill.legal_source_display || pill.legal_source || "";
  const ruleName = lang === "en" && pill.rule_name_en ? pill.rule_name_en : pill.rule_name_de;
  const partyLabel = partyLabelFor(pill.party);

  const durationHtml = pill.duration
    ? `<span class="fristen-pill-duration">${escHtml(formatDuration(pill.duration, lang))}</span>`
    : "";
  const procHtml = procLabel
    ? `<span class="fristen-pill-proc">${escHtml(procLabel)}</span>`
    : `<span class="fristen-pill-proc fristen-pill-proc--cross">${escHtml(t("deadlines.search.pills.cross_cutting"))}</span>`;
  const partyHtml = partyLabel ? `<span class="fristen-pill-party fristen-pill-party--${escAttr(pill.party)}">${escHtml(partyLabel)}</span>` : "";
  const sourceHtml = sourceLabel
    ? `<span class="fristen-pill-source">${escHtml(sourceLabel)}</span>`
    : "";

  // data-* attributes carry everything the click handler needs to drill in
  // without re-parsing JSON. drill_url is the canonical fallback (used when
  // the user middle-clicks / cmd-clicks for a new tab).
  const dataAttrs = [
    `data-kind="${escAttr(pill.kind)}"`,
    pill.proceeding ? `data-proc="${escAttr(pill.proceeding.code)}"` : "",
    pill.rule_local_code ? `data-focus="${escAttr(pill.rule_local_code)}"` : "",
    pill.trigger_event_id !== undefined ? `data-trigger-id="${pill.trigger_event_id}"` : "",
  ].filter(Boolean).join(" ");

  return `
    <a href="${escAttr(pill.drill_url)}" class="fristen-pill" ${dataAttrs}>
      ${procHtml}
      <span class="fristen-pill-rule">${escHtml(ruleName)}</span>
      ${sourceHtml}
      ${durationHtml}
      ${partyHtml}
    </a>`;
}

function formatDuration(d: SearchPillDuration, lang: "de" | "en"): string {
  const unitLabels: Record<string, { de: string; en: string }> = {
    days:         { de: "Tage",          en: "days" },
    working_days: { de: "Arbeitstage",   en: "working days" },
    weeks:        { de: "Wochen",        en: "weeks" },
    months:       { de: "Monate",        en: "months" },
    years:        { de: "Jahre",         en: "years" },
  };
  const label = unitLabels[d.unit] ? unitLabels[d.unit][lang] : d.unit;
  return `${d.value} ${label}`;
}

function partyLabelFor(party: string): string {
  switch (party) {
    case "claimant":  return t("deadlines.search.party.claimant");
    case "defendant": return t("deadlines.search.party.defendant");
    case "both":      return t("deadlines.search.party.both");
    case "court":     return t("deadlines.search.party.court");
    default:          return party;
  }
}

// ----- Drill-in --------------------------------------------------------------

// Pending focus the next renderProcedureResults() will scroll to and
// highlight. Set by drillToProceeding right before the scheduled calc fires.
let pendingFocusRule: string | null = null;

function applyPendingFocus() {
  if (!pendingFocusRule) return;
  const code = pendingFocusRule;
  pendingFocusRule = null;
  // Wait one frame so the DOM has settled.
  requestAnimationFrame(() => {
    const target = document.querySelector<HTMLElement>(`[data-rule-code="${CSS.escape(code)}"]`);
    if (!target) return;
    const row = target.closest<HTMLElement>(".frist-row, .frist-card, li") || target;
    row.classList.add("fristen-focus-highlight");
    row.scrollIntoView({ behavior: "smooth", block: "center" });
    window.setTimeout(() => row.classList.remove("fristen-focus-highlight"), 2400);
  });
}

function drillToProceeding(procCode: string, focusCode: string | null) {
  // Switch to procedure mode if we're on event mode.
  const procTab = document.getElementById("mode-procedure-tab");
  if (procTab) procTab.click();

  const btn = document.querySelector<HTMLButtonElement>(`.proceeding-btn[data-code="${procCode}"]`);
  if (!btn) return;
  if (focusCode) pendingFocusRule = focusCode;
  selectProceeding(btn);
  // Scroll the wizard into view so the user sees what just happened.
  document.getElementById("step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
}

function drillToTrigger(triggerId: number) {
  // v3 (Phase E): legacy tabs are gone. Show the event panel directly.
  // Triggered from concept-card pill clicks; routes via Pathway A so the
  // Verfahrensablauf user surface stays consistent.
  const procedurePanel = document.getElementById("mode-procedure-panel");
  const eventPanel = document.getElementById("mode-event-panel");
  if (procedurePanel) procedurePanel.hidden = true;
  if (eventPanel) eventPanel.hidden = false;
  // Defer a tick so the panel swap has rendered before we touch state.
  window.setTimeout(() => {
    selectTriggerEvent(triggerId);
    document.getElementById("event-step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
  }, 0);
}

// ----- URL state -------------------------------------------------------------

function syncSearchURL(q: string) {
  const url = new URL(window.location.href);
  if (q === "") url.searchParams.delete("q");
  else url.searchParams.set("q", q);
  // Only push if the URL actually changed — avoids spamming history with
  // identical entries while the user types.
  if (url.toString() !== window.location.href) {
    window.history.replaceState(null, "", url.toString());
  }
}

function readInitialSearchQuery(): string {
  return new URLSearchParams(window.location.search).get("q") || "";
}

// Quick-pick chips (t-paliad-134) carry both DE and EN labels via
// data-chip-name-de / data-chip-name-en attributes. relabelChips
// rewrites the visible text to match the active language; chipQueryFor
// returns the active-language label for use as the search query.
function relabelChips() {
  const lang = getLang();
  document.querySelectorAll<HTMLButtonElement>(".fristen-search-chip").forEach((chip) => {
    const de = chip.dataset.chipNameDe;
    const en = chip.dataset.chipNameEn;
    if (!de && !en) return; // legacy chip without slug-based labels
    const label = lang === "en" ? (en || de || "") : (de || en || "");
    if (label && chip.textContent?.trim() !== label) {
      chip.textContent = label;
    }
    // data-q kept in sync so existing click paths (e.g. fork-chip path)
    // see the right query string without needing a chip-aware fallback.
    chip.dataset.q = label;
  });
}

function chipQueryFor(chip: HTMLButtonElement): string {
  const lang = getLang();
  const de = chip.dataset.chipNameDe;
  const en = chip.dataset.chipNameEn;
  if (de || en) {
    return lang === "en" ? (en || de || "") : (de || en || "");
  }
  return chip.dataset.q || chip.textContent || "";
}

// ----- Wiring ----------------------------------------------------------------

function initSearch() {
  const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
  const chips = document.getElementById("fristen-search-chips");
  const results = document.getElementById("fristen-search-results");
  const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
  if (!input || !chips || !results) return; // older bundle — skip silently

  // Initial state from URL.
  const initial = readInitialSearchQuery();
  if (initial) {
    input.value = initial;
    if (clearBtn) clearBtn.hidden = false;
    scheduleSearch(0);
  }

  input.addEventListener("input", () => scheduleSearch());
  input.addEventListener("keydown", (e) => {
    if ((e as KeyboardEvent).key === "Escape") {
      input.value = "";
      scheduleSearch(0);
    } else if ((e as KeyboardEvent).key === "Enter") {
      scheduleSearch(0);
    }
  });

  if (clearBtn) {
    clearBtn.addEventListener("click", () => {
      input.value = "";
      input.focus();
      scheduleSearch(0);
    });
  }

  chips.addEventListener("click", (e) => {
    const target = (e.target as HTMLElement).closest<HTMLButtonElement>(".fristen-search-chip");
    if (!target) return;
    // Slug-based chips (t-paliad-134) carry both labels and use the
    // active language. Legacy chips (no slug) fall back to data-q.
    const q = chipQueryFor(target);
    input.value = q;
    input.focus();
    scheduleSearch(0);
  });
  relabelChips();

  wirePillClicks(results);

  // Re-render on language flip so card / pill labels follow the active locale.
  onLangChange(() => {
    relabelChips();
    const q = input.value.trim();
    if (q !== "") scheduleSearch(0);
  });

  // Browser back/forward should restore search state.
  window.addEventListener("popstate", () => {
    const q = readInitialSearchQuery();
    if (q !== input.value) {
      input.value = q;
      scheduleSearch(0);
    }
  });
}

// Wire on DOM ready (the existing DOMContentLoaded handler is already busy;
// add a lightweight follow-up listener to keep the diff small).
document.addEventListener("DOMContentLoaded", initSearch);

// ============================================================================
// v3 pathway fork (t-paliad-133)
// ============================================================================
// Three-state landing surface: fork (default), Pathway A (Verfahrensablauf —
// existing wizard), Pathway B (Frist eintragen — search/B1/B2). URL ?path=
// drives visibility; localStorage remembers the last-used pathway for soft
// re-entry. ?legacy=1 keeps the pre-v3 layout (no fork) for parity testing
// during the rollout window.

// Pathway values:
//   fork     — Step 1 / Step 2 visible (the new front of the funnel)
//   outgoing — Step 3a chooser (File / Draft / Enter) visible
//   a        — Pathway A wizard (Verfahrensablauf timeline)
//   b        — Pathway B cascade
type Pathway = "fork" | "outgoing" | "a" | "b";
type BMode = "tree" | "filter";

const PATHWAY_STORAGE_KEY = "paliad.fristen.pathway";

function readPathwayFromURL(): Pathway {
  const sp = new URLSearchParams(window.location.search);
  const p = sp.get("path");
  if (p === "a" || p === "b" || p === "outgoing") return p;
  return "fork";
}

function readBModeFromURL(): BMode {
  const sp = new URLSearchParams(window.location.search);
  const m = sp.get("mode");
  if (m === "tree" || m === "filter") return m;
  // Default: tree mode (B1 cascade is the discovery surface; the
  // free-text/filter B2 mode is for power users who already know what
  // they want).
  return "tree";
}

function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
  const url = new URL(window.location.href);
  if (path === "fork") {
    url.searchParams.delete("path");
    url.searchParams.delete("mode");
    url.searchParams.delete("b1");
  } else {
    url.searchParams.set("path", path);
    if (path === "b" && mode) {
      url.searchParams.set("mode", mode);
    } else {
      url.searchParams.delete("mode");
    }
  }
  if (replace) {
    window.history.replaceState({}, "", url.toString());
  } else {
    window.history.pushState({}, "", url.toString());
  }
}

// resolveDeadlinesNewURL builds the Step 3a "Enter" destination. For a
// real project: /projects/{id}/deadlines/new. For ad-hoc explore-mode:
// /deadlines/new (the project picker is in the form itself, but the
// user has no Akte to attach it to without picking one anew). m's
// 2026-05-08 spec: this is the manual-entry escape hatch.
function resolveDeadlinesNewURL(ctx: Step1Context): string {
  if (ctx.kind === "project" && ctx.projectId) {
    return `/projects/${encodeURIComponent(ctx.projectId)}/deadlines/new`;
  }
  return "/deadlines/new";
}

function showPathway(path: Pathway, mode?: BMode) {
  // m's 2026-05-08 18:08 redesign retired the legacy fork; Step 1 and
  // Step 2 sit where it used to live. The "fork" Pathway value now
  // means "show Step 1 + Step 2 (Step 2 visibility gated by whether a
  // project / ad-hoc context is selected — managed by initStep1Step2)";
  // "a" / "b" still drive the existing wizard / cascade shells.
  const step1 = document.getElementById("fristen-step1");
  const step1Summary = document.getElementById("fristen-step1-summary");
  const step2 = document.getElementById("fristen-step2");
  const step3a = document.getElementById("fristen-step3a");
  const a = document.getElementById("fristen-pathway-a");
  const b = document.getElementById("fristen-pathway-b");
  if (!a || !b) return;

  // Step 1 + 2 stay mounted under "fork". Step 2 visibility is also
  // gated by the Step 1 context state — initStep1Step2 owns the
  // toggle between Step 1 expanded vs collapsed-with-summary, and
  // the "show Step 2" gate. We just hide them wholesale when not on
  // the fork.
  if (step1) step1.style.display = path === "fork" ? "" : "none";
  if (step1Summary) {
    // Summary stays visible from Step 2 onward so the user always
    // sees their selected Akte. Hidden only when no context is set.
    const ctx = readStep1ContextFromURL();
    step1Summary.style.display = (ctx.kind !== "none" && path !== "fork") ? "" : step1Summary.style.display;
  }
  if (step2) step2.hidden = path !== "fork";
  if (step3a) step3a.hidden = path !== "outgoing";
  a.hidden = path !== "a";
  b.hidden = path !== "b";

  if (path === "b") {
    showBMode(mode || readBModeFromURL());
  }
}

function showBMode(mode: BMode) {
  const tree = document.getElementById("fristen-b1-panel");
  const filter = document.getElementById("fristen-b2-panel");
  if (!tree || !filter) return;
  tree.hidden = mode !== "tree";
  filter.hidden = mode !== "filter";

  // Trigger tree load on entering tree mode. renderRowStack repaints
  // every row + calls runB1Search() for the current slug. With slug=""
  // this fetches every concept reachable from any leaf (browse-all,
  // t-paliad-134) so the user sees the full landscape before drilling
  // in.
  if (mode === "tree") {
    const stack = document.getElementById("fristen-row-stack");
    if (stack && stack.childElementCount === 0) {
      stack.innerHTML = `<div class="fristen-b1-stub">${escHtml(t("deadlines.search.loading"))}</div>`;
    }
    void loadAndRenderB1();
  }
}

function navigateToPathway(path: Pathway, mode?: BMode) {
  setPathwayURL(path, mode);
  showPathway(path, mode);
  if (path !== "fork") {
    try {
      localStorage.setItem(PATHWAY_STORAGE_KEY, path);
    } catch { /* private mode */ }
  }
}

// ============================================================================
// m's 2026-05-08 18:08 Determinator redesign — Step 1 + Step 2 state
// ============================================================================
// Step 1: pick the project (Akte) that scopes everything downstream, OR
// pick an ad-hoc explore-mode chip (4 jurisdictions). Step 2: choose
// between outgoing intent (Pathway A / Verfahrensablauf) and incoming
// event (Pathway B / cascade). Step 3+ stay as today (Pathway A wizard,
// B1 cascade, B2 search). The legacy "Was möchten Sie tun?" fork is
// retired; back-buttons inside Pathway A/B return to Step 2.

type Step1ContextKind = "project" | "adhoc" | "none";
type AdhocForum = "upc" | "de" | "epa" | "dpma";

interface Step1Context {
  kind: Step1ContextKind;
  projectId?: string;
  project?: ProjectOption;
  adhocForum?: AdhocForum;
}

let currentStep1Context: Step1Context = { kind: "none" };
let cachedAkten: ProjectOption[] = [];

function readStep1ContextFromURL(): Step1Context {
  const sp = new URLSearchParams(window.location.search);
  const project = sp.get("project");
  const adhoc = sp.get("ad_hoc");
  if (project) return { kind: "project", projectId: project };
  if (adhoc === "upc" || adhoc === "de" || adhoc === "epa" || adhoc === "dpma") {
    return { kind: "adhoc", adhocForum: adhoc };
  }
  return { kind: "none" };
}

function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
  const url = new URL(window.location.href);
  if (ctx.kind === "project" && ctx.projectId) {
    url.searchParams.set("project", ctx.projectId);
    url.searchParams.delete("ad_hoc");
  } else if (ctx.kind === "adhoc" && ctx.adhocForum) {
    url.searchParams.set("ad_hoc", ctx.adhocForum);
    url.searchParams.delete("project");
  } else {
    url.searchParams.delete("project");
    url.searchParams.delete("ad_hoc");
  }
  if (replace) window.history.replaceState({}, "", url.toString());
  else window.history.pushState({}, "", url.toString());
}

// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
// project to save against, so the CTA disables and renders a hint.
// t-paliad-168: also true when no Step 1 context is set at all (the
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
// Pathway A without an Akte). In both cases the user has no project
// to save against; the CTA renders disabled with the same hint.
function isAdhocMode(): boolean {
  return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
}

function adhocSummaryLabel(forum: AdhocForum): string {
  switch (forum) {
    case "upc":  return "Ad-hoc UPC";
    case "de":   return "Ad-hoc DE";
    case "epa":  return "Ad-hoc EPA";
    case "dpma": return "Ad-hoc DPMA";
  }
}

function renderAkteList(query: string) {
  const list = document.getElementById("fristen-akte-list");
  if (!list) return;
  const q = query.trim().toLowerCase();
  const filtered = q === ""
    ? cachedAkten.slice(0, 12)
    : cachedAkten.filter((p) =>
        (p.title || "").toLowerCase().includes(q) ||
        (p.reference || "").toLowerCase().includes(q));
  if (filtered.length === 0) {
    list.innerHTML = `<li class="fristen-akte-list-empty">${escHtml(t("deadlines.step1.search.empty"))}</li>`;
    return;
  }
  list.innerHTML = filtered.map((p) => {
    const ref = p.reference ? `<span class="fristen-akte-list-ref">${escHtml(p.reference)}</span> · ` : "";
    return `<li><button type="button" class="fristen-akte-list-item" data-project-id="${escAttr(p.id)}">
      ${ref}<span class="fristen-akte-list-title">${escHtml(p.title)}</span>
    </button></li>`;
  }).join("");
  list.querySelectorAll<HTMLButtonElement>(".fristen-akte-list-item").forEach((btn) => {
    btn.addEventListener("click", () => {
      const id = btn.dataset.projectId!;
      const project = cachedAkten.find((p) => p.id === id);
      if (project) selectProject(project);
    });
  });
}

function selectProject(project: ProjectOption) {
  currentStep1Context = { kind: "project", projectId: project.id, project };
  writeStep1ContextToURL(currentStep1Context);
  renderStep1Summary();
  showStep2Card();
  // t-paliad-164: project.our_side predefines the perspective chip.
  // Only fires when the user hasn't already locked a perspective via
  // ?role= in the URL — the URL pick wins because it represents an
  // explicit choice (chip click or shared link).
  applyOurSidePredefine(project, /* replaceURL */ false);
  // Slice 3b: project's proceeding type narrows the B1 cascade if the
  // user reaches it via Step 2 → Etwas ist passiert. Refresh here so
  // a cascade already on screen (rare but possible via popstate) picks
  // up the new narrowing.
  triggerCascadeRefresh();
}

function selectAdhoc(forum: AdhocForum) {
  currentStep1Context = { kind: "adhoc", adhocForum: forum };
  writeStep1ContextToURL(currentStep1Context);
  renderStep1Summary();
  showStep2Card();
  triggerCascadeRefresh();
}

function clearStep1Context() {
  currentStep1Context = { kind: "none" };
  writeStep1ContextToURL(currentStep1Context);
  renderStep1Summary();
  hideStep2Card();
  // t-paliad-180: dropping the project context makes the perspective
  // row revert from is-prefilled to is-answered (the "aus Akte" tag
  // disappears) — repainting the row stack is the only thing needed;
  // we deliberately leave currentPerspective alone, since the user
  // may want to keep their pick when returning to Step 1.
  triggerCascadeRefresh();
}

function renderStep1Summary() {
  const summary = document.getElementById("fristen-step1-summary") as HTMLElement | null;
  const name = document.getElementById("fristen-step1-summary-name");
  const meta = document.getElementById("fristen-step1-summary-meta");
  const step1 = document.getElementById("fristen-step1") as HTMLElement | null;
  if (!summary || !name || !step1) return;

  if (currentStep1Context.kind === "none") {
    summary.style.display = "none";
    step1.style.display = "";
    return;
  }

  if (currentStep1Context.kind === "project" && currentStep1Context.project) {
    const p = currentStep1Context.project;
    const ref = p.reference ? p.reference + " · " : "";
    name.textContent = ref + p.title;
    if (meta) meta.textContent = "";
  } else if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
    name.textContent = adhocSummaryLabel(currentStep1Context.adhocForum);
    if (meta) meta.textContent = " · " + t("deadlines.step1.summary.adhoc.suffix");
  }
  summary.style.display = "";
  step1.style.display = "none";
}

function showStep2Card() {
  const step2 = document.getElementById("fristen-step2");
  if (step2) step2.hidden = false;
}

function hideStep2Card() {
  const step2 = document.getElementById("fristen-step2");
  if (step2) step2.hidden = true;
}

function initPathwayFork() {
  // Set chip labels to active language before user sees them.
  relabelChips();

  // Hydrate Step 1 context from URL first — Step 2 visibility depends
  // on whether a project / ad-hoc chip is already locked in.
  currentStep1Context = readStep1ContextFromURL();

  // Initial Pathway render. Inherits the URL ?path= semantic — Step 2
  // having been satisfied is implied if path = a / b.
  const initial = readPathwayFromURL();
  const initialMode = readBModeFromURL();
  showPathway(initial, initialMode);

  // Step 1 summary visibility flows from the context kind.
  renderStep1Summary();
  if (currentStep1Context.kind !== "none") {
    showStep2Card();
  }

  if (initial !== "fork") {
    try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
  }

  // Step 1 — fetch projects + proceeding-types in parallel. Both are
  // small + cacheable; both are needed before the cascade narrowing
  // can fire correctly. Render the list as soon as projects come in,
  // refresh cascade once the proceeding-types map is also populated.
  void (async () => {
    const [projects] = await Promise.all([
      fetchProjects(),
      loadProceedingTypes(),
    ]);
    cachedAkten = projects;
    if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
      currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
      renderStep1Summary();
      // t-paliad-164: deep-link / refresh path. project loaded async, so
      // the predefine has to wait for cachedAkten. replace=true keeps
      // the URL clean — the user didn't navigate, they just refreshed.
      applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
    }
    renderAkteList("");
    // Cascade may already be on screen if the user landed with
    // ?path=b&project=<uuid>; re-render to apply the now-known forum.
    triggerCascadeRefresh();
  })();

  const akteSearch = document.getElementById("fristen-akte-search") as HTMLInputElement | null;
  if (akteSearch) {
    akteSearch.addEventListener("input", () => renderAkteList(akteSearch.value));
  }

  // Ad-hoc chips — explore-mode escape hatch. No DB write; the
  // save-modal CTA disables itself in this state.
  document.querySelectorAll<HTMLButtonElement>(".fristen-adhoc-chip").forEach((chip) => {
    chip.addEventListener("click", () => {
      const forum = chip.dataset.adHoc as AdhocForum | undefined;
      if (forum) selectAdhoc(forum);
    });
  });

  // t-paliad-180: perspective + inbox chip strips folded into the
  // row stack; click wiring now lives inside renderRowStack(). We
  // still hydrate perspective from URL here so the row stack picks
  // up the correct picked-value on first paint.
  applyPerspective(readPerspectiveFromURL());

  // t-paliad-180: search escape-hatch (Pathway B Direkt-suchen
  // button) routes to ?mode=filter. Reset link drops the cascade
  // slug back to root, keeping mode / perspective / inbox alone.
  // t-paliad-198 Slice 3: Direkt-suchen icon expands an inline search
  // overlay over the row stack — the legacy ?mode=filter route is kept
  // for deep-link compatibility but is no longer the user-facing path.
  document.getElementById("fristen-row-search-link")?.addEventListener("click", () => {
    setInlineSearchActive(true);
  });
  document.getElementById("fristen-row-search-panel-back")?.addEventListener("click", () => {
    setInlineSearchActive(false);
  });
  document.getElementById("fristen-row-reset")?.addEventListener("click", () => {
    currentActiveRow = null;
    navigateB1("");
  });
  initInlineSearch();

  // Reselect: drop the locked context, return to Step 1.
  document.getElementById("fristen-step1-summary-reselect")?.addEventListener("click", () => {
    clearStep1Context();
    // Bounce back to fork (Step 1 + 2) so the user sees the picker,
    // even if they were currently in Pathway A or B.
    if (initial !== "fork") {
      navigateToPathway("fork");
    }
  });

  // Step 2 cards — outgoing (Step 3a chooser) vs incoming (Pathway B
  // cascade). showPathway() owns the actual transition; we just drive
  // it from the action choice.
  document.getElementById("fristen-step2-file")?.addEventListener("click", () => {
    navigateToPathway("outgoing");
  });
  document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
    navigateToPathway("b", "tree");
  });
  // t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
  // has been retired — the abstract-browse intent lives on its own
  // route at /tools/verfahrensablauf now. No third-card handler here.

  // Step 3a cards — File / Draft / Enter. File drops into the existing
  // Pathway A wizard; Enter routes to the manual-create form;
  // Draft is a v1 placeholder (button disabled in markup, no handler).
  document.getElementById("fristen-step3a-file")?.addEventListener("click", () => {
    navigateToPathway("a");
  });
  document.getElementById("fristen-step3a-enter")?.addEventListener("click", () => {
    window.location.href = resolveDeadlinesNewURL(currentStep1Context);
  });

  // Back-from-Pathway buttons return to Step 2 (the new "fork" state).
  // Pathway A's back returns to Step 3a since that's where the user
  // came from in the new flow; pre-Slice-3 muscle memory of Pathway A
  // back going all the way to fork is preserved by clicking back twice.
  document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
    navigateToPathway("outgoing");
  });
  document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
    navigateToPathway("fork");
  });
  document.getElementById("fristen-step3a-back")?.addEventListener("click", () => {
    navigateToPathway("fork");
  });

  // t-paliad-180: B1/B2 mode toggle moved into the row stack. The
  // mode row's click handler (renderRowStack → handleRowPick) drives
  // setPathwayURL + showBMode now; no more standalone radio.

  // Quick-pick chips on the Step 2 shortcut row → jump straight to
  // Pathway B + filter mode + prefilled query. Same behaviour as the
  // legacy fork; only the ID's mounting point changed.
  document.querySelectorAll<HTMLButtonElement>("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
    chip.addEventListener("click", () => {
      const q = chipQueryFor(chip);
      const url = new URL(window.location.href);
      url.searchParams.set("path", "b");
      url.searchParams.set("mode", "filter");
      if (q) url.searchParams.set("q", q);
      window.history.pushState({}, "", url.toString());
      showPathway("b", "filter");
      const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
      if (input && q) {
        input.value = q;
        input.dispatchEvent(new Event("input", { bubbles: true }));
      }
    });
  });

  // Browser back/forward restores pathway + Step 1 context + perspective.
  window.addEventListener("popstate", () => {
    currentStep1Context = readStep1ContextFromURL();
    if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
      currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
    }
    renderStep1Summary();
    if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
    applyPerspective(readPerspectiveFromURL());
    // t-paliad-180: the "aus Akte" tag is now part of the prefilled
    // row state; buildRowStack derives visibility from
    // project.our_side ↔ currentPerspective inside the renderer, so
    // no separate DOM hint flip is needed on popstate.
    currentActiveRow = null;
    // t-paliad-197: popstate is a fresh navigation — auto-walk should
    // re-evaluate from scratch, no stale "ändern-just-clicked" cap.
    cascadeAutoWalkStopAfter = null;
    const path = readPathwayFromURL();
    const mode = readBModeFromURL();
    showPathway(path, mode);
  });
}

document.addEventListener("DOMContentLoaded", initPathwayFork);

// ============================================================================
// v3 B1 decision tree (t-paliad-133 Phase C)
// ============================================================================
// Data-driven cascade: fetch the event-categories tree from
// GET /api/tools/fristenrechner/event-categories, render the current
// step's button set, walk down on click, show breadcrumb + reset.
// Result cards below come from /api/tools/fristenrechner/search with
// ?event_category_slug= narrowing.

interface EventCategoryNode {
  id: string;
  slug: string;
  label_de: string;
  label_en: string;
  description_de?: string;
  description_en?: string;
  step_question_de?: string;
  step_question_en?: string;
  icon?: string;
  sort_order: number;
  is_leaf: boolean;
  // #15 follow-up: coarse forum tags ('upc' | 'de' | 'epa' | 'dpma').
  // Empty / undefined = neutral, node visible from every inbox setting.
  forums?: string[];
  // Slice 3c: party tags ('claimant' | 'defendant' | 'both' | 'court').
  // Empty / undefined = neutral. Hides leaves whose tag doesn't match
  // the active perspective chip.
  party?: string[];
  children?: EventCategoryNode[];
}

let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;

// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
  "ich-moechte-einreichen",
]);

async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
  if (eventCategoryTree) return eventCategoryTree;
  if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
  eventCategoryFetchInflight = (async () => {
    try {
      const r = await fetch("/api/tools/fristenrechner/event-categories");
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const data = await r.json();
      const raw = (data.tree || []) as EventCategoryNode[];
      eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
      return eventCategoryTree;
    } finally {
      eventCategoryFetchInflight = null;
    }
  })();
  return eventCategoryFetchInflight;
}

function readB1PathFromURL(): string {
  return new URLSearchParams(window.location.search).get("b1") || "";
}

function setB1PathInURL(slug: string, replace = false) {
  const url = new URL(window.location.href);
  if (slug) {
    url.searchParams.set("b1", slug);
  } else {
    url.searchParams.delete("b1");
  }
  if (replace) {
    window.history.replaceState({}, "", url.toString());
  } else {
    window.history.pushState({}, "", url.toString());
  }
}

function findNodeBySlug(roots: EventCategoryNode[], slug: string): EventCategoryNode | null {
  for (const root of roots) {
    if (root.slug === slug) return root;
    if (root.children) {
      const inner = findNodeBySlug(root.children, slug);
      if (inner) return inner;
    }
  }
  return null;
}

function buildBreadcrumb(roots: EventCategoryNode[], slug: string): EventCategoryNode[] {
  // Slug is dot-separated; walk down each segment.
  if (!slug) return [];
  const parts = slug.split(".");
  const trail: EventCategoryNode[] = [];
  let scope = roots;
  let cumulative = "";
  for (const seg of parts) {
    cumulative = cumulative ? `${cumulative}.${seg}` : seg;
    const node = scope.find((n) => n.slug === cumulative);
    if (!node) break;
    trail.push(node);
    scope = node.children || [];
  }
  return trail;
}

function nodeLabel(n: EventCategoryNode): string {
  return getLang() === "de" ? n.label_de : n.label_en;
}

function nodeStepQuestion(n: EventCategoryNode): string {
  return getLang() === "de"
    ? (n.step_question_de || "")
    : (n.step_question_en || n.step_question_de || "");
}

// t-paliad-180 Slice 1 — row-stack rendering.
//
// The Pathway B shell used to layer four functionally different decision
// surfaces with four different visuals: a radio (mode), two pill strips
// (perspective + inbox), and a button-grid cascade with a breadcrumb.
// They're all "narrow the deadline-rule space" steps; the new model
// renders every one of them as the same `.fristen-row` primitive in a
// single stack — top-down, persistent, each row showing the question +
// the picked answer + an inline "ändern" affordance.
//
// Slice 1 is visual-only: the narrowing engine (forum + perspective
// filters) is unchanged. What changes is presentation — the user can
// finally see their full decision path at a glance, and edit any row
// without losing the rest. Slice 2 will add project-driven prefills +
// auto-walk; Slice 3 polishes mobile + the search escape-hatch.

// t-paliad-198 Slice 3: mode kind retired — the icon-button at the top
// of the row stack (`#fristen-row-search-link`) replaces the mode row,
// toggling an inline search overlay instead of routing to ?mode=filter.
type RowKind = "perspective" | "inbox" | "cascade";
type RowState = "active" | "answered" | "prefilled";

interface RowOption {
  value: string;
  label: string;
  icon?: string;
  isLeaf?: boolean;
  title?: string;
}

interface RowSpec {
  kind: RowKind;
  rowId: string;
  state: RowState;
  question: string;
  options: RowOption[];
  pickedValue?: string;
  pickedLabel?: string;
  pickedIcon?: string;
  // is-prefilled rows can carry an "aus Akte: <reference>" annotation.
  // Per design §11.2, only the first prefilled row in a stack shows the
  // reference; subsequent ones show the plain tag.
  prefilledReference?: string;
  // Cascade-only: the slug of the ancestor node whose children populate
  // this row's options. Used by the click handler to navigate to a
  // sibling without losing prefix state. Empty string = cascade root.
  cascadeParentSlug?: string;
  // Cascade-only: the slug to navigate to when the user clicks "ändern"
  // on this answered row. Always the row's parent slug (ancestor at
  // depth K-1), which drops descendants and re-actives this depth.
  cascadeRevertSlug?: string;
  // Cascade-only: the depth this row occupies in the post-auto-walk
  // trail (0 = root bucket). Used by handleRowEdit to compute the
  // auto-walk suppression key.
  cascadeDepth?: number;
}

// currentActiveRow tracks which non-cascade row the user has clicked
// "ändern" on. null = no override; the deepest cascade step is the
// natural active row. Cleared on every successful pick.
let currentActiveRow: string | null = null;

// cascadeAutoWalkStopAfter caps the auto-walk depth for the next
// render. Set when the user clicks "ändern" on an auto-walked cascade
// row at depth K — auto-walk would otherwise re-fire on render and
// undo the edit. The cap stays in place until the user picks a chip
// (which clears it via handleRowPick → renderRowStack → cleared by
// the picker), at which point auto-walk re-engages from the new
// position. null = no cap (auto-walk runs to first branching point).
let cascadeAutoWalkStopAfter: number | null = null;

// cascadeTooltipDismissedKey backs the "first auto-walk tooltip" flag
// in localStorage. Once the user dismisses the tooltip, the key stays
// for future sessions; we only re-prompt if the key gets cleared.
const cascadeTooltipDismissedKey = "paliad.fristen.cascade.autoWalkTooltipSeen";

function perspectiveOptionLabel(value: string): string {
  if (value === "claimant") return t("deadlines.perspective.claimant.short");
  if (value === "defendant") return t("deadlines.perspective.defendant.short");
  return t("deadlines.perspective.both.short");
}

function inboxOptionLabel(value: string): string {
  if (value === "cms") return "CMS";
  if (value === "bea") return "beA";
  if (value === "posteingang") return t("deadlines.inbox.posteingang");
  return t("deadlines.inbox.all");
}


// Slice 2: cascade-segment ↔ fristenrechner-code bridge. The event_categories
// taxonomy uses kebab-case segments under the `cms-eingang.*` buckets to
// represent proceedings (`upc-inf`, `de-bgh-null`, …); paliad.projects
// binds to fristenrechner codes by id and the lookup yields the
// lowercase dot-separated taxonomy ratified by mig 096
// (`upc.inf.cfi`, `de.inf.bgh`, …). The event_categories slugs are NOT
// renamed by mig 096 — they live in a separate taxonomy and the kebab
// form is presentation-layer (it appears in URL fragments). This map
// is the bridge. Any code not in the map degrades to "no proceeding-
// axis narrowing" — better silent than wrong (design §11.6).
//
// upc.ccr.cfi is the illustrative peer added by mig 096; it shares the
// `upc-inf` kebab segment because rules live on upc.inf.cfi with
// with_ccr=true (design doc S1, proceeding_mapping.go).
const fristenrechnerCodeToCascadeSegment: Record<string, string> = {
  "upc.inf.cfi":    "upc-inf",
  "upc.ccr.cfi":    "upc-inf",
  "upc.rev.cfi":    "upc-rev",
  "upc.apl.merits": "upc-app",
  "upc.pi.cfi":     "upc-pi",
  "de.inf.lg":      "de-inf",
  "de.null.bpatg":  "de-null",
  "de.inf.bgh":     "de-bgh-inf",
  "de.null.bgh":    "de-bgh-null",
  "dpma.opp.dpma":  "dpma-opp",
  "dpma.appeal.bgh":"dpma-bgh",
  "epa.opp.opd":    "epa-opp",
  "epa.opp.boa":    "epa-app",
};

// Set of kebab segments known to be proceeding-axis values. Used to
// distinguish "this child is a proceeding pick" from "this child is a
// sender / bucket / Schriftsatz" — proceeding-axis siblings get filtered
// by project context, others always pass.
const proceedingCascadeSegments = new Set(Object.values(fristenrechnerCodeToCascadeSegment));

function fristenrechnerCodeFromProject(p?: ProjectOption | null): string | null {
  if (!p || p.proceeding_type_id == null) return null;
  return cachedProceedingTypes.get(p.proceeding_type_id) ?? null;
}

function projectCascadeSegment(p?: ProjectOption | null): string | null {
  const code = fristenrechnerCodeFromProject(p);
  if (!code) return null;
  return fristenrechnerCodeToCascadeSegment[code] ?? null;
}

// cascadeChildAllowsProject narrows cascade children to those matching
// the project's proceeding code along the proceeding axis. Children
// whose terminal slug segment isn't a known proceeding segment always
// pass (they're orthogonal axes — sender / bucket / Schriftsatz / …).
// Without a project context this is a no-op.
function cascadeChildAllowsProject(child: EventCategoryNode, projectSegment: string | null): boolean {
  if (!projectSegment) return true;
  const terminal = child.slug.split(".").pop() || "";
  if (!proceedingCascadeSegments.has(terminal)) return true;
  return terminal === projectSegment;
}

function filterCascadeChildren(scope: EventCategoryNode[], projectSegment: string | null): EventCategoryNode[] {
  return scope.filter((c) =>
    inboxFilterAllowsForums(c.forums)
    && perspectiveAllowsParty(c.party)
    && cascadeChildAllowsProject(c, projectSegment));
}

// projectReferenceLabel returns the short identifier used in the "aus
// Akte: <reference>" tag. Falls back to a short slice of the UUID when
// the project has no human-readable reference yet.
function projectReferenceLabel(p?: ProjectOption | null): string {
  if (!p) return "";
  if (p.reference && p.reference.trim() !== "") return p.reference.trim();
  return p.id ? p.id.slice(0, 8) : "";
}

function buildRowStack(currentSlug: string): RowSpec[] {
  const rows: RowSpec[] = [];
  const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
  const hasProject = !!proj;
  const projectForum = hasProject ? forumFromProject(proj) : null;
  const projectSegment = projectCascadeSegment(proj);
  const projectRef = projectReferenceLabel(proj);

  // Once a single "aus Akte: <reference>" tag has been rendered, subsequent
  // prefilled rows omit the reference (design §11.2) — repeating it on
  // every row is just noise.
  let referenceTagUsed = false;
  const consumeReferenceTag = (): string => {
    if (!projectRef) return "";
    if (referenceTagUsed) return "";
    referenceTagUsed = true;
    return projectRef;
  };

  // R1 — Perspective. Default null ("Beide") = no filter; the row
  // renders as answered with that label. t-paliad-164 carry-over: a
  // project-bound perspective shows as `is-prefilled` with the "aus Akte"
  // tag in the picked-answer area.
  const perspectiveValue = currentPerspective ?? "";
  const expectedFromAkte = ourSideToPerspective(proj?.our_side);
  const perspectivePrefilled =
    !!(proj && proj.our_side && expectedFromAkte === currentPerspective && currentPerspective !== null);
  rows.push({
    kind: "perspective",
    rowId: "perspective",
    state: currentActiveRow === "perspective"
      ? "active"
      : perspectivePrefilled ? "prefilled" : "answered",
    question: t("deadlines.perspective.label"),
    options: [
      { value: "claimant", label: t("deadlines.perspective.claimant.short"), title: t("deadlines.perspective.claimant.title") },
      { value: "defendant", label: t("deadlines.perspective.defendant.short"), title: t("deadlines.perspective.defendant.title") },
      { value: "", label: t("deadlines.perspective.both.short") },
    ],
    pickedValue: perspectiveValue,
    pickedLabel: perspectiveOptionLabel(perspectiveValue),
    prefilledReference: perspectivePrefilled ? consumeReferenceTag() : undefined,
  });

  // R2 — Inbox channel. Hidden entirely when the project's forum is UPC
  // (CMS is the only valid inbox for UPC matters, so the question is
  // moot — design §5 matrix). For non-UPC projects, ad-hoc explore mode,
  // and the no-context case, the row renders as answered with whatever
  // "Wo kam es an?" channel was last picked (default: "Alle").
  const r2Hidden = projectForum === "upc" && currentActiveRow !== "inbox";
  if (!r2Hidden) {
    const inboxValue = currentInboxChannel ?? "";
    rows.push({
      kind: "inbox",
      rowId: "inbox",
      state: currentActiveRow === "inbox" ? "active" : "answered",
      question: t("deadlines.inbox.label"),
      options: [
        { value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") },
        { value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") },
        { value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") },
        { value: "", label: t("deadlines.inbox.all") },
      ],
      pickedValue: inboxValue,
      pickedLabel: inboxOptionLabel(inboxValue),
    });
  }

  // R3..Rn — Cascade. URL slug = user-picked deepest position. The
  // trail walks the URL slug; each trail node renders as `answered`
  // unless it was the sole option at its parent's filtered scope under
  // a project context — in which case it's `prefilled` (the user
  // effectively had no choice; project context implied this pick).
  //
  // After the trail, the auto-walk extends the stack with `prefilled`
  // rows whenever the remaining scope narrows to a single option under
  // a project context; the walk stops at the first branching point,
  // empty scope, or leaf — which is where the active row goes (if any).
  // Auto-walk does NOT extend the URL — the user-picked slug stays
  // explicit. "Ändern" on an auto-walked row sets `cascadeAutoWalkStopAfter`
  // so the next render shows the active row at that depth.
  const trail = eventCategoryTree
    ? buildBreadcrumb(eventCategoryTree, currentSlug)
    : [];
  let parentScope: EventCategoryNode[] = eventCategoryTree || [];
  let parentSlug = "";
  let parentQuestion = t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?";

  for (let i = 0; i < trail.length; i++) {
    const node = trail[i];
    const filteredSiblings = filterCascadeChildren(parentScope, projectSegment);
    // "Sole option" detection: at this depth, the project-filtered scope
    // contains only this node — the pick was implied by context.
    const wasSoleOption = hasProject && filteredSiblings.length === 1
      && filteredSiblings[0].slug === node.slug;
    const isPrefilled = wasSoleOption;
    rows.push({
      kind: "cascade",
      rowId: `cascade:${i}`,
      state: isPrefilled ? "prefilled" : "answered",
      question: parentQuestion,
      options: filteredSiblings.map(nodeToRowOption),
      pickedValue: node.slug,
      pickedLabel: nodeLabel(node),
      pickedIcon: node.icon,
      cascadeParentSlug: parentSlug,
      cascadeRevertSlug: parentSlug,
      cascadeDepth: i,
      prefilledReference: isPrefilled ? consumeReferenceTag() : undefined,
    });
    parentScope = node.children || [];
    parentSlug = node.slug;
    parentQuestion = nodeStepQuestion(node) || parentQuestion;
  }

  // Auto-walk extension + active row. Walk down the cascade as long as
  // the filtered scope narrows to a single option (with a project
  // context that justifies the inference); stop at branching, empty
  // scope, or a leaf. The first branching point becomes the active row;
  // an empty scope or leaf finalises the stack with no active row.
  let walkDepth = trail.length;
  const deepestTrail = trail.length > 0 ? trail[trail.length - 1] : null;
  let walked: EventCategoryNode | null = null;

  if (!deepestTrail || !deepestTrail.is_leaf) {
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const filtered = filterCascadeChildren(parentScope, projectSegment);
      if (filtered.length === 0) break;
      const canAutoWalk = filtered.length === 1
        && hasProject
        && (cascadeAutoWalkStopAfter === null || walkDepth < cascadeAutoWalkStopAfter);
      if (canAutoWalk) {
        const node = filtered[0];
        rows.push({
          kind: "cascade",
          rowId: `cascade:${walkDepth}`,
          state: "prefilled",
          question: parentQuestion,
          options: filtered.map(nodeToRowOption),
          pickedValue: node.slug,
          pickedLabel: nodeLabel(node),
          pickedIcon: node.icon,
          cascadeParentSlug: parentSlug,
          // Revert slug for an auto-walked row is the URL-explicit slug
          // (= the trail tail). ändern on this row should NOT change the
          // URL — it suppresses auto-walk past this depth instead. See
          // handleRowEdit's cascade branch.
          cascadeRevertSlug: parentSlug,
          cascadeDepth: walkDepth,
          prefilledReference: consumeReferenceTag(),
        });
        walked = node;
        parentScope = node.children || [];
        parentSlug = node.slug;
        parentQuestion = nodeStepQuestion(node) || parentQuestion;
        walkDepth += 1;
        if (node.is_leaf) break;
        continue;
      }
      // Branching or no-project — render active row at this depth.
      rows.push({
        kind: "cascade",
        rowId: `cascade:${walkDepth}`,
        state: "active",
        question: parentQuestion,
        options: filtered.map(nodeToRowOption),
        cascadeParentSlug: parentSlug,
        cascadeRevertSlug: parentSlug,
        cascadeDepth: walkDepth,
      });
      break;
    }
  }

  // Stash the effective deepest slug so renderRowStack can pass it to
  // runB1Search — the result panel narrows on the auto-walked leaf, not
  // the URL slug.
  cascadeEffectiveSlug = walked ? walked.slug : (deepestTrail ? deepestTrail.slug : "");

  return rows;
}

// cascadeEffectiveSlug is the deepest slug after auto-walk extension —
// used by runB1Search to narrow the concept-card results to the
// effective cascade position, not just the URL-explicit slug. Updated
// by buildRowStack on every render.
let cascadeEffectiveSlug = "";

function nodeToRowOption(c: EventCategoryNode): RowOption {
  return {
    value: c.slug,
    label: nodeLabel(c),
    icon: c.icon,
    isLeaf: c.is_leaf,
  };
}

function rowOptionChipHtml(rowId: string, opt: RowOption, isPicked: boolean): string {
  const cls = [
    "fristen-row-chip",
    opt.isLeaf ? "fristen-row-chip--leaf" : "",
    isPicked ? "is-picked" : "",
  ].filter(Boolean).join(" ");
  const titleAttr = opt.title ? ` title="${escAttr(opt.title)}"` : "";
  const iconHtml = opt.icon
    ? `<span class="fristen-row-chip-icon" aria-hidden="true">${escHtml(opt.icon)}</span>`
    : "";
  return `<button type="button" class="${cls}" data-row-id="${escAttr(rowId)}"
                  data-row-value="${escAttr(opt.value)}"${titleAttr}>
            ${iconHtml}<span class="fristen-row-chip-label">${escHtml(opt.label)}</span>
          </button>`;
}

function rowHtml(row: RowSpec, rowNumber: number): string {
  const stateClass = `is-${row.state}`;
  const ariaCurrent = row.state === "active" ? ' aria-current="step"' : "";
  const dataKindAttr = ` data-row-kind="${escAttr(row.kind)}"`;
  const dataIdAttr = ` data-row-id="${escAttr(row.rowId)}"`;

  if (row.state === "active") {
    const chipsHtml = row.options.map((o) =>
      rowOptionChipHtml(row.rowId, o, o.value === (row.pickedValue ?? ""))).join("");
    return `<div class="fristen-row ${stateClass}"${ariaCurrent}${dataKindAttr}${dataIdAttr}>
              <div class="fristen-row-head">
                <span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
                <span class="fristen-row-label">${escHtml(row.question)}</span>
              </div>
              <div class="fristen-row-body">${chipsHtml}</div>
            </div>`;
  }

  // answered + prefilled share the compact head-only layout.
  const iconHtml = row.pickedIcon
    ? `<span class="fristen-row-answer-icon" aria-hidden="true">${escHtml(row.pickedIcon)}</span>`
    : "";
  // Prefilled tag carries the project reference on the first prefilled
  // row only (design §11.2 — subsequent rows show the plain "aus Akte"
  // tag). prefilledReference !== "" signals "this row owns the
  // reference"; "" / undefined signals plain tag.
  const prefilledTag = row.state === "prefilled"
    ? (row.prefilledReference
        ? `<span class="fristen-row-prefilled-tag">
            <span data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>:
            <span class="fristen-row-prefilled-ref">${escHtml(row.prefilledReference)}</span>
           </span>`
        : `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`)
    : "";
  return `<div class="fristen-row ${stateClass}"${dataKindAttr}${dataIdAttr}>
            <div class="fristen-row-head">
              <span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
              <span class="fristen-row-label">${escHtml(row.question)}</span>
              <span class="fristen-row-answer">
                <span class="fristen-row-answer-check" aria-hidden="true">&#10003;</span>
                ${iconHtml}<span class="fristen-row-answer-label">${escHtml(row.pickedLabel || "")}</span>
                ${prefilledTag}
              </span>
              <button type="button" class="fristen-row-edit" data-row-edit="${escAttr(row.rowId)}">
                <span>${escHtml(t("deadlines.row.edit"))}</span>
              </button>
            </div>
          </div>`;
}

function renderRowStack(currentSlug: string) {
  const stack = document.getElementById("fristen-row-stack");
  if (!stack || !eventCategoryTree) return;

  const rows = buildRowStack(currentSlug);
  stack.innerHTML = rows.map((r, i) => rowHtml(r, i + 1)).join("");
  maybeShowAutoWalkTooltip(stack, rows);
  // t-paliad-198 Slice 3: bring the active row into view on every
  // render. Particularly important on mobile, where a chip pick may
  // push the next active row below the fold; the helper is a no-op
  // when the row is already in view, so desktop doesn't see jumpy
  // scrolling.
  autoscrollToActiveRow(stack);

  // Wire chip picks (active rows).
  stack.querySelectorAll<HTMLButtonElement>(".fristen-row-chip").forEach((btn) => {
    btn.addEventListener("click", () => {
      const rowId = btn.dataset.rowId || "";
      const value = btn.dataset.rowValue || "";
      handleRowPick(rowId, value);
    });
  });

  // Wire "ändern" affordance + whole-row click on answered/prefilled
  // rows. Whole-row click follows the CLAUDE.md "row-level click handler
  // that skips inner <a>/<button>" pattern — clicking the ändern button
  // is handled by its own listener, so the row-level handler skips it.
  stack.querySelectorAll<HTMLButtonElement>(".fristen-row-edit").forEach((btn) => {
    btn.addEventListener("click", (ev) => {
      ev.stopPropagation();
      const rowId = btn.dataset.rowEdit || "";
      handleRowEdit(rowId);
    });
  });
  stack.querySelectorAll<HTMLDivElement>(".fristen-row.is-answered, .fristen-row.is-prefilled")
    .forEach((row) => {
      row.addEventListener("click", (ev) => {
        const target = ev.target as Element | null;
        if (target && target.closest("a, button")) return;
        const rowId = (row as HTMLElement).dataset.rowId || "";
        handleRowEdit(rowId);
      });
    });

  runB1Search(cascadeEffectiveSlug);
}

// handleRowPick routes a chip selection in an active row to the right
// state mutator + URL writer. Cascade picks navigate to the child slug
// (slugs encode the full path, so cascade depth is reconstructed on
// every render); the other kinds update their per-kind state + clear
// the "currently being edited" override.
function handleRowPick(rowId: string, value: string) {
  // t-paliad-198 Slice 3: any chip pick is also a "user interacted"
  // signal — the auto-walk tooltip's job is done.
  dismissAutoWalkTooltip();
  if (rowId === "perspective") {
    const next: Perspective = value === "claimant" || value === "defendant" ? value : null;
    writePerspectiveToURL(next);
    currentActiveRow = null;
    cascadeAutoWalkStopAfter = null;
    applyPerspective(next);
    return;
  }
  if (rowId === "inbox") {
    const next: InboxChannel = value === "cms" || value === "bea" || value === "posteingang"
      ? value : null;
    writeInboxToURL(next);
    currentActiveRow = null;
    cascadeAutoWalkStopAfter = null;
    applyInboxFilter(next);
    applyFineForumsFromInbox(next);
    writeForumsToURL(true);
    void persistInboxPref(next);
    return;
  }
  if (rowId.startsWith("cascade:")) {
    currentActiveRow = null;
    // A new cascade pick clears any auto-walk cap — the user has
    // committed to this branch, so auto-walk can re-engage from here.
    cascadeAutoWalkStopAfter = null;
    navigateB1(value);
    return;
  }
}

// handleRowEdit re-activates an answered row. For non-cascade rows that
// flips the row to active in-place (toggle via currentActiveRow) so the
// user can re-pick; the cascade below stays valid. For cascade rows it
// either drops descendants by navigating to the row's revert slug —
// matching today's breadcrumb-click semantic — or, when the row was
// auto-walked, caps the auto-walk depth so the row turns active
// in-place without changing the URL.
function handleRowEdit(rowId: string) {
  if (rowId === "perspective" || rowId === "inbox") {
    currentActiveRow = rowId;
    renderRowStack(readB1PathFromURL());
    // t-paliad-198 Slice 3: any explicit ändern click counts as user
    // interaction, so the auto-walk tooltip is no longer needed.
    dismissAutoWalkTooltip();
    return;
  }
  if (rowId.startsWith("cascade:")) {
    const idx = parseInt(rowId.slice("cascade:".length), 10);
    if (!Number.isFinite(idx)) return;
    const urlSlug = readB1PathFromURL();
    const trail = buildBreadcrumb(eventCategoryTree || [], urlSlug);
    if (idx < trail.length) {
      // Row K is within the user-explicit trail — ändern drops to
      // the parent slug, which drops descendants AND clears any
      // auto-walk suppression so the next pick can extend again.
      const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : "";
      currentActiveRow = null;
      cascadeAutoWalkStopAfter = null;
      navigateB1(revertSlug);
      dismissAutoWalkTooltip();
      return;
    }
    // Row K is auto-walked (beyond the URL trail). Suppress auto-walk
    // past depth K so the row materialises as active in the next
    // render — URL stays at the trail end.
    cascadeAutoWalkStopAfter = idx;
    currentActiveRow = null;
    renderRowStack(urlSlug);
    dismissAutoWalkTooltip();
  }
}

// ============================================================================
// t-paliad-198 Slice 3 — inline search overlay + autoscroll-to-active +
// tooltip polish.
// ============================================================================
//
// Inline search replaces the Slice 1 mode-toggle row: clicking the
// `🔍 Direkt suchen` icon in the row-stack header expands a search input
// over the row stack and renders results into the same #fristen-b1-results
// container the cascade narrows into. ESC inside the input or the back
// button collapses it. State is ephemeral — no URL change — so a user can
// step into search to check a name, then return to the cascade with all
// their picks intact. Deep-link path ?mode=filter still routes to the
// legacy B2 panel for backwards compatibility, but is no longer exposed
// in the cascade UI.

let inlineSearchActive = false;
let inlineSearchSeq = 0;
let inlineSearchDebounce: number | undefined;

function setInlineSearchActive(active: boolean) {
  if (inlineSearchActive === active) return;
  inlineSearchActive = active;
  const panel = document.getElementById("fristen-row-search-panel");
  const header = document.getElementById("fristen-row-stack-header");
  const stack = document.getElementById("fristen-row-stack");
  const results = document.getElementById("fristen-b1-results");
  const trigger = document.getElementById("fristen-row-search-link") as HTMLButtonElement | null;
  const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
  if (!panel || !stack) return;
  panel.hidden = !active;
  stack.hidden = active;
  // Reset link belongs to the cascade affordance set; hide while the
  // user is in search mode so the header reads as "← back to tree" only.
  if (header) header.classList.toggle("is-inline-search-active", active);
  if (trigger) trigger.setAttribute("aria-expanded", active ? "true" : "false");
  if (active) {
    if (input) {
      input.focus();
      if (input.value.trim() !== "") {
        scheduleInlineSearch(0);
      } else if (results) {
        // No query yet — clear results so a stale cascade leaf panel
        // doesn't show alongside the search input.
        results.innerHTML = "";
        results.classList.remove("is-loading", "is-no-hits");
      }
    }
  } else {
    // On collapse, restore the cascade results for the current slug
    // so the user picks up exactly where they left off.
    runB1Search(cascadeEffectiveSlug || readB1PathFromURL());
  }
}

function scheduleInlineSearch(delayMs = 180) {
  if (inlineSearchDebounce !== undefined) window.clearTimeout(inlineSearchDebounce);
  inlineSearchDebounce = window.setTimeout(runInlineSearch, delayMs);
}

async function runInlineSearch() {
  const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
  const results = document.getElementById("fristen-b1-results");
  const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
  if (!input || !results) return;
  const q = input.value.trim();
  if (clearBtn) clearBtn.hidden = q.length === 0;
  if (q === "") {
    results.innerHTML = "";
    results.classList.remove("is-loading", "is-no-hits");
    return;
  }
  results.classList.remove("is-no-hits");
  results.classList.add("is-loading");
  if (results.childElementCount === 0) {
    results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
  }
  const seq = ++inlineSearchSeq;
  try {
    const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
    url.searchParams.set("q", q);
    url.searchParams.set("limit", "12");
    const forums = getActiveForumsParam();
    if (forums) url.searchParams.set("forum", forums);
    const r = await fetch(url.toString(), { credentials: "same-origin" });
    if (seq !== inlineSearchSeq) return;
    results.classList.remove("is-loading");
    if (!r.ok) {
      results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
      return;
    }
    const data = (await r.json()) as SearchResponse;
    if (seq !== inlineSearchSeq) return;
    if (!data.cards || data.cards.length === 0) {
      results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
      results.classList.add("is-no-hits");
      return;
    }
    renderSearchResultsInto("fristen-b1-results", data);
  } catch {
    if (seq !== inlineSearchSeq) return;
    results.classList.remove("is-loading");
    results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
  }
}

function initInlineSearch() {
  const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
  const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
  if (input) {
    input.addEventListener("input", () => scheduleInlineSearch());
    input.addEventListener("keydown", (ev) => {
      if (ev.key === "Escape") {
        if (input.value.trim() !== "") {
          // First ESC: clear the input; second ESC: close the panel.
          input.value = "";
          scheduleInlineSearch(0);
          return;
        }
        setInlineSearchActive(false);
      }
    });
  }
  if (clearBtn) {
    clearBtn.addEventListener("click", () => {
      if (!input) return;
      input.value = "";
      input.focus();
      scheduleInlineSearch(0);
    });
  }
}

// autoscrollToActiveRow brings the active cascade row into view when a
// user pick on a higher row causes descendants to drop and the next
// active row is now below the fold. The 60px headroom matches the
// pattern used by the Akte picker (Step 1) and other paliad forms so
// the row's chip body is visible without scrolling further. Desktop +
// mobile share this behaviour; the visual change is only noticeable on
// narrow viewports where the row stack scrolls.
function autoscrollToActiveRow(stack: HTMLElement) {
  const active = stack.querySelector<HTMLElement>(".fristen-row.is-active");
  if (!active) return;
  // Skip if the active row is already fully visible (avoid jumpy
  // scroll-on-every-render on desktop).
  const rect = active.getBoundingClientRect();
  if (rect.top >= 60 && rect.bottom <= window.innerHeight - 16) return;
  const targetY = window.scrollY + rect.top - 60;
  window.scrollTo({ top: Math.max(0, targetY), behavior: "smooth" });
}

// dismissAutoWalkTooltip removes the live tooltip element (if any) and
// flips the localStorage suppression key so future renders skip it.
// Called from any meaningful user interaction (chip pick, ändern,
// dismiss-button) — once the user has touched the cascade they no
// longer need the inference hint.
function dismissAutoWalkTooltip() {
  document.querySelector(".fristen-row-autowalk-tip")?.remove();
  try { localStorage.setItem(cascadeTooltipDismissedKey, "1"); } catch { /* private mode */ }
}

// maybeShowAutoWalkTooltip surfaces a one-time hint when ≥ 2 cascade
// rows render in the prefilled (auto-walked) state. Per design §11.3
// and Q11 (deferred to v2 nice-to-have), the tooltip only appears the
// first time the user lands in a multi-row auto-walk — once dismissed,
// localStorage suppresses it forever. Tooltip is inline (no portal /
// modal): it injects a small banner above the first prefilled row.
function maybeShowAutoWalkTooltip(stack: HTMLElement, rows: RowSpec[]) {
  const prefilledCount = rows.filter((r) => r.state === "prefilled" && r.kind === "cascade").length;
  if (prefilledCount < 2) return;
  let dismissed = false;
  try { dismissed = localStorage.getItem(cascadeTooltipDismissedKey) === "1"; } catch { /* private mode */ }
  if (dismissed) return;
  // Inject the tooltip element. It's a sibling of the row stack, slotted
  // just above the first prefilled row via DOM insertion. The
  // `is-entering` class drives the fade-in + slide-down animation in
  // CSS (Slice 3 polish); we remove it on the next animation frame so
  // the CSS transition kicks in.
  const firstPrefilled = stack.querySelector(".fristen-row.is-prefilled");
  if (!firstPrefilled) return;
  const tip = document.createElement("div");
  tip.className = "fristen-row-autowalk-tip is-entering";
  tip.setAttribute("role", "status");
  tip.innerHTML = `
    <span class="fristen-row-autowalk-tip-icon" aria-hidden="true">&#9432;</span>
    <span class="fristen-row-autowalk-tip-text" data-i18n="deadlines.row.autowalk.tooltip">
      ${escHtml(t("deadlines.row.autowalk.tooltip"))}
    </span>
    <button type="button" class="fristen-row-autowalk-tip-dismiss"
            aria-label="${escAttr(t("deadlines.row.autowalk.dismiss"))}">
      &times;
    </button>`;
  // Desktop: tip sits above the prefilled row so it reads as a header.
  // Mobile (<640px): tip sits below the row so the user's eye lands on
  // the row's "aus Akte" tag first and reads the explanation beneath
  // — matches design §7 + the natural reading pattern on small screens.
  const isMobile = window.matchMedia && window.matchMedia("(max-width: 640px)").matches;
  if (isMobile) {
    firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled.nextSibling);
  } else {
    firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled);
  }
  // Trigger the transition: remove the is-entering offset on next frame.
  window.requestAnimationFrame(() => tip.classList.remove("is-entering"));
  tip.querySelector<HTMLButtonElement>(".fristen-row-autowalk-tip-dismiss")?.addEventListener("click", () => {
    dismissAutoWalkTooltip();
  });
}


// b1SearchSeq guards against out-of-order responses when the user
// click-cascades faster than the network. Only the latest invocation
// gets to render its result.
let b1SearchSeq = 0;

async function runB1Search(slug: string) {
  const results = document.getElementById("fristen-b1-results");
  if (!results) return;
  wirePillClicks(results);

  const seq = ++b1SearchSeq;
  results.classList.add("is-loading");
  results.classList.remove("is-no-hits");
  // Fade-out is CSS-driven via .is-loading; render the spinner-row only
  // when the container is still empty (first paint), otherwise keep the
  // previous cards visible underneath the dimming layer for continuity.
  if (results.childElementCount === 0) {
    results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
  }

  try {
    const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
    if (slug) {
      url.searchParams.set("event_category_slug", slug);
    } else {
      // No tree node picked yet → show every concept reachable from any
      // leaf (t-paliad-134: full landscape on entry).
      url.searchParams.set("browse", "all");
    }
    const forums = getActiveForumsParam();
    if (forums) url.searchParams.set("forum", forums);
    const r = await fetch(url.toString());
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const data = await r.json();
    if (seq !== b1SearchSeq) return;
    results.classList.remove("is-loading");

    if (!data.cards || data.cards.length === 0) {
      // At root we should always have data (event_category_concepts is
      // seeded). At a deeper node, offer to step back. Either way render
      // a friendly empty state.
      const empty = `<div class="fristen-search-status fristen-search-error">
        ${escHtml(t("deadlines.pathway.b.tree.empty"))}
        ${slug ? `<button type="button" class="fristen-b1-loosen-link" data-action="loosen">
          ${escHtml(t("deadlines.pathway.b.tree.step.back"))}
        </button>` : ""}
      </div>`;
      results.innerHTML = empty;
      results.classList.add("is-no-hits");
      if (slug) {
        results.querySelector<HTMLButtonElement>(".fristen-b1-loosen-link")?.addEventListener("click", () => {
          const trail = buildBreadcrumb(eventCategoryTree || [], slug);
          const parent = trail.length > 1 ? trail[trail.length - 2].slug : "";
          navigateB1(parent);
        });
      }
      return;
    }
    renderSearchResultsInto("fristen-b1-results", data);
  } catch (e) {
    if (seq !== b1SearchSeq) return;
    results.classList.remove("is-loading");
    results.innerHTML = `<div class="fristen-search-status fristen-search-error">
      ${escHtml(t("deadlines.search.no_hits"))}
    </div>`;
  }
}

function navigateB1(slug: string) {
  setB1PathInURL(slug);
  renderRowStack(slug);
}

// loadAndRenderB1 fetches the tree (cached after first call) and
// renders the cascade + result cards at the slug currently in the URL.
// Module-level so showBMode("tree") can trigger it on Pathway B entry
// without relying on a synthetic radio-change event.
async function loadAndRenderB1() {
  try {
    await loadEventCategoryTree();
    renderRowStack(readB1PathFromURL());
  } catch (e) {
    const stack = document.getElementById("fristen-row-stack");
    if (stack) {
      stack.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
    }
  }
}

async function initB1Cascade() {
  const panel = document.getElementById("fristen-b1-panel");
  if (!panel) return;

  // t-paliad-180: mode-radio retired; the row-stack's mode-row click
  // handler drives tree↔filter routing. No standalone change listener
  // needed here — showBMode() triggers loadAndRenderB1 when the
  // pathway enters tree mode.

  // Initial render if the URL already lands in tree mode.
  const sp = new URLSearchParams(window.location.search);
  if (sp.get("path") === "b" && sp.get("mode") === "tree") {
    loadAndRenderB1();
  }

  // popstate restores the cascade depth.
  window.addEventListener("popstate", () => {
    const params = new URLSearchParams(window.location.search);
    if (params.get("path") === "b" && params.get("mode") === "tree") {
      // Always re-render — tree may not have loaded yet on first popstate.
      currentActiveRow = null;
      cascadeAutoWalkStopAfter = null;
      loadAndRenderB1();
    }
  });
}

document.addEventListener("DOMContentLoaded", initB1Cascade);

// ============================================================================
// v3 B2 forum filter (t-paliad-133 Phase D)
// ============================================================================
// 10 forum buckets per m's spec lock §10 Q8. Multi-select chips,
// AND-narrowing: each chip click toggles its membership in the active
// set; the active set is sent as ?forum=<comma-separated> on every
// search. Empty set = no filter.

const FORUM_BUCKETS: { slug: string; i18nKey: string }[] = [
  { slug: "upc_cfi",    i18nKey: "deadlines.filter.forum.upc_cfi" },
  { slug: "upc_coa",    i18nKey: "deadlines.filter.forum.upc_coa" },
  { slug: "de_lg",      i18nKey: "deadlines.filter.forum.de_lg" },
  { slug: "de_olg",     i18nKey: "deadlines.filter.forum.de_olg" },
  { slug: "de_bgh",     i18nKey: "deadlines.filter.forum.de_bgh" },
  { slug: "de_bpatg",   i18nKey: "deadlines.filter.forum.de_bpatg" },
  { slug: "epa_grant",  i18nKey: "deadlines.filter.forum.epa_grant" },
  { slug: "epa_opp",    i18nKey: "deadlines.filter.forum.epa_opp" },
  { slug: "epa_appeal", i18nKey: "deadlines.filter.forum.epa_appeal" },
  { slug: "dpma",       i18nKey: "deadlines.filter.forum.dpma" },
];

const activeForums = new Set<string>();

function readForumsFromURL(): string[] {
  const sp = new URLSearchParams(window.location.search);
  const raw = sp.get("forum");
  if (!raw) return [];
  return raw.split(",").map((s) => s.trim()).filter((s) => FORUM_BUCKETS.some((b) => b.slug === s));
}

function writeForumsToURL(replace = false) {
  const url = new URL(window.location.href);
  if (activeForums.size === 0) {
    url.searchParams.delete("forum");
  } else {
    url.searchParams.set("forum", Array.from(activeForums).sort().join(","));
  }
  if (replace) {
    window.history.replaceState({}, "", url.toString());
  } else {
    window.history.pushState({}, "", url.toString());
  }
}

function renderForumChips() {
  const container = document.getElementById("fristen-forum-chips");
  const wrapper = document.getElementById("fristen-forum-filter");
  if (!container || !wrapper) return;
  wrapper.hidden = false;
  container.innerHTML = FORUM_BUCKETS.map((b) => {
    const active = activeForums.has(b.slug);
    return `<button type="button" class="fristen-forum-chip${active ? " fristen-forum-chip--active" : ""}"
                    data-forum="${escAttr(b.slug)}"
                    aria-pressed="${active ? "true" : "false"}">
              ${escHtml(t(b.i18nKey))}
            </button>`;
  }).join("");
  container.querySelectorAll<HTMLButtonElement>(".fristen-forum-chip").forEach((chip) => {
    chip.addEventListener("click", () => {
      const slug = chip.dataset.forum || "";
      if (!slug) return;
      if (activeForums.has(slug)) {
        activeForums.delete(slug);
      } else {
        activeForums.add(slug);
      }
      writeForumsToURL();
      renderForumChips();
      reissueSearchWithCurrentFilters();
    });
  });
}

function reissueSearchWithCurrentFilters() {
  // If we're in B1 mode, refresh the current cascade slug's results.
  const sp = new URLSearchParams(window.location.search);
  if (sp.get("mode") === "tree") {
    const slug = sp.get("b1") || "";
    if (slug) {
      runB1Search(slug);
      return;
    }
  }
  // Otherwise re-trigger the B2 search input handler.
  const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
  if (input && input.value.trim() !== "") {
    input.dispatchEvent(new Event("input", { bubbles: true }));
  }
}

function getActiveForumsParam(): string {
  if (activeForums.size === 0) return "";
  return Array.from(activeForums).sort().join(",");
}

function initForumFilter() {
  // Hydrate from URL on first load.
  for (const slug of readForumsFromURL()) {
    activeForums.add(slug);
  }
  renderForumChips();

  // Restore on browser nav.
  window.addEventListener("popstate", () => {
    activeForums.clear();
    for (const slug of readForumsFromURL()) {
      activeForums.add(slug);
    }
    renderForumChips();
  });

  // Re-render labels on language change.
  onLangChange(() => renderForumChips());
}

document.addEventListener("DOMContentLoaded", initForumFilter);

// ============================================================================
// m/paliad#15 inbox-channel pre-filter
// ============================================================================
// Coarse forum chip on the page header above the pathway fork. Three
// values: cms (UPC), bea (national-DE), posteingang (national-DE, slower
// channel — same forums as beA). State priority on hydrate:
//   1. URL ?inbox=cms|bea|posteingang  (per-visit override; lets a
//      colleague share a CMS-narrowed link without flipping anyone's
//      saved preference)
//   2. /api/me forum_pref               (the user's persisted default)
//   3. unset                            (no filter; picker shows all groups)
//
// On chip click: write URL + PATCH /api/me + filter Pathway A picker.
// Empty string PATCH body clears the saved preference (matches the
// EscalationContactID convention in services/user_service.go).

type InboxChannel = "cms" | "bea" | "posteingang" | null;

const INBOX_CHANNEL_VALUES = new Set<string>(["cms", "bea", "posteingang"]);

// currentInboxChannel mirrors the chip's active state so the B1 cascade
// renderer (which lives in a different section of this file) can ask
// "which forum is active right now?" without re-deriving from URL on
// every render. Updated by applyInboxFilter on hydrate / click /
// popstate.
let currentInboxChannel: InboxChannel = null;

function readInboxFromURL(): InboxChannel {
  const raw = new URLSearchParams(window.location.search).get("inbox");
  return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null;
}

function writeInboxToURL(ch: InboxChannel, replace = false) {
  const url = new URL(window.location.href);
  if (ch === null) url.searchParams.delete("inbox");
  else url.searchParams.set("inbox", ch);
  if (replace) window.history.replaceState({}, "", url.toString());
  else window.history.pushState({}, "", url.toString());
}

// inboxChannelToForum collapses the channel onto the coarse forum
// bucket the proceeding-group filter understands. cms → upc; beA and
// Posteingang both → de (same set of national-DE proceedings, different
// inbox name). Null = no filter.
function inboxChannelToForum(ch: InboxChannel): "upc" | "de" | null {
  if (ch === "cms") return "upc";
  if (ch === "bea" || ch === "posteingang") return "de";
  return null;
}

// inboxToFineForumSlugs maps the inbox channel to the matching subset
// of the 10-bucket B2 forum filter. CMS narrows to UPC CFI + CoA;
// beA / Posteingang narrow to all four national-DE buckets (LG / OLG /
// BGH / BPatG). Null returns an empty list — caller handles the
// "no inbox set" case explicitly because the meaning differs between
// hydrate (don't touch fine chips) and click-clear (clear them).
function inboxToFineForumSlugs(ch: InboxChannel): string[] {
  if (ch === "cms") return ["upc_cfi", "upc_coa"];
  if (ch === "bea" || ch === "posteingang") return ["de_lg", "de_olg", "de_bgh", "de_bpatg"];
  return [];
}

// applyFineForumsFromInbox replaces activeForums with the inbox's
// implied fine-bucket set and re-renders the B2 chip strip + reissues
// the active search. Caller decides when to invoke (user click always;
// hydrate / popstate only when URL ?forum= is empty so an explicit
// link-share wins over the inbox derivation).
function applyFineForumsFromInbox(ch: InboxChannel) {
  activeForums.clear();
  for (const slug of inboxToFineForumSlugs(ch)) activeForums.add(slug);
  // renderForumChips guards on missing container, so calling from
  // contexts where Pathway B isn't yet rendered is safe.
  renderForumChips();
  reissueSearchWithCurrentFilters();
}

function applyInboxFilter(ch: InboxChannel) {
  currentInboxChannel = ch;

  // t-paliad-180: the inbox chip strip is gone — chip state now lives
  // inside the row stack and is repainted on every renderRowStack call.
  // Pathway A's "Verlauf" is intentionally NOT filtered here (m's
  // 2026-05-08 feedback: the chip belongs inside the Determinator, not
  // page-wide). The .proceeding-group [data-forum] attributes stay on
  // the markup as documentation of intent but no longer drive
  // visibility.
  if (eventCategoryTree) {
    renderRowStack(readB1PathFromURL());
  }
}

// Slice 3b: cascade narrowing now flows from THREE inputs, in priority
// order. Whichever is set first wins.
//
//   1. Inbox chip   (cms / bea / posteingang) — user-clicked override.
//      Maps to upc / de / de.
//   2. Ad-hoc chip  (Step 1's explore-mode upc / de / epa / dpma).
//   3. Project context (Step 1's selected Akte → proceeding_type_id →
//      proceeding_types.code → forum prefix).
//
// activeForumOnPage() returns the first non-null value or null when
// nothing is set. inboxFilterAllowsForums consults this so the B1
// cascade narrows automatically when the user enters Pathway B with a
// project context — no extra clicks needed. The chip can still
// override at the top of the B1 panel.

let cachedProceedingTypes: Map<number, string> = new Map();

async function loadProceedingTypes(): Promise<void> {
  try {
    const resp = await fetch("/api/proceeding-types-db");
    if (!resp.ok) return;
    const list = (await resp.json()) as Array<{ id: number; code: string }>;
    for (const pt of list) {
      cachedProceedingTypes.set(pt.id, pt.code);
    }
  } catch {
    // Anonymous visitor / network blip — leave the map empty; project
    // narrowing falls back to "no opinion" and the cascade stays
    // wide-open.
  }
}

function forumFromProceedingCode(code: string): "upc" | "de" | "epa" | "dpma" | null {
  if (code.startsWith("UPC_")) return "upc";
  if (code.startsWith("DE_")) return "de";
  if (code.startsWith("EPA_") || code.startsWith("EP_")) return "epa";
  if (code.startsWith("DPMA_")) return "dpma";
  return null;
}

function forumFromProject(p?: ProjectOption | null): "upc" | "de" | "epa" | "dpma" | null {
  if (!p || p.proceeding_type_id == null) return null;
  const code = cachedProceedingTypes.get(p.proceeding_type_id);
  return code ? forumFromProceedingCode(code) : null;
}

function activeForumOnPage(): "upc" | "de" | "epa" | "dpma" | null {
  const chipForum = inboxChannelToForum(currentInboxChannel);
  if (chipForum !== null) return chipForum;
  if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
    return currentStep1Context.adhocForum;
  }
  if (currentStep1Context.kind === "project") {
    return forumFromProject(currentStep1Context.project);
  }
  return null;
}

// inboxFilterAllowsForums returns true when a node with the given
// forums tags should be visible. Neutral nodes (forums undefined /
// empty) are always visible. When no forum is active anywhere on the
// page, every node is visible.
function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
  if (!forums || forums.length === 0) return true;
  const active = activeForumOnPage();
  if (active === null) return true;
  return forums.includes(active);
}

// triggerCascadeRefresh re-renders the B1 row stack if the panel is
// mounted. Call after any change that affects activeForumOnPage() or
// the perspective filter (row pick, project selection, ad-hoc
// selection, clear, perspective change).
function triggerCascadeRefresh() {
  if (eventCategoryTree && document.getElementById("fristen-row-stack")) {
    renderRowStack(readB1PathFromURL());
  }
}

// ============================================================================
// Slice 3c: perspective chip (Klägerseite / Beklagtenseite / Beide)
// ============================================================================
// Lives at the top of the B1 panel above the inbox-channel chip strip.
// Filters the cascade by event_categories.party. Default unset →
// every leaf visible regardless of party.
//
// State is URL-only (?role=claimant|defendant). No localStorage —
// dogfood will tell us whether persistence is wanted.

type Perspective = "claimant" | "defendant" | null;

let currentPerspective: Perspective = null;

function readPerspectiveFromURL(): Perspective {
  const raw = new URLSearchParams(window.location.search).get("role");
  return raw === "claimant" || raw === "defendant" ? raw : null;
}

function writePerspectiveToURL(p: Perspective, replace = false) {
  const url = new URL(window.location.href);
  if (p === null) url.searchParams.delete("role");
  else url.searchParams.set("role", p);
  if (replace) window.history.replaceState({}, "", url.toString());
  else window.history.pushState({}, "", url.toString());
}

function applyPerspective(p: Perspective) {
  currentPerspective = p;
  // t-paliad-180: chip state lives inside the row stack now; one refresh
  // repaints the perspective row + re-narrows the cascade options.
  triggerCascadeRefresh();
}

// ourSideToPerspective maps the project-level "Wir vertreten" enum
// onto the chip-strip Perspective. 'court' / 'both' map to null
// (chip cleared) — court actions are neutral to the user's side and
// "both" is explicit no-filter intent.
function ourSideToPerspective(os: string | null | undefined): Perspective {
  if (os === "claimant") return "claimant";
  if (os === "defendant") return "defendant";
  return null;
}

// applyOurSidePredefine locks the perspective from project.our_side
// when the user hasn't already explicitly picked one. The URL is the
// "explicit pick" signal: if ?role= is present at call time, the user
// (or a shared link) chose it and we don't overwrite. When we do
// predefine, we write the same value to the URL so back/forward +
// refresh round-trip cleanly. t-paliad-180: the "aus Akte" tag now
// lives inline in the prefilled-row state — no separate hint element.
//
// `replaceURL=true` is for the deep-link / refresh path; `false` for
// in-page project selection so back-button restores the empty state.
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
  if (!project || !project.our_side) return;
  // URL wins — user has an explicit pick. Don't clobber it.
  if (readPerspectiveFromURL() !== null) return;
  const next = ourSideToPerspective(project.our_side);
  writePerspectiveToURL(next, replaceURL);
  applyPerspective(next);
}

// perspectiveAllowsParty returns true when a node tagged with `party`
// should be visible under the current perspective. Neutral nodes
// (party undefined / empty) always pass. "both" matches every
// perspective; "court" matches every perspective (court actions are
// neutral to the user's side).
function perspectiveAllowsParty(party: string[] | undefined): boolean {
  if (!party || party.length === 0) return true;
  if (currentPerspective === null) return true;
  if (party.includes("both") || party.includes("court")) return true;
  return party.includes(currentPerspective);
}

async function persistInboxPref(ch: InboxChannel) {
  // forum_pref="" clears on the server side (NULL in the DB) — matches
  // the EscalationContactID convention in services/user_service.go.
  // Persistence is opportunistic; URL state already won the visit.
  try {
    await fetch("/api/me", {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      credentials: "same-origin",
      body: JSON.stringify({ forum_pref: ch === null ? "" : ch }),
    });
  } catch {
    // Network blip on persist isn't worth blocking the UI; the user can
    // re-pick on the next visit.
  }
}

async function initInboxFilter() {
  // t-paliad-180: the standalone inbox chip strip is retired; inbox
  // state still drives cascade narrowing + B2 fine-bucket sync, just
  // surfaced through the row-stack row now. This init still hydrates
  // from URL / saved preference + wires the popstate restore.
  if (!document.getElementById("fristen-b1-panel")) return;

  let initial: InboxChannel = readInboxFromURL();
  if (initial === null) {
    try {
      const resp = await fetch("/api/me", { credentials: "same-origin" });
      if (resp.ok) {
        const me = (await resp.json()) as { forum_pref?: string | null };
        if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
          initial = me.forum_pref as InboxChannel;
        }
      }
    } catch {
      // Anonymous visitor or transient error — leave the chip unset.
    }
  }
  applyInboxFilter(initial);

  // Sync B2 fine-bucket chips from the inbox on hydrate ONLY when the
  // URL doesn't explicitly carry ?forum=… — an explicit forum= comes
  // from a shared link and should win over the user's saved inbox
  // preference. initForumFilter (which runs first) has already
  // populated activeForums from URL forum=, so we leave it alone here.
  if (initial !== null && readForumsFromURL().length === 0) {
    applyFineForumsFromInbox(initial);
    writeForumsToURL(true);
  }

  window.addEventListener("popstate", () => {
    const newInbox = readInboxFromURL();
    applyInboxFilter(newInbox);
    // popstate can land on a URL with inbox= but no forum= (the user
    // navigated to a state where derivation should re-apply). Don't
    // touch activeForums when forum= is explicit — initForumFilter's
    // own popstate handler has already loaded it from the URL.
    if (newInbox !== null && readForumsFromURL().length === 0) {
      applyFineForumsFromInbox(newInbox);
    }
  });
}

document.addEventListener("DOMContentLoaded", initInboxFilter);

