// Litigation Builder client (m/paliad#153 PRD §3, B1).
//
// Boots /tools/procedures. Talks to the B0 surface
// (/api/builder/scenarios/*) for persistence and reuses
// verfahrensablauf-core for the per-triplet calc + 3-column render.
//
// B1 ships:
//   - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list.
//   - Scenario picker, name action, Stichtag, auto-save (500ms debounce).
//   - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen).
//   - Single triplet renders end-to-end with calc.
//   - Side panel "Meine Szenarien" with Aktiv bucket.
//
// B2 extends:
//   - Multi-triplet stack with `+ Verfahren hinzufügen`.
//   - Per-triplet perspective + flag strip.
//   - Spawn child triplets render inline.
//   - 3-state event cards (planned/filed/skipped) + per-card optional horizon.

import { t, getLang } from "./i18n";
import {
  calculateDeadlines,
  renderColumnsBody,
  type DeadlineResponse,
  type Side,
} from "./views/verfahrensablauf-core";
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
import {
  mountBuilderSearch,
  type EventSearchHit,
  type ScenarioSearchHit,
  type ProjectSearchHit,
} from "./builder-search";
import {
  mountAktePicker,
  createScenarioFromProject,
  renderAkteBanner,
  type AktePickerHandle,
} from "./builder-akte";
import {
  onScenarioFlagsChanged,
  SCENARIO_FLAG_CHANGED_EVENT,
  type ScenarioFlagChangedDetail,
} from "./scenario-flags";
import { openShareModal, type BuilderShareRow } from "./builder-shares";
import { openPromoteWizard } from "./builder-promote";

// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
// parent proceeding, the builder auto-creates a child proceeding row
// linked via parent_scenario_proceeding_id. When the flag flips back
// OFF, the child is deleted (its events cascade via the schema's ON
// DELETE CASCADE). Today's data has 2-deep nesting at most, with
// `with_ccr` on `upc.inf.cfi` as the load-bearing case; the map is
// data-driven so future flags slot in by adding rows.
//
// Entries are picked up by syncSpawnChildren after each successful
// flag PATCH. Falsy entries are simple flag-only flips with no spawn
// effect.
const SPAWN_MAP: Record<string, Record<string, string>> = {
  // Parent proceeding code → flag_key → child proceeding code.
  "upc.inf.cfi": { with_ccr: "upc.ccr.cfi" },
};

// ────────────────────────────────────────────────────────────────────────────
// Wire types — mirror internal/services/scenario_builder_service.go
// ────────────────────────────────────────────────────────────────────────────

export interface BuilderScenario {
  id: string;
  owner_id?: string;
  name: string;
  status: "active" | "archived" | "promoted";
  origin_project_id?: string;
  promoted_project_id?: string;
  stichtag?: string;
  notes?: string;
  created_at: string;
  updated_at: string;
}

export interface BuilderProceeding {
  id: string;
  scenario_id: string;
  proceeding_type_id: number;
  primary_party?: "claimant" | "defendant";
  scenario_flags: Record<string, unknown>;
  parent_scenario_proceeding_id?: string;
  spawn_anchor_event_id?: string;
  ordinal: number;
  stichtag?: string;
  detailgrad: "selected" | "all_options";
  appeal_target?: string;
  collapsed: boolean;
  created_at: string;
  updated_at: string;
}

export interface BuilderEvent {
  id: string;
  scenario_proceeding_id: string;
  sequencing_rule_id?: string;
  procedural_event_id?: string;
  custom_label?: string;
  state: "planned" | "filed" | "skipped";
  actual_date?: string;
  skip_reason?: string;
  notes?: string;
  horizon_optional: number;
  created_at: string;
  updated_at: string;
}

export interface BuilderScenarioDeep extends BuilderScenario {
  proceedings: BuilderProceeding[];
  events: BuilderEvent[];
  shares: unknown[];
}

// ────────────────────────────────────────────────────────────────────────────
// Module state — single active scenario per tab.
// ────────────────────────────────────────────────────────────────────────────

type EntryMode = "cold" | "event" | "akte";

interface State {
  active: BuilderScenarioDeep | null;
  list: BuilderScenario[];
  // B5 — scenarios shared read-only with me (the "Geteilt mit mir"
  // bucket). Disjoint from `list` (which is owner-scoped). readonly is
  // true when the active scenario is one of these OR is promoted —
  // either way every mutating affordance is disabled + a watermark shows.
  shared: BuilderScenario[];
  readonly: boolean;
  // owner display-name cache for the read-only watermark.
  ownerNameById: Map<string, string>;
  procTypes: ProceedingTypeMeta[];
  procTypesById: Map<number, ProceedingTypeMeta>;
  procTypesByCode: Map<string, ProceedingTypeMeta>;
  flagCatalog: ScenarioFlagCatalogEntry[];
  saveTimer: number | null;
  // Pending field-level deltas merged before each PATCH flush. Avoids
  // racing PATCHes overwriting each other when the user changes more
  // than one field inside a 500ms window.
  pending: { name?: string; stichtag?: string; notes?: string };
  // B3: entry mode + anchor highlight. PRD §2.2 — when the user picks
  // an event from universal search, the canvas renders one triplet
  // with the picked rule highlighted (lime band + "DU BIST HIER"
  // divider). anchorRuleID survives across re-renders within a single
  // mode session; switching mode away from "event" clears it.
  mode: EntryMode;
  anchorRuleID: string | null;
  searchCtl: { focus: () => void; close: () => void } | null;
  // B4 — Akte-mode picker handle. Null until the page mounts; once
  // mountAktePicker resolves, the handle lets us keep the picker
  // selection reflective of the active scenario (origin_project_id).
  aktePicker: AktePickerHandle | null;
}

const state: State = {
  active: null,
  list: [],
  shared: [],
  readonly: false,
  ownerNameById: new Map(),
  procTypes: [],
  procTypesById: new Map(),
  procTypesByCode: new Map(),
  flagCatalog: [],
  saveTimer: null,
  pending: {},
  mode: "cold",
  anchorRuleID: null,
  searchCtl: null,
  aktePicker: null,
};

// ────────────────────────────────────────────────────────────────────────────
// Fetch helpers
// ────────────────────────────────────────────────────────────────────────────

async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T | null> {
  try {
    const resp = await fetch(input, init);
    if (!resp.ok) {
      const body = await resp.text().catch(() => "");
      console.error("builder fetch error:", resp.status, input, body);
      return null;
    }
    if (resp.status === 204) return null;
    return (await resp.json()) as T;
  } catch (err) {
    console.error("builder network error:", input, err);
    return null;
  }
}

async function fetchScenarios(): Promise<BuilderScenario[]> {
  // B5 — pull every status so the side panel can bucket into Aktiv /
  // Promoted / Archiviert. The picker + recent list filter to active.
  const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
  return Array.isArray(out) ? out : [];
}

async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
  const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
  return Array.isArray(out) ? out : [];
}

// fetchOwnerNames lazily loads the user directory once so the read-only
// watermark can render "Geteilt von <Name>". Failures degrade to showing
// the owner uuid; the watermark is informational, not load-bearing.
async function ensureOwnerNames(): Promise<void> {
  if (state.ownerNameById.size > 0) return;
  const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
  if (!Array.isArray(users)) return;
  for (const u of users) {
    state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
  }
}

async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
  return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}

async function fetchProceedingTypes(): Promise<ProceedingTypeMeta[]> {
  // PRD v1 is UPC-only; later jurisdictions plug into the same picker
  // shape (Forum chip row gates the Verfahren chip row).
  const out = await fetchJSON<ProceedingTypeMeta[]>(
    "/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding",
  );
  return Array.isArray(out) ? out : [];
}

async function fetchFlagCatalog(): Promise<ScenarioFlagCatalogEntry[]> {
  const out = await fetchJSON<ScenarioFlagCatalogEntry[]>("/api/builder/scenario-flag-catalog");
  return Array.isArray(out) ? out : [];
}

async function createScenario(name?: string): Promise<BuilderScenario | null> {
  const body: Record<string, unknown> = {};
  if (name) body.name = name;
  return await fetchJSON<BuilderScenario>("/api/builder/scenarios", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

async function patchScenario(id: string, body: Record<string, unknown>): Promise<BuilderScenario | null> {
  return await fetchJSON<BuilderScenario>("/api/builder/scenarios/" + encodeURIComponent(id), {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

async function addProceeding(
  scenarioID: string,
  body: {
    proceeding_type_id: number;
    primary_party?: string;
    parent_scenario_proceeding_id?: string;
    spawn_anchor_event_id?: string;
  },
): Promise<BuilderProceeding | null> {
  return await fetchJSON<BuilderProceeding>(
    "/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );
}

async function patchProceeding(
  scenarioID: string,
  proceedingID: string,
  body: Record<string, unknown>,
): Promise<BuilderProceeding | null> {
  return await fetchJSON<BuilderProceeding>(
    "/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
      "/proceedings/" + encodeURIComponent(proceedingID),
    {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );
}

async function deleteProceeding(scenarioID: string, proceedingID: string): Promise<boolean> {
  try {
    const resp = await fetch(
      "/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
        "/proceedings/" + encodeURIComponent(proceedingID),
      { method: "DELETE" },
    );
    return resp.ok;
  } catch (err) {
    console.error("builder delete proceeding error:", err);
    return false;
  }
}

async function addEvent(
  scenarioID: string,
  proceedingID: string,
  body: {
    sequencing_rule_id?: string;
    state?: string;
    actual_date?: string;
    skip_reason?: string;
    horizon_optional?: number;
  },
): Promise<BuilderEvent | null> {
  return await fetchJSON<BuilderEvent>(
    "/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
      "/proceedings/" + encodeURIComponent(proceedingID) + "/events",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );
}

async function patchEvent(
  scenarioID: string,
  eventID: string,
  body: Record<string, unknown>,
): Promise<BuilderEvent | null> {
  return await fetchJSON<BuilderEvent>(
    "/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
      "/events/" + encodeURIComponent(eventID),
    {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );
}

// ────────────────────────────────────────────────────────────────────────────
// URL state
// ────────────────────────────────────────────────────────────────────────────

function readScenarioFromUrl(): string | null {
  return new URLSearchParams(window.location.search).get("scenario");
}

function writeScenarioToUrl(id: string | null): void {
  const url = new URL(window.location.href);
  if (id) url.searchParams.set("scenario", id);
  else url.searchParams.delete("scenario");
  history.replaceState(null, "", url.pathname + url.search + url.hash);
}

// ────────────────────────────────────────────────────────────────────────────
// Save indicator
// ────────────────────────────────────────────────────────────────────────────

type SaveState = "idle" | "saving" | "saved" | "error";

function setSaveState(s: SaveState): void {
  const el = document.getElementById("builder-save-status");
  if (!el) return;
  el.setAttribute("data-state", s);
  const span = el.querySelector("span");
  if (!span) return;
  const text =
    s === "saving" ? t("builder.save.saving") :
    s === "saved"  ? t("builder.save.saved")  :
    s === "error"  ? t("builder.save.error")  :
                     t("builder.save.idle");
  const key =
    s === "saving" ? "builder.save.saving" :
    s === "saved"  ? "builder.save.saved"  :
    s === "error"  ? "builder.save.error"  :
                     "builder.save.idle";
  span.setAttribute("data-i18n", key);
  span.textContent = text;
}

// ────────────────────────────────────────────────────────────────────────────
// Auto-save (500ms debounce per PRD §4.2 + §10).
// ────────────────────────────────────────────────────────────────────────────

function scheduleAutoSave(): void {
  if (!state.active) return;
  setSaveState("saving");
  if (state.saveTimer !== null) {
    window.clearTimeout(state.saveTimer);
  }
  state.saveTimer = window.setTimeout(() => {
    void flushAutoSave();
  }, 500);
}

async function flushAutoSave(): Promise<void> {
  state.saveTimer = null;
  if (!state.active) return;
  const body = { ...state.pending };
  state.pending = {};
  if (Object.keys(body).length === 0) {
    setSaveState("saved");
    return;
  }
  const updated = await patchScenario(state.active.id, body);
  if (!updated) {
    setSaveState("error");
    return;
  }
  state.active.name = updated.name;
  state.active.status = updated.status;
  state.active.stichtag = updated.stichtag;
  state.active.notes = updated.notes;
  state.active.updated_at = updated.updated_at;
  setSaveState("saved");
  // Refresh the side panel so the just-saved scenario floats to top.
  await refreshScenarioList();
}

// ────────────────────────────────────────────────────────────────────────────
// Side panel + dropdown
// ────────────────────────────────────────────────────────────────────────────

async function refreshScenarioList(): Promise<void> {
  // Owned (all statuses) + shared-with-me run in parallel.
  const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
  state.list = owned;
  state.shared = shared;
  renderScenarioList();
  renderScenarioPicker();
}

// renderBucket paints one side-panel bucket UL + toggles its wrapper's
// hidden attribute when empty. The Aktiv bucket always renders (shows the
// empty hint); the others hide when they have no rows.
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
  const ul = document.getElementById(listId);
  if (!ul) return;
  if (wrapId) {
    const wrap = document.getElementById(wrapId);
    if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
  }
  if (scenarios.length === 0) {
    ul.innerHTML = alwaysShow
      ? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
      : "";
    return;
  }
  const activeId = state.active?.id;
  ul.innerHTML = scenarios.map((sc) => {
    const isActive = sc.id === activeId;
    return (
      `<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
      ` data-scenario-id="${escAttr(sc.id)}">` +
      `<button type="button" class="builder-scenario-list-link">` +
      `<span class="builder-scenario-list-name">${escHtml(sc.name)}</span>` +
      `</button></li>`
    );
  }).join("");
  ul.querySelectorAll<HTMLElement>(".builder-scenario-list-item").forEach((li) => {
    const id = li.getAttribute("data-scenario-id");
    if (!id) return;
    li.addEventListener("click", () => {
      void loadScenario(id);
    });
  });
}

function renderScenarioList(): void {
  renderBucket("builder-scenario-list-active", null,
    state.list.filter((s) => s.status === "active"), true);
  renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
  renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
    state.list.filter((s) => s.status === "promoted"), false);
  renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
    state.list.filter((s) => s.status === "archived"), false);
}

function renderScenarioPicker(): void {
  const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
  if (!sel) return;
  const placeholderText = t("builder.picker.placeholder");
  const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
  // Picker shows openable scenarios: active owned + shared-with-me.
  const pickable = [
    ...state.list.filter((s) => s.status === "active"),
    ...state.shared,
  ];
  // Ensure the currently-active scenario is selectable even if promoted/
  // archived (so the dropdown reflects reality when one is open).
  if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
    pickable.unshift(state.active);
  }
  for (const sc of pickable) {
    const selected = sc.id === state.active?.id ? " selected" : "";
    opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
  }
  sel.innerHTML = opts.join("");
}

// ────────────────────────────────────────────────────────────────────────────
// Canvas rendering
// ────────────────────────────────────────────────────────────────────────────

function showEmpty(): void {
  const canvas = document.getElementById("builder-canvas");
  if (!canvas) return;
  canvas.innerHTML = "";
  const empty = document.createElement("div");
  empty.id = "builder-empty";
  empty.className = "builder-empty";
  empty.innerHTML = `
    <p class="builder-empty-headline">${escHtml(t("builder.empty.headline"))}</p>
    <p class="builder-empty-hint">${escHtml(t("builder.empty.hint"))}</p>
    <button type="button" id="builder-cta-new" class="builder-cta-new">
      ${escHtml(t("builder.empty.cta"))}
    </button>
    ${renderRecentList()}
  `;
  canvas.appendChild(empty);
  document.getElementById("builder-cta-new")?.addEventListener("click", () => {
    void onNewScenarioClick();
  });
  empty.querySelectorAll<HTMLElement>(".builder-recent-item").forEach((li) => {
    const id = li.getAttribute("data-scenario-id");
    if (!id) return;
    li.addEventListener("click", () => {
      void loadScenario(id);
    });
  });
}

function renderRecentList(): string {
  if (state.list.length === 0) return "";
  const recent = state.list.slice(0, 5);
  const items = recent.map((sc) => (
    `<li class="builder-recent-item" data-scenario-id="${escAttr(sc.id)}">` +
      `<span class="builder-recent-name">${escHtml(sc.name)}</span>` +
    `</li>`
  )).join("");
  return (
    `<div class="builder-recent">` +
    `<h3 class="builder-recent-title">${escHtml(t("builder.empty.recent"))}</h3>` +
    `<ul class="builder-recent-list">${items}</ul>` +
    `</div>`
  );
}

function renderCanvas(): void {
  if (!state.active) {
    showEmpty();
    return;
  }
  const canvas = document.getElementById("builder-canvas");
  if (!canvas) return;
  canvas.innerHTML = "";

  // Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL).
  const topLevel = state.active.proceedings
    .filter((p) => !p.parent_scenario_proceeding_id)
    .sort((a, b) => a.ordinal - b.ordinal);

  for (const proc of topLevel) {
    renderProceedingTripletInto(canvas, proc, /*isChild*/ false);
    // Inline child triplets (spawn nesting). PRD §3.6.
    const children = state.active.proceedings
      .filter((p) => p.parent_scenario_proceeding_id === proc.id)
      .sort((a, b) => a.ordinal - b.ordinal);
    for (const child of children) {
      renderProceedingTripletInto(canvas, child, /*isChild*/ true);
    }
  }

  // Add-proceeding affordance always at the bottom of the stack.
  const addBtn = document.createElement("button");
  addBtn.type = "button";
  addBtn.className = "builder-add-proceeding-btn";
  addBtn.id = "builder-add-proceeding-btn";
  addBtn.textContent = t("builder.canvas.add_proceeding");
  addBtn.addEventListener("click", () => {
    openAddProceedingPicker(addBtn);
  });
  canvas.appendChild(addBtn);
}

function renderProceedingTripletInto(
  canvas: HTMLElement,
  proc: BuilderProceeding,
  isChild: boolean,
): void {
  const host = document.createElement("article");
  host.className = "builder-triplet-host";
  host.setAttribute("data-proceeding-id", proc.id);
  if (isChild) host.setAttribute("data-child", "true");
  canvas.appendChild(host);
  void hydrateTriplet(proc, host, isChild);
}

async function hydrateTriplet(
  proc: BuilderProceeding,
  host: HTMLElement,
  isChild: boolean,
): Promise<void> {
  const meta = state.procTypesById.get(proc.proceeding_type_id);
  if (!meta) {
    host.innerHTML = `<div class="builder-triplet-error">${escHtml(
      t("builder.triplet.unknown_proceeding"),
    )}</div>`;
    return;
  }
  const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
  // t-paliad-348 — port engine semantics to the Builder triplet calc:
  //   - `includeOptional` follows the proceeding's detailgrad. The
  //     "selected" default suppresses optional rules server-side
  //     (engine drops them); "all_options" opts in so the dimmed
  //     optional cards can be rendered for the lawyer to opt into.
  //   - `filterByDetailMode` then runs client-side over what the
  //     engine emitted, dropping isConditional rows (rules whose
  //     `trigger_event_id` anchor wasn't supplied) when the lawyer
  //     is on "selected"/"mandatory_only" — those rules belong to the
  //     "naked proceeding with options but not always displayed"
  //     mental model and shouldn't pollute the backbone view.
  const detailgrad: DetailMode = (proc.detailgrad as DetailMode) || "selected";
  const data: DeadlineResponse | null = await calculateDeadlines({
    proceedingType: meta.code,
    triggerDate: stichtag,
    flags: scenarioFlagsToArray(proc.scenario_flags),
    includeOptional: detailgrad === "all_options",
  });
  const side: Side = (proc.primary_party as Side) || null;
  const eventsByRule = buildEventsByRule(proc.id);
  const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
  const filteredData: DeadlineResponse | null = data
    ? { ...data, deadlines: filterByDetailMode(data.deadlines, detailgrad, scenarioFlagsBool) }
    : null;
  const columnsHtml = filteredData
    ? renderColumnsBody(filteredData, { editable: false, side, showDurations: false })
    : "";
  host.innerHTML = renderTriplet({
    proceeding: proc,
    meta,
    data,
    side,
    flagCatalog: state.flagCatalog,
    eventsByRule,
    columnsHtml,
    isChild,
  });
  wireTripletInteractions(host, proc, meta);
  overlayEventStates(host, eventsByRule, {
    onAction: (ruleId, action, payload) => {
      void onEventAction(proc, ruleId, action, payload);
    },
    onHorizon: (ruleId, delta) => {
      void onEventHorizon(proc, ruleId, delta);
    },
  });
  // B3 — apply the event-anchor highlight + "DU BIST HIER" divider on
  // the matching card. Only fires when the active mode is "event" and
  // an anchor rule was set by the search-pick flow.
  if (state.mode === "event" && state.anchorRuleID) {
    applyAnchorHighlight(host, state.anchorRuleID);
  }
}

// applyAnchorHighlight spotlights the picked event card on a freshly
// rendered triplet. PRD §2.2: lime band on the card + a horizontal
// "━━ DU BIST HIER ━━" divider above the next-coming events. The
// divider spans all 3 columns of the grid via grid-column: 1 / -1.
function applyAnchorHighlight(host: HTMLElement, ruleId: string): void {
  const ruleKey = ruleId.toLowerCase();
  // Attribute-value comparison via JS rather than a CSS selector so
  // either-cased UUIDs from the server still match.
  const anchorCard = Array.from(
    host.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]"),
  ).find((el) => (el.getAttribute("data-rule-id") || "").toLowerCase() === ruleKey);
  if (!anchorCard) return;
  anchorCard.classList.add("builder-anchor-card");

  // Locate the anchor's row in the .fr-columns-view grid. The grid
  // is row-major with 3 cells per row after the 3 column headers
  // (ours/court/opponent). The cell containing the anchor card is
  // the closest .fr-col-cell ancestor; once we know its index in the
  // grid's cell list, the row index is floor((idx - 3) / 3) (the
  // -3 accounts for the header row).
  const cell = anchorCard.closest<HTMLElement>(".fr-col-cell");
  if (!cell) return;
  const grid = cell.parentElement;
  if (!grid || !grid.classList.contains("fr-columns-view")) return;
  const cells = Array.from(grid.children) as HTMLElement[];
  const idx = cells.indexOf(cell);
  if (idx < 0) return;
  // Insertion point: after the last cell of the anchor's row. The
  // grid renders cells in row-major order; we step forward from the
  // anchor cell to the end of the current row (2 cells past, modulo
  // header row offset). A guarded fall-through inserts at the end of
  // the grid if positions don't line up cleanly.
  // Header row contributes 3 cells; body rows are 3 cells each.
  const headerCells = 3;
  if (idx < headerCells) return; // anchor on a header? shouldn't happen, no-op.
  const bodyIdx = idx - headerCells;
  const rowStart = headerCells + Math.floor(bodyIdx / 3) * 3;
  const rowEnd = rowStart + 2; // inclusive
  const after = cells[rowEnd] || cells[cells.length - 1];

  const divider = document.createElement("div");
  divider.className = "builder-anchor-divider";
  divider.textContent = t("builder.search.anchor.divider");
  divider.setAttribute("aria-hidden", "true");
  after.insertAdjacentElement("afterend", divider);
}

function buildEventsByRule(proceedingID: string): Map<string, BuilderEvent> {
  const out = new Map<string, BuilderEvent>();
  if (!state.active) return out;
  for (const ev of state.active.events) {
    if (ev.scenario_proceeding_id !== proceedingID) continue;
    if (!ev.sequencing_rule_id) continue;
    out.set(ev.sequencing_rule_id.toLowerCase(), ev);
  }
  return out;
}

function wireTripletInteractions(
  host: HTMLElement,
  proc: BuilderProceeding,
  meta: ProceedingTypeMeta,
): void {
  // Perspective radio
  host.querySelectorAll<HTMLElement>("[data-action='perspective']").forEach((btn) => {
    btn.addEventListener("click", () => {
      const value = btn.getAttribute("data-value") || "";
      void onPerspectiveChange(proc, value);
    });
  });
  // Detailgrad toggle
  host.querySelectorAll<HTMLElement>("[data-action='detailgrad']").forEach((btn) => {
    btn.addEventListener("click", () => {
      const value = btn.getAttribute("data-value") || "selected";
      void onDetailgradChange(proc, value);
    });
  });
  // Remove
  host.querySelectorAll<HTMLElement>("[data-action='remove']").forEach((btn) => {
    btn.addEventListener("click", () => {
      void onRemoveProceeding(proc);
    });
  });
  // Flag checkboxes
  host.querySelectorAll<HTMLInputElement>("[data-action='flag']").forEach((box) => {
    box.addEventListener("change", () => {
      const key = box.getAttribute("data-flag-key");
      if (!key) return;
      void onFlagChange(proc, meta, key, box.checked);
    });
  });
}

async function onPerspectiveChange(proc: BuilderProceeding, value: string): Promise<void> {
  if (!state.active) return;
  const updated = await patchProceeding(state.active.id, proc.id, {
    primary_party: value,
  });
  if (!updated) {
    setSaveState("error");
    return;
  }
  applyProceedingPatch(updated);
  setSaveState("saved");
  renderCanvas();
}

async function onDetailgradChange(proc: BuilderProceeding, value: string): Promise<void> {
  if (!state.active) return;
  const updated = await patchProceeding(state.active.id, proc.id, {
    detailgrad: value,
  });
  if (!updated) {
    setSaveState("error");
    return;
  }
  applyProceedingPatch(updated);
  setSaveState("saved");
  renderCanvas();
}

async function onRemoveProceeding(proc: BuilderProceeding): Promise<void> {
  if (!state.active) return;
  const confirmed = window.confirm(t("builder.triplet.remove") + " — " + state.procTypesById.get(proc.proceeding_type_id)?.code);
  if (!confirmed) return;
  const ok = await deleteProceeding(state.active.id, proc.id);
  if (!ok) {
    setSaveState("error");
    return;
  }
  // Cascade in-memory: drop the proceeding + its child proceedings + events.
  state.active.proceedings = state.active.proceedings.filter(
    (p) => p.id !== proc.id && p.parent_scenario_proceeding_id !== proc.id,
  );
  state.active.events = state.active.events.filter(
    (e) => state.active!.proceedings.some((p) => p.id === e.scenario_proceeding_id),
  );
  setSaveState("saved");
  renderCanvas();
}

async function onFlagChange(
  proc: BuilderProceeding,
  meta: ProceedingTypeMeta,
  flagKey: string,
  enabled: boolean,
): Promise<void> {
  if (!state.active) return;
  const newFlags: Record<string, unknown> = { ...proc.scenario_flags, [flagKey]: enabled };
  const updated = await patchProceeding(state.active.id, proc.id, {
    scenario_flags: newFlags,
  });
  if (!updated) {
    setSaveState("error");
    return;
  }
  applyProceedingPatch(updated);

  // Spawn handling — flip the child triplet in or out based on the
  // SPAWN_MAP entry for this parent.
  const spawnChildCode = SPAWN_MAP[meta.code]?.[flagKey];
  if (spawnChildCode) {
    await syncSpawnChild(updated, spawnChildCode, enabled);
  }

  // B4 — Akte-backed flag toggles dual-write to projects.scenario_flags
  // on the server. Mirror the cross-surface CustomEvent here so peer
  // surfaces (Verfahrensablauf strip, project Verlauf, dashboard
  // panes) listening via `scenario-flags.ts` re-sync without a fresh
  // GET. The PATCH /api/builder/.../proceedings hop bypasses
  // patchScenarioFlags (which is what normally dispatches), so we
  // recreate the event here when the active scenario is project-
  // backed AND the patched triplet is top-level.
  if (
    state.active.origin_project_id &&
    !updated.parent_scenario_proceeding_id
  ) {
    const detail: ScenarioFlagChangedDetail = {
      projectId: state.active.origin_project_id,
      flags: builderFlagsToBoolMap(updated.scenario_flags),
      changedKeys: [flagKey],
    };
    document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
  }

  setSaveState("saved");
  renderCanvas();
}

// builderFlagsToBoolMap converts the builder's loose
// Record<string, unknown> (the wire shape of scenario_proceedings.
// scenario_flags) into the strict Record<string, boolean> shape peer
// surfaces expect. Non-bool values are dropped — defensive parsing
// for the same reason the server's flagDeltaFromBuilder skips them.
function builderFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
  const out: Record<string, boolean> = {};
  for (const [k, v] of Object.entries(flags)) {
    if (typeof v === "boolean") out[k] = v;
  }
  return out;
}

async function syncSpawnChild(
  parent: BuilderProceeding,
  childCode: string,
  enabled: boolean,
): Promise<void> {
  if (!state.active) return;
  const existing = state.active.proceedings.find(
    (p) => p.parent_scenario_proceeding_id === parent.id,
  );
  if (enabled && !existing) {
    const childMeta = state.procTypesByCode.get(childCode);
    if (!childMeta) return;
    const child = await addProceeding(state.active.id, {
      proceeding_type_id: childMeta.id,
      parent_scenario_proceeding_id: parent.id,
    });
    if (child) state.active.proceedings.push(child);
  } else if (!enabled && existing) {
    const ok = await deleteProceeding(state.active.id, existing.id);
    if (ok) {
      state.active.proceedings = state.active.proceedings.filter((p) => p.id !== existing.id);
      state.active.events = state.active.events.filter(
        (e) => e.scenario_proceeding_id !== existing.id,
      );
    }
  }
}

function applyProceedingPatch(updated: BuilderProceeding): void {
  if (!state.active) return;
  const idx = state.active.proceedings.findIndex((p) => p.id === updated.id);
  if (idx >= 0) state.active.proceedings[idx] = updated;
}

async function onEventAction(
  proc: BuilderProceeding,
  ruleId: string,
  action: "file" | "skip" | "reset",
  payload?: { date?: string; reason?: string },
): Promise<void> {
  if (!state.active) return;
  const ruleKey = ruleId.toLowerCase();
  const existing = state.active.events.find(
    (e) => e.scenario_proceeding_id === proc.id &&
           e.sequencing_rule_id?.toLowerCase() === ruleKey,
  );
  if (action === "file") {
    const date = payload?.date || todayISO();
    if (existing) {
      const upd = await patchEvent(state.active.id, existing.id, {
        state: "filed",
        actual_date: date,
      });
      if (upd) replaceEvent(upd);
    } else {
      const ev = await addEvent(state.active.id, proc.id, {
        sequencing_rule_id: ruleId,
        state: "filed",
        actual_date: date,
      });
      if (ev) state.active.events.push(ev);
    }
  } else if (action === "skip") {
    if (existing) {
      const upd = await patchEvent(state.active.id, existing.id, {
        state: "skipped",
        skip_reason: payload?.reason ?? "",
      });
      if (upd) replaceEvent(upd);
    } else {
      const ev = await addEvent(state.active.id, proc.id, {
        sequencing_rule_id: ruleId,
        state: "skipped",
        skip_reason: payload?.reason ?? "",
      });
      if (ev) state.active.events.push(ev);
    }
  } else {
    // reset → either patch back to planned or delete the row outright.
    // PATCH is simpler and keeps any horizon_optional the user had set;
    // a separate "clear horizon" affordance handles full removal.
    if (existing) {
      const upd = await patchEvent(state.active.id, existing.id, { state: "planned" });
      if (upd) replaceEvent(upd);
    }
  }
  setSaveState("saved");
  renderCanvas();
}

async function onEventHorizon(
  proc: BuilderProceeding,
  ruleId: string,
  delta: 1 | -1,
): Promise<void> {
  if (!state.active) return;
  const ruleKey = ruleId.toLowerCase();
  const existing = state.active.events.find(
    (e) => e.scenario_proceeding_id === proc.id &&
           e.sequencing_rule_id?.toLowerCase() === ruleKey,
  );
  if (existing) {
    const newHorizon = Math.max(0, existing.horizon_optional + delta);
    const upd = await patchEvent(state.active.id, existing.id, {
      horizon_optional: newHorizon,
    });
    if (upd) replaceEvent(upd);
  } else if (delta > 0) {
    const ev = await addEvent(state.active.id, proc.id, {
      sequencing_rule_id: ruleId,
      horizon_optional: 1,
      state: "planned",
    });
    if (ev) state.active.events.push(ev);
  }
  setSaveState("saved");
  renderCanvas();
}

function replaceEvent(updated: BuilderEvent): void {
  if (!state.active) return;
  const idx = state.active.events.findIndex((e) => e.id === updated.id);
  if (idx >= 0) state.active.events[idx] = updated;
  else state.active.events.push(updated);
}

function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
  // The calc API still consumes the historical flat-flag array form
  // (string slug per active flag). Builder scenario_flags is the
  // jsonb {flag_name: true|false|null} shape — translate by picking
  // every truthy key.
  const out: string[] = [];
  for (const [k, v] of Object.entries(flags)) {
    if (v === true) out.push(k);
  }
  return out;
}

// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob
// (`{key: true|false|null|other}`) to the strict `Record<string, boolean>`
// shape filterByDetailMode consumes. The rule:<uuid>=true|false per-rule
// deviation keys flow through verbatim (their truthiness IS the override
// signal isRuleSelected reads).
function scenarioFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
  const out: Record<string, boolean> = {};
  for (const [k, v] of Object.entries(flags)) {
    if (typeof v === "boolean") out[k] = v;
  }
  return out;
}

// ────────────────────────────────────────────────────────────────────────────
// Actions
// ────────────────────────────────────────────────────────────────────────────

async function loadScenario(id: string): Promise<void> {
  const deep = await fetchScenarioDeep(id);
  if (!deep) {
    setSaveState("error");
    return;
  }
  // Defensive: Go's encoding/json serialises a nil slice as `null`, not
  // `[]`. The server initialises these arrays today, but normalising on
  // the client too means a future regression (or an older deployed
  // build) can't crash renderCanvas with `null.filter(...)`.
  if (!Array.isArray(deep.proceedings)) deep.proceedings = [];
  if (!Array.isArray(deep.events)) deep.events = [];
  if (!Array.isArray(deep.shares)) deep.shares = [];
  state.active = deep;
  state.pending = {};
  // B5 — read-only when the scenario is shared with me (I'm not the
  // owner) or already promoted (server blocks mutations either way).
  const isShared = state.shared.some((s) => s.id === id);
  state.readonly = isShared || deep.status === "promoted";
  writeScenarioToUrl(id);
  setSaveState("saved");
  // Sync header inputs to scenario state.
  const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
  if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
  await applyScenarioChrome(deep, isShared);
  // B4 — reflect the scenario's Akte link on the page-header picker
  // + banner. Project-backed scenarios reveal the source project so
  // the user knows the builder writes feed into that Akte; non-Akte
  // (cold-open / event-triggered) scenarios reset the picker to
  // "— ohne —" + hide the banner.
  if (state.aktePicker) {
    state.aktePicker.setSelectedProject(deep.origin_project_id ?? null);
  }
  renderAkteBanner(deep.origin_project_id ?? null);
  renderScenarioPicker();
  renderScenarioList();
  renderCanvas();
}

async function onNewScenarioClick(): Promise<void> {
  // Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server
  // applies the default name "Unbenanntes Szenario".
  const sc = await createScenario();
  if (!sc) {
    setSaveState("error");
    return;
  }
  state.list.unshift(sc);
  await loadScenario(sc.id);
  // Open the add-proceeding picker so the user lands on the next action.
  const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null;
  if (btn) openAddProceedingPicker(btn);
}

function openAddProceedingPicker(anchor: HTMLElement): void {
  if (!state.active) return;
  mountAddProceedingPicker(anchor, state.procTypes, async (meta) => {
    if (!state.active) return;
    // Guard against a wire-shape regression: if the proceeding-types
    // endpoint stops returning `id`, `meta.id` is undefined and
    // JSON.stringify silently drops the field, the server rejects the
    // POST with a 400, and fetchJSON swallows the error — the user
    // sees "nothing happens" (t-paliad-345). Fail loud instead.
    if (typeof meta.id !== "number" || meta.id <= 0) {
      console.error("builder: missing proceeding_type id in picker meta", meta);
      setSaveState("error");
      return;
    }
    const proc = await addProceeding(state.active.id, {
      proceeding_type_id: meta.id,
    });
    if (!proc) {
      setSaveState("error");
      return;
    }
    state.active.proceedings.push(proc);
    setSaveState("saved");
    renderCanvas();
  });
}

// applyScenarioChrome sets the page-header action buttons + read-only
// watermark + body class for the freshly-loaded scenario. Editable
// scenarios get rename / share / promote enabled; read-only ones (shared
// with me, or promoted) lock all three and show the watermark. The body
// class drives the CSS that neutralises in-canvas mutating affordances.
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
  document.body.classList.toggle("builder-readonly", state.readonly);
  const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
  const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
  const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
  if (rename) rename.disabled = state.readonly;
  if (share) share.disabled = state.readonly;
  if (promote) promote.disabled = state.readonly;

  const wm = document.getElementById("builder-readonly-watermark");
  if (!wm) return;
  if (!state.readonly) {
    wm.hidden = true;
    wm.textContent = "";
    return;
  }
  if (isShared) {
    await ensureOwnerNames();
    const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
    wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
  } else {
    // Promoted (owned) scenario — read-only reference.
    wm.textContent = t("builder.bucket.promoted");
  }
  wm.hidden = false;
}

// resetScenarioChrome clears the page-header action state + watermark
// when no scenario is active (cold-open / picker cleared).
function resetScenarioChrome(): void {
  document.body.classList.remove("builder-readonly");
  for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
    const b = document.getElementById(id) as HTMLButtonElement | null;
    if (b) b.disabled = true;
  }
  const wm = document.getElementById("builder-readonly-watermark");
  if (wm) {
    wm.hidden = true;
    wm.textContent = "";
  }
}

// onShareClick opens the share modal for the active (owned, editable)
// scenario. PRD §2.5.
function onShareClick(): void {
  if (!state.active || state.readonly) return;
  void openShareModal({
    scenarioId: state.active.id,
    ownerId: state.active.owner_id,
    currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
    onChanged: (shares) => {
      if (state.active) state.active.shares = shares;
    },
  });
}

// onPromoteClick gathers the summary numbers for wizard step 1 and opens
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
// ordinal top-level) + its spawned descendants are what the server
// promotes into one case file; additional standalone proceedings are
// reported in the summary as staying behind.
function onPromoteClick(): void {
  if (!state.active || state.readonly) return;
  const sc = state.active;
  const topLevel = sc.proceedings
    .filter((p) => !p.parent_scenario_proceeding_id)
    .sort((a, b) => a.ordinal - b.ordinal);
  const primary = topLevel[0];
  if (!primary) {
    setSaveState("error");
    return;
  }
  // Collect primary + descendants to scope the event counts.
  const subtree = new Set<string>([primary.id]);
  for (let changed = true; changed; ) {
    changed = false;
    for (const p of sc.proceedings) {
      if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
        subtree.add(p.id);
        changed = true;
      }
    }
  }
  const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
  const meta = state.procTypesById.get(primary.proceeding_type_id);
  const label = meta ? meta.name || meta.code : "?";
  const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
  void openPromoteWizard({
    scenarioId: sc.id,
    ownerId: sc.owner_id,
    proceedingLabel: label,
    filedCount: evs.filter((e) => e.state === "filed").length,
    plannedCount: evs.filter((e) => e.state === "planned").length,
    flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
    extraTopLevel: topLevel.length - 1,
    defaultOurSide: defaultParty,
    defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
    onSuccess: (projectId) => {
      window.location.href = "/projects/" + encodeURIComponent(projectId);
    },
  });
}

async function onRenameClick(): Promise<void> {
  if (!state.active) return;
  const current = state.active.name;
  const next = window.prompt(t("builder.action.rename.prompt"), current);
  if (next === null) return;
  const trimmed = next.trim();
  if (!trimmed || trimmed === current) return;
  state.pending.name = trimmed;
  scheduleAutoSave();
  state.active.name = trimmed;
  renderScenarioPicker();
  renderScenarioList();
}

function onStichtagChange(value: string): void {
  if (!state.active) return;
  state.active.stichtag = value;
  state.pending.stichtag = value;
  scheduleAutoSave();
  // Re-render: the triplet's calc result depends on stichtag.
  renderCanvas();
}

// ────────────────────────────────────────────────────────────────────────────
// Wiring
// ────────────────────────────────────────────────────────────────────────────

// ────────────────────────────────────────────────────────────────────────────
// Entry-mode + universal search (B3)
// ────────────────────────────────────────────────────────────────────────────

function setMode(mode: EntryMode): void {
  if (mode === state.mode) return;
  // PRD §2.2 step 6: filter pills reset on mode switch. The current
  // surface doesn't yet render forum/proc/party/kind chips, so the
  // reset is a search-input clear; once the filter strip lands in a
  // follow-up slice the chip state attaches here too.
  const searchInput = document.getElementById("builder-search-input") as HTMLInputElement | null;
  if (searchInput) searchInput.value = "";
  state.searchCtl?.close();

  state.mode = mode;
  state.anchorRuleID = null;

  // Reflect the mode on the radio strip.
  document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
    const isActive = btn.getAttribute("data-mode") === mode;
    btn.classList.toggle("is-active", isActive);
    btn.setAttribute("aria-selected", String(isActive));
  });

  // On entering Ereignis mode, autofocus the search input per PRD §2.2.
  if (mode === "event" && state.searchCtl) {
    state.searchCtl.focus();
  }
  // On leaving event mode, redraw the canvas so any anchor highlights
  // are dropped.
  if (mode !== "event") {
    renderCanvas();
  }
}

async function onPickEvent(hit: EventSearchHit): Promise<void> {
  // PRD §2.2 — picking an event creates a scratch scenario with one
  // triplet anchored on that event's proceeding type, with the event
  // card auto-anchored (lime band + DU BIST HIER divider).
  const sc = await createScenario();
  if (!sc) {
    setSaveState("error");
    return;
  }
  state.list.unshift(sc);
  // Load the new (empty) scenario, then add the anchored proceeding.
  await loadScenario(sc.id);
  if (!state.active) return;
  state.anchorRuleID = hit.anchor_rule_id;
  const proc = await addProceeding(state.active.id, {
    proceeding_type_id: hit.proceeding_type.id,
  });
  if (!proc) {
    setSaveState("error");
    return;
  }
  state.active.proceedings.push(proc);
  setSaveState("saved");
  renderCanvas();
}

async function onPickScenarioFromSearch(hit: ScenarioSearchHit): Promise<void> {
  await loadScenario(hit.id);
}

async function onPickProjectFromSearch(hit: ProjectSearchHit): Promise<void> {
  // PRD §2.3 — picking a project from universal search puts the
  // builder into Akte mode and creates a project-backed scenario.
  // Switch the mode chip + sync the page-header Akte picker so the
  // user sees the consistent state.
  if (state.mode !== "akte") setMode("akte");
  await loadAkteScenario(hit.id);
}

// loadAkteScenario is the canonical path that turns a project_id into
// a live builder scenario. POST /api/builder/scenarios/from-project
// returns the new scenario id; we then reuse loadScenario() (which
// fetches the deep payload + sets state.active + paints the canvas).
// Refreshes the scenario list afterwards so the side panel + picker
// pick up the new row.
async function loadAkteScenario(projectId: string): Promise<void> {
  setSaveState("saving");
  const created = await createScenarioFromProject(projectId);
  if (!created) {
    setSaveState("error");
    return;
  }
  await refreshScenarioList();
  await loadScenario(created.id);
  if (state.aktePicker) state.aktePicker.setSelectedProject(projectId);
  renderAkteBanner(projectId);
}

function wireModeBar(): void {
  document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
    if (btn.hasAttribute("disabled")) return;
    btn.addEventListener("click", () => {
      const m = btn.getAttribute("data-mode") as EntryMode | null;
      if (m) setMode(m);
    });
  });
}

function wireSearch(): void {
  const input = document.getElementById("builder-search-input") as HTMLInputElement | null;
  if (!input) return;
  state.searchCtl = mountBuilderSearch(input, {
    onPickEvent: (hit) => {
      // Picking an event implicitly switches to event mode if not
      // already there — keeps the affordance honest when the user
      // searches without first clicking "Ereignis".
      if (state.mode !== "event") setMode("event");
      void onPickEvent(hit);
    },
    onPickScenario: (hit) => {
      void onPickScenarioFromSearch(hit);
    },
    onPickProject: (hit) => {
      onPickProjectFromSearch(hit);
    },
  });
}

function wirePageHeader(): void {
  document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
    void onRenameClick();
  });
  document.getElementById("builder-share-btn")?.addEventListener("click", () => {
    onShareClick();
  });
  document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
    onPromoteClick();
  });
  document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
    void onNewScenarioClick();
  });
  document.getElementById("builder-cta-new")?.addEventListener("click", () => {
    void onNewScenarioClick();
  });
  const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
  picker?.addEventListener("change", () => {
    const id = picker.value;
    if (id) void loadScenario(id);
    else {
      state.active = null;
      state.readonly = false;
      writeScenarioToUrl(null);
      resetScenarioChrome();
      renderCanvas();
    }
  });
  const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
  stichtag?.addEventListener("change", () => {
    onStichtagChange(stichtag.value);
  });
}

// wireScenarioFlagsListener keeps the builder in sync with cross-
// surface scenario_flag mutations. When a peer surface (Verfahrensablauf
// strip, project-detail page, project Verlauf) PATCHes
// /api/projects/{id}/scenario-flags, scenario-flags.ts dispatches a
// CustomEvent on document. If the changed project is the active
// scenario's origin_project_id, we refetch the scenario deep payload
// so the builder's top-level triplet picks up the new flags + any
// spawn children re-render. Non-matching events (unrelated project,
// or scenario isn't Akte-backed) are ignored.
function wireScenarioFlagsListener(): void {
  onScenarioFlagsChanged(async (detail: ScenarioFlagChangedDetail) => {
    const sc = state.active;
    if (!sc || !sc.origin_project_id) return;
    if (sc.origin_project_id !== detail.projectId) return;
    // Avoid feedback loops: the builder's own PATCH (via patchScenario
    // Flags inside scenario-flags.ts) fires this event too. We can't
    // distinguish our own dispatch from a peer's, but the refetch
    // path is idempotent — fetchScenarioDeep returns the same data
    // we already have on first paint, and the canvas re-render is
    // cheap. The save state stays "saved" because no fields are
    // dirty.
    const fresh = await fetchScenarioDeep(sc.id);
    if (!fresh) return;
    if (!Array.isArray(fresh.proceedings)) fresh.proceedings = [];
    if (!Array.isArray(fresh.events)) fresh.events = [];
    if (!Array.isArray(fresh.shares)) fresh.shares = [];
    state.active = fresh;
    renderCanvas();
  });
}

// ────────────────────────────────────────────────────────────────────────────
// B6 — mobile basic-read guard (PRD §10 + §7.1)
// ────────────────────────────────────────────────────────────────────────────

// Mutating affordances that get gated on narrow viewports. Reading
// (open a scenario from the side panel, switch via the picker, search,
// switch entry mode) stays fully functional — only scenario-mutating
// taps are intercepted.
const MOBILE_MUTATING_SELECTOR = [
  "#builder-rename-btn",
  "#builder-share-btn",
  "#builder-promote-btn",
  "#builder-new-scenario-btn",
  "#builder-cta-new",
  "#builder-add-proceeding-btn",
  ".builder-triplet-host button",
  ".builder-triplet-host input",
  ".builder-triplet-host select",
].join(",");

function isNarrowViewport(): boolean {
  return typeof window.matchMedia === "function" &&
    window.matchMedia("(max-width: 640px)").matches;
}

let mobileToastTimer: number | null = null;

function showMobileBlockedToast(): void {
  let toast = document.getElementById("builder-mobile-toast");
  if (!toast) {
    toast = document.createElement("div");
    toast.id = "builder-mobile-toast";
    toast.className = "builder-mobile-toast";
    toast.setAttribute("role", "status");
    toast.setAttribute("aria-live", "polite");
    document.body.appendChild(toast);
  }
  toast.textContent = t("builder.mobile.blocked");
  toast.classList.add("is-visible");
  if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
  mobileToastTimer = window.setTimeout(() => {
    document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
  }, 2600);
}

// wireMobileGuard intercepts taps on mutating affordances when the
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
// öffnen" toast instead of running the action. Capture phase so it
// pre-empts the control's own (bubble-phase) handler; calling
// preventDefault on a checkbox click also blocks its toggle + change
// event. Desktop is untouched — the guard early-returns unless the media
// query matches, so the desktop interaction code paths stay identical
// (PRD §10).
function wireMobileGuard(): void {
  document.addEventListener(
    "click",
    (e) => {
      if (!isNarrowViewport()) return;
      const target = e.target as HTMLElement | null;
      if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
      e.preventDefault();
      e.stopPropagation();
      showMobileBlockedToast();
    },
    true,
  );
}

export async function mountBuilder(): Promise<void> {
  wirePageHeader();
  wireModeBar();
  wireSearch();
  wireScenarioFlagsListener();
  wireMobileGuard();
  // Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
  // for the add-proceeding picker + scenario_flag_catalog for the
  // per-triplet flag strip. PRD §0.4 — UPC v1.
  const [procTypes, flagCatalog] = await Promise.all([
    fetchProceedingTypes(),
    fetchFlagCatalog(),
  ]);
  state.procTypes = procTypes;
  state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
  state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
  state.flagCatalog = flagCatalog;
  await refreshScenarioList();

  // B4 — mount the Akte picker in parallel with the scenario load.
  // The picker fetches /api/projects?type=case; the call is gated on
  // requireDB so dev/test environments without a DB pool return [].
  // Independent of scenario state — we mount it before deciding
  // whether to load a scenario so the picker is interactive even if
  // the requested scenario fails to load.
  state.aktePicker = await mountAktePicker(async (projectId) => {
    if (state.mode !== "akte") setMode("akte");
    await loadAkteScenario(projectId);
  });

  const requested = readScenarioFromUrl();
  // Deep-link auto-load covers both owned scenarios and ones shared with
  // me (so a "Geteilt mit mir" link opens straight into the read-only
  // view, not the cold-open canvas). loadScenario derives read-only from
  // state.shared, so the share watermark + locked affordances apply.
  const isKnown =
    requested != null &&
    (state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
  if (isKnown) {
    await loadScenario(requested as string);
  } else {
    renderCanvas();
  }
  setSaveState("idle");
}

// ────────────────────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────────────────────

function todayISO(): string {
  return new Date().toISOString().slice(0, 10);
}

export function escAttr(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

export function escHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

// Re-export getLang so the per-page bundle pulls i18n into the dep
// graph (the i18n module's side-effect-free initialiser otherwise
// gets tree-shaken when only string keys are referenced).
export { getLang };
