import type { LaneInfo, TimelineEvent } from "./shape-timeline";

// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
// renderer for the standalone Project Timeline / Chart page.
//
// Split into two concerns:
//
//   layout(events, lanes, viewport): ChartLayout
//     pure function — translates the wire shape into deterministic
//     SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
//     today-rule x). No DOM access. Table-driven tests pin this in
//     shape-timeline-chart.test.ts.
//
//   paint(layout, root): void  (Slice 1, next commit)
//     DOM-mutates an SVGSVGElement. Reads layout, never recomputes
//     positions. Idempotent — calling twice with the same layout
//     produces the same DOM.
//
//   mount(host, opts): ChartHandle  (Slice 1, next commit)
//     End-to-end: fetches /api/projects/{id}/timeline, computes layout,
//     paints, returns a handle with .refresh() / .dispose().
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export type Density = "compact" | "standard" | "spacious";

export interface ChartViewport {
  width: number;
  height: number;
  /** Reserved on the left for lane labels (and the undated zone). */
  laneLabelWidth: number;
  /** Reserved on top for the date axis. */
  dateAxisHeight: number;
  /** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
  todayISO: string;
  /** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
  rangeFrom: string;
  /** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
  rangeTo: string;
  density: Density;
}

export interface AxisTick {
  x: number;
  label: string;
  kind: "year" | "quarter" | "month";
  isYearBoundary: boolean;
}

export interface LaneRow {
  id: string;
  label: string;
  y: number;
  height: number;
}

export type MarkShape =
  | "dot"
  | "diamond"
  | "hatched-dot"
  | "dashed-dot";

export interface Mark {
  /** Index into the original events array — paint() reuses this for tooltips + deep-links. */
  eventIndex: number;
  x: number;
  y: number;
  /** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
  radius: number;
  shape: MarkShape;
  kind: TimelineEvent["kind"];
  status: TimelineEvent["status"];
  laneId: string;
  undated: boolean;
}

export interface ChartLayout {
  viewport: ChartViewport;
  pxPerDay: number;
  chartLeft: number;
  chartTop: number;
  chartWidth: number;
  chartHeight: number;
  axisTicks: AxisTick[];
  laneRows: LaneRow[];
  marks: Mark[];
  /** Pixel x of the today rule, or null when today is outside the range. */
  todayX: number | null;
  undatedCount: number;
}

// ---------------------------------------------------------------------------
// Density tokens — single source of truth, used by layout() and CSS swap.
// ---------------------------------------------------------------------------

const LANE_HEIGHT: Record<Density, number> = {
  compact: 24,
  standard: 40,
  spacious: 64,
};

const MARK_RADIUS: Record<Density, number> = {
  compact: 5,
  standard: 7,
  spacious: 10,
};

// ---------------------------------------------------------------------------
// Date helpers — UTC throughout to avoid DST drift in day-math.
// ---------------------------------------------------------------------------

const DAY_MS = 86_400_000;

function parseISODay(iso: string): number | null {
  // Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
  // deadline.due_date as the UTC-midnight form — see format.ts).
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
  if (!m) return null;
  const y = Number(m[1]);
  const mo = Number(m[2]);
  const d = Number(m[3]);
  if (
    !Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
    mo < 1 || mo > 12 || d < 1 || d > 31
  ) {
    return null;
  }
  return Date.UTC(y, mo - 1, d);
}

function dayDelta(fromMs: number, toMs: number): number {
  return Math.round((toMs - fromMs) / DAY_MS);
}

// ---------------------------------------------------------------------------
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
// ---------------------------------------------------------------------------

function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
  if (kind === "milestone") return "diamond";
  if (kind === "projected") {
    if (status === "court_set") return "dashed-dot";
    return "hatched-dot"; // predicted, predicted_overdue, off_script
  }
  // deadline + appointment + everything else → plain dot. Status drives
  // colour saturation (see CSS palette tokens), not shape.
  return "dot";
}

// ---------------------------------------------------------------------------
// Axis tick generation — granularity by total span.
// ---------------------------------------------------------------------------

function generateTicks(
  fromMs: number,
  toMs: number,
  chartLeft: number,
  pxPerDay: number,
): AxisTick[] {
  const totalDays = dayDelta(fromMs, toMs);
  const ticks: AxisTick[] = [];

  // Walk from the first day-of-month >= fromMs forward.
  const start = new Date(fromMs);
  const yStart = start.getUTCFullYear();
  const mStart = start.getUTCMonth();

  // Density rules:
  //   <90d   → month ticks (every month-start)
  //   90-730 → quarter ticks (Jan, Apr, Jul, Oct)
  //   >730   → year ticks (Jan only)
  let kind: AxisTick["kind"];
  let monthStep: number;
  if (totalDays < 90) {
    kind = "month";
    monthStep = 1;
  } else if (totalDays <= 730) {
    kind = "quarter";
    monthStep = 3;
  } else {
    kind = "year";
    monthStep = 12;
  }

  // For quarter/year ticks, snap the starting month to the next aligned
  // boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
  // Feb/May/Aug/Nov).
  let mCursor = mStart;
  let yCursor = yStart;
  if (kind === "quarter") {
    const offset = mCursor % 3;
    if (offset !== 0) mCursor += 3 - offset;
  } else if (kind === "year") {
    if (mCursor !== 0) {
      mCursor = 0;
      yCursor += 1;
    }
  }
  // If the first day of fromMs is not month-1, advance by one month so we
  // don't double-print the partial month at the very start.
  if (kind === "month" && start.getUTCDate() !== 1) {
    mCursor += 1;
  }
  while (mCursor >= 12) {
    mCursor -= 12;
    yCursor += 1;
  }

  // Emit ticks until past toMs.
  while (true) {
    const tickMs = Date.UTC(yCursor, mCursor, 1);
    if (tickMs > toMs) break;
    const days = dayDelta(fromMs, tickMs);
    const x = chartLeft + days * pxPerDay;
    const label = formatTickLabel(yCursor, mCursor, kind);
    ticks.push({
      x,
      label,
      kind,
      isYearBoundary: mCursor === 0,
    });
    mCursor += monthStep;
    while (mCursor >= 12) {
      mCursor -= 12;
      yCursor += 1;
    }
  }
  return ticks;
}

const MONTH_DE = [
  "Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
  "Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];

function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
  if (kind === "year") return String(year);
  if (kind === "quarter") {
    const q = Math.floor(month / 3) + 1;
    return `Q${q} ${year}`;
  }
  return MONTH_DE[month];
}

// ---------------------------------------------------------------------------
// Public: layout
// ---------------------------------------------------------------------------

export function layout(
  events: ReadonlyArray<TimelineEvent>,
  lanes: ReadonlyArray<LaneInfo>,
  viewport: ChartViewport,
): ChartLayout {
  // -- Canvas geometry --------------------------------------------------
  const chartLeft = viewport.laneLabelWidth;
  const chartTop = viewport.dateAxisHeight;
  const chartWidth = Math.max(0, viewport.width - chartLeft);
  // chartHeight is derived from the number of lane rows so the SVG grows
  // / shrinks vertically with the data, not the supplied viewport.height
  // (which the caller uses as a hint — actual height comes back in
  // viewport.height after the paint pass).
  const laneCount = Math.max(1, lanes.length);
  const laneHeight = LANE_HEIGHT[viewport.density];
  const chartHeight = laneCount * laneHeight;

  // -- Date math --------------------------------------------------------
  const fromMs = parseISODay(viewport.rangeFrom);
  const toMsRaw = parseISODay(viewport.rangeTo);
  if (fromMs === null || toMsRaw === null) {
    // Degenerate input — return an empty layout rather than NaN-paint.
    return {
      viewport,
      pxPerDay: 0,
      chartLeft,
      chartTop,
      chartWidth,
      chartHeight,
      axisTicks: [],
      laneRows: synthLaneRows(lanes, chartTop, laneHeight),
      marks: [],
      todayX: null,
      undatedCount: 0,
    };
  }
  // Guard against to < from. Clamp the inverted case to a 1-day span so
  // pxPerDay stays positive and finite.
  const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
  const totalDays = Math.max(1, dayDelta(fromMs, toMs));
  const pxPerDay = chartWidth / totalDays;

  // -- Today rule -------------------------------------------------------
  const todayMs = parseISODay(viewport.todayISO);
  let todayX: number | null = null;
  if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
    todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
  }

  // -- Lane rows --------------------------------------------------------
  const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
  const laneIndex = new Map<string, LaneRow>();
  for (const row of laneRows) laneIndex.set(row.id, row);
  const fallbackLane = laneRows[0];

  // -- Marks ------------------------------------------------------------
  const marks: Mark[] = [];
  let undatedCount = 0;
  const radius = MARK_RADIUS[viewport.density];

  for (let i = 0; i < events.length; i++) {
    const event = events[i];
    const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;

    if (!event.date) {
      // Undated rows live in a gutter to the left of the chart canvas.
      // We pile them up vertically inside the lane label area so they
      // remain hover-/click-targets, but they don't compete with the
      // date-axis-positioned marks for screen space.
      undatedCount++;
      const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
      marks.push({
        eventIndex: i,
        x: undatedX,
        y: laneRow.y + laneRow.height / 2,
        radius,
        shape: markShape(event.kind, event.status),
        kind: event.kind,
        status: event.status,
        laneId: laneRow.id,
        undated: true,
      });
      continue;
    }

    const ms = parseISODay(event.date);
    if (ms === null) continue; // unparseable date, drop defensively
    if (ms < fromMs || ms > toMs) continue; // outside range — clipped

    const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
    const y = laneRow.y + laneRow.height / 2;
    marks.push({
      eventIndex: i,
      x,
      y,
      radius,
      shape: markShape(event.kind, event.status),
      kind: event.kind,
      status: event.status,
      laneId: laneRow.id,
      undated: false,
    });
  }

  // -- Axis ticks -------------------------------------------------------
  const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);

  return {
    viewport,
    pxPerDay,
    chartLeft,
    chartTop,
    chartWidth,
    chartHeight,
    axisTicks,
    laneRows,
    marks,
    todayX,
    undatedCount,
  };
}

function synthLaneRows(
  lanes: ReadonlyArray<LaneInfo>,
  chartTop: number,
  laneHeight: number,
): LaneRow[] {
  if (lanes.length === 0) {
    return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
  }
  return lanes.map((lane, idx) => ({
    id: lane.id,
    label: lane.label,
    y: chartTop + idx * laneHeight,
    height: laneHeight,
  }));
}

// ---------------------------------------------------------------------------
// Public: paint
// ---------------------------------------------------------------------------

const SVG_NS = "http://www.w3.org/2000/svg";

function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
  const el = document.createElementNS(SVG_NS, name);
  for (const [k, v] of Object.entries(attrs)) {
    el.setAttribute(k, String(v));
  }
  return el;
}

/**
 * paint mutates an existing SVGSVGElement to reflect a ChartLayout.
 * Idempotent: clears prior children before painting, so calling twice
 * with the same layout produces the same DOM.
 *
 * Events are *not* wired here — mount() attaches the delegated listeners
 * after paint() returns. paint() stays pure-render so it stays cheap to
 * call from a resize / palette swap.
 */
export function paint(
  chart: ChartLayout,
  root: SVGSVGElement,
  events: ReadonlyArray<TimelineEvent>,
): void {
  // Clear prior contents.
  while (root.firstChild) root.removeChild(root.firstChild);

  const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
  root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
  root.setAttribute("preserveAspectRatio", "xMinYMin meet");
  root.setAttribute("role", "img");
  root.setAttribute("aria-label", "Project Timeline / Chart");

  // <defs> — hatched pattern for projected marks.
  const defs = svg("defs");
  const pattern = svg("pattern", {
    id: "chart-hatch",
    patternUnits: "userSpaceOnUse",
    width: 4,
    height: 4,
  });
  pattern.appendChild(svg("path", {
    d: "M0,4 L4,0",
    stroke: "currentColor",
    "stroke-width": 1,
    fill: "none",
  }));
  defs.appendChild(pattern);
  root.appendChild(defs);

  // Layer order: grid → lane separators → today rule → marks → labels.
  const gGrid = svg("g", { class: "chart-grid" });
  root.appendChild(gGrid);

  // Date axis ticks — vertical guidelines + labels at top.
  for (const tick of chart.axisTicks) {
    gGrid.appendChild(svg("line", {
      class: tick.isYearBoundary
        ? "chart-tick chart-tick--year"
        : "chart-tick",
      x1: tick.x,
      y1: chart.chartTop,
      x2: tick.x,
      y2: chart.chartTop + chart.chartHeight,
    }));
    const label = svg("text", {
      class: "chart-tick-label",
      x: tick.x + 4,
      y: chart.chartTop - 8,
    });
    label.textContent = tick.label;
    gGrid.appendChild(label);
  }

  // Lane separators — horizontal lines between rows + labels in the gutter.
  // Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
  // tooltip cleanly. SVG <text> has no auto-clipping and long titles
  // would bleed into the chart canvas (t-paliad-211).
  const labelPadding = 8;
  const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
  for (let i = 0; i < chart.laneRows.length; i++) {
    const row = chart.laneRows[i];
    if (i > 0) {
      gGrid.appendChild(svg("line", {
        class: "chart-lane-separator",
        x1: 0,
        y1: row.y,
        x2: chart.viewport.width,
        y2: row.y,
      }));
    }
    if (row.label) {
      const fo = svg("foreignObject", {
        class: "chart-lane-label-fo",
        x: labelPadding,
        y: row.y,
        width: labelMaxWidth,
        height: row.height,
      });
      const div = document.createElement("div");
      div.className = "chart-lane-label";
      div.textContent = row.label;
      div.title = row.label;
      fo.appendChild(div);
      gGrid.appendChild(fo);
    }
  }

  // Today rule — vertical lime line + "Heute" label.
  if (chart.todayX !== null) {
    gGrid.appendChild(svg("line", {
      class: "chart-today-rule",
      x1: chart.todayX,
      y1: chart.chartTop - 4,
      x2: chart.todayX,
      y2: chart.chartTop + chart.chartHeight + 4,
    }));
    const todayLabel = svg("text", {
      class: "chart-today-label",
      x: chart.todayX + 4,
      y: chart.chartTop + chart.chartHeight + 18,
    });
    todayLabel.textContent = "Heute";
    gGrid.appendChild(todayLabel);
  }

  // Marks.
  const gMarks = svg("g", { class: "chart-marks" });
  root.appendChild(gMarks);

  for (const mark of chart.marks) {
    const event = events[mark.eventIndex];
    const markEl = paintMark(mark, event);
    gMarks.appendChild(markEl);
  }
}

function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
  // Wrap every mark in a <g> with data-* attributes so mount() can do
  // event-delegation off the top-level <svg> without per-mark listeners.
  const g = svg("g", {
    class: markClassName(mark),
    "data-event-index": mark.eventIndex,
    "data-kind": mark.kind,
    "data-status": mark.status,
    "data-lane": mark.laneId,
    "data-undated": mark.undated ? "1" : "0",
    "data-deadline-id": event.deadline_id || "",
    "data-appointment-id": event.appointment_id || "",
    "data-project-event-id": event.project_event_id || "",
    role: "img",
    tabindex: 0,
  });

  // ARIA label so screen-readers can read each mark (§13).
  const title = svg("title");
  title.textContent = markAriaLabel(mark, event);
  g.appendChild(title);

  // Generous invisible hit-target so dots are easy to click without
  // hunting (12px hit halo around a 7px standard radius).
  g.appendChild(svg("circle", {
    class: "chart-mark-hit",
    cx: mark.x,
    cy: mark.y,
    r: mark.radius + 6,
    fill: "transparent",
  }));

  switch (mark.shape) {
    case "dot": {
      const c = svg("circle", {
        class: "chart-mark-dot",
        cx: mark.x,
        cy: mark.y,
        r: mark.radius,
      });
      g.appendChild(c);
      break;
    }
    case "diamond": {
      const r = mark.radius;
      g.appendChild(svg("polygon", {
        class: "chart-mark-diamond",
        points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
      }));
      break;
    }
    case "hatched-dot": {
      g.appendChild(svg("circle", {
        class: "chart-mark-hatched",
        cx: mark.x,
        cy: mark.y,
        r: mark.radius,
        fill: "url(#chart-hatch)",
      }));
      break;
    }
    case "dashed-dot": {
      g.appendChild(svg("circle", {
        class: "chart-mark-dashed",
        cx: mark.x,
        cy: mark.y,
        r: mark.radius,
      }));
      break;
    }
  }
  return g;
}

function markClassName(mark: Mark): string {
  const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
  if (mark.undated) parts.push("chart-mark--undated");
  return parts.join(" ");
}

function markAriaLabel(mark: Mark, event: TimelineEvent): string {
  const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
  return `${event.title} — ${event.kind} (${event.status}) — ${dateStr}`;
}

// ---------------------------------------------------------------------------
// Public: mount
// ---------------------------------------------------------------------------

/** Palette presets from design §5.1. Each is a CSS-var override hung off
 *  `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
 *  reads palette state directly. */
export type Palette =
  | "default"
  | "kind-coded"
  | "track-coded"
  | "high-contrast"
  | "print";

export const ALL_PALETTES: ReadonlyArray<Palette> = [
  "default",
  "kind-coded",
  "track-coded",
  "high-contrast",
  "print",
];

export const ALL_DENSITIES: ReadonlyArray<Density> = [
  "compact",
  "standard",
  "spacious",
];

/** Range presets from design §10 + faraday-Q8 default. The chart caller
 *  drives the active preset via setRange; "all" derives bounds from the
 *  loaded events at repaint time so adding / completing a row reflows. */
export type RangePreset = "1y" | "2y" | "all" | "custom";

export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
  "1y",
  "2y",
  "all",
  "custom",
];

export interface ChartMountOpts {
  projectId: string;
  todayISO?: string;
  density?: Density;
  palette?: Palette;
  /** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
  rangePreset?: RangePreset;
  /** When rangePreset === "custom", these supply the bounds. Ignored for
   *  preset values — those derive bounds from the preset + todayISO (or,
   *  for "all", from the loaded events). */
  rangeFrom?: string;
  rangeTo?: string;
  /** Optional callback fired when the user clicks a mark with a known
   *  deep-link target. Receives the underlying TimelineEvent. */
  onMarkClick?: (event: TimelineEvent) => void;
  /** Optional callback fired after every refresh() so the host can
   *  re-render dynamic UI (e.g. lane filter chips). */
  onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
  /** Initial visible-lane allowlist. null = show all (default).
   *  Lane ids not present in the response are silently dropped. */
  visibleLanes?: string[] | null;
  /** Pre-loaded data — used by Custom Views (Slice 4) where the rows
   *  come from ViewService not /api/projects/{id}/timeline. When set,
   *  mount() skips the initial fetch and paints from this data; the
   *  handle's refresh() still hits the project endpoint (caller can
   *  swap the chart back to project-mode via the standalone /chart URL). */
  staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
}

export interface ChartHandle {
  /** Re-fetches the timeline and re-paints. */
  refresh: () => Promise<void>;
  /** Removes event listeners + tears down the SVG. */
  dispose: () => void;
  /** Returns the last computed layout (useful for tests / debugging). */
  getLayout: () => ChartLayout | null;
  /** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
  setPalette: (palette: Palette) => void;
  /** Swap density. Re-runs layout() since lane height / mark radius change. */
  setDensity: (density: Density) => void;
  /** Switch range preset. "all" derives bounds from the loaded events;
   *  "custom" expects customFrom + customTo (otherwise it falls back to
   *  today-1y..today+1y). All others are time-shifted from todayISO. */
  setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
  /** Set the lane allowlist. null = show all lanes (default). Unknown
   *  ids in the passed array are silently dropped on repaint. */
  setVisibleLanes: (lanes: string[] | null) => void;
  /** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
  getSVGElement: () => SVGSVGElement;
  /** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
  getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
}

interface TimelineEnvelope {
  events: TimelineEvent[];
  lanes: LaneInfo[];
}

/**
 * mount builds a chart inside the given host element. The host's
 * dimensions drive the SVG width; height grows from the lane row count.
 * Returns a handle for refresh / dispose.
 */
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
  host.classList.add("smart-timeline-chart-host");

  // Empty / error placeholders.
  const messageEl = document.createElement("div");
  messageEl.className = "smart-timeline-chart-message";
  messageEl.textContent = "";
  host.appendChild(messageEl);

  // The SVG root we paint into.
  const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
  svgEl.classList.add("smart-timeline-chart");
  svgEl.setAttribute("data-palette", opts.palette ?? "default");
  svgEl.setAttribute("data-density", opts.density ?? "standard");
  host.appendChild(svgEl);

  let lastEvents: TimelineEvent[] = [];
  let lastLayout: ChartLayout | null = null;

  const todayISO = opts.todayISO ?? today();
  let currentDensity: Density = opts.density ?? "standard";
  let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
  let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
  let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
  let currentVisibleLanes: Set<string> | null = opts.visibleLanes
    ? new Set(opts.visibleLanes)
    : null;

  function resolveRange(): { from: string; to: string } {
    switch (currentRangePreset) {
      case "1y":
        return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
      case "2y":
        return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
      case "all":
        return rangeFromEvents(lastEvents, todayISO);
      case "custom":
        return { from: customRangeFrom, to: customRangeTo };
    }
  }

  function repaint(): void {
    const rect = host.getBoundingClientRect();
    // Minimum width keeps the canvas usable when the host is hidden /
    // about to be sized; resize listener will repaint on real layout.
    const width = Math.max(640, rect.width || 1000);
    const { from, to } = resolveRange();
    const viewport: ChartViewport = {
      width,
      height: 400,
      laneLabelWidth: 200,
      dateAxisHeight: 40,
      todayISO,
      rangeFrom: from,
      rangeTo: to,
      density: currentDensity,
    };
    // Lane allowlist filter. null = show all; otherwise drop both the
    // lane rows AND the events whose lane_id sits outside the allowlist.
    // (We don't fall back to "first lane" here — that's only sensible
    // when a stale id slips through; an explicit hide is a hide.)
    let renderLanes = [...currentLanes];
    let renderEvents: TimelineEvent[] = lastEvents;
    if (currentVisibleLanes !== null) {
      const allow = currentVisibleLanes;
      renderLanes = currentLanes.filter((l) => allow.has(l.id));
      renderEvents = lastEvents.filter((e) => {
        // Empty / missing lane_id is treated as "self" — included only
        // when the synthetic "self" lane is allowed.
        const id = e.lane_id || "self";
        return allow.has(id);
      });
    }
    const chart = layout(renderEvents, renderLanes, viewport);
    lastLayout = chart;
    paint(chart, svgEl, renderEvents);
    svgEl.setAttribute("width", String(width));
    svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
  }

  let currentLanes: LaneInfo[] = [];

  async function refresh(): Promise<void> {
    messageEl.textContent = "Lädt …";
    messageEl.classList.remove("smart-timeline-chart-message--error");
    try {
      const resp = await fetch(
        `/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
      );
      if (!resp.ok) {
        messageEl.textContent = "Timeline konnte nicht geladen werden.";
        messageEl.classList.add("smart-timeline-chart-message--error");
        return;
      }
      const body = await resp.json();
      // Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
      // even though the Slice-4 envelope is the contract today.
      if (Array.isArray(body)) {
        lastEvents = body as TimelineEvent[];
        currentLanes = [];
      } else {
        const env = body as TimelineEnvelope;
        lastEvents = env.events ?? [];
        currentLanes = env.lanes ?? [];
      }
      if (lastEvents.length === 0) {
        messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
      } else {
        messageEl.textContent = "";
      }
      // Drop stale lane ids from the allowlist — a deleted CCR / child
      // case shouldn't keep its lane id alive across re-fetches.
      if (currentVisibleLanes !== null) {
        const valid = new Set(currentLanes.map((l) => l.id));
        valid.add("self"); // synthetic lane always allowed
        const trimmed = new Set<string>();
        for (const id of currentVisibleLanes) {
          if (valid.has(id)) trimmed.add(id);
        }
        currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
      }
      repaint();
      if (opts.onDataLoaded) {
        opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
      }
    } catch (err) {
      messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
      messageEl.classList.add("smart-timeline-chart-message--error");
    }
  }

  // Click delegation — read data-* attrs to deep-link.
  function handleClick(e: Event) {
    const target = e.target as Element | null;
    if (!target) return;
    const g = target.closest("g.chart-mark") as Element | null;
    if (!g) return;
    const indexAttr = g.getAttribute("data-event-index");
    if (!indexAttr) return;
    const idx = Number(indexAttr);
    const event = lastEvents[idx];
    if (!event) return;
    if (opts.onMarkClick) {
      opts.onMarkClick(event);
      return;
    }
    if (event.deadline_id) {
      window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
    } else if (event.appointment_id) {
      window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
    }
    // Milestones + projected rows have no detail page today — no-op.
  }

  // Resize handler — debounced.
  let resizeTimer: ReturnType<typeof setTimeout> | null = null;
  function handleResize() {
    if (resizeTimer) clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      repaint();
    }, 120);
  }

  svgEl.addEventListener("click", handleClick);
  window.addEventListener("resize", handleResize);

  // If the caller supplied data up front (Custom Views host path), skip
  // the project-timeline fetch entirely — paint from the supplied rows.
  // Otherwise kick off the initial /api/projects/{id}/timeline load.
  if (opts.staticData) {
    lastEvents = opts.staticData.events;
    currentLanes = opts.staticData.lanes;
    if (lastEvents.length === 0) {
      messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
    } else {
      messageEl.textContent = "";
    }
    repaint();
    if (opts.onDataLoaded) {
      opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
    }
  } else {
    void refresh();
  }

  return {
    refresh,
    getLayout: () => lastLayout,
    setPalette: (palette: Palette) => {
      svgEl.setAttribute("data-palette", palette);
    },
    setDensity: (density: Density) => {
      currentDensity = density;
      svgEl.setAttribute("data-density", density);
      repaint();
    },
    setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
      currentRangePreset = preset;
      if (preset === "custom") {
        if (customFrom) customRangeFrom = customFrom;
        if (customTo) customRangeTo = customTo;
      }
      svgEl.setAttribute("data-range-preset", preset);
      repaint();
    },
    setVisibleLanes: (lanes: string[] | null) => {
      currentVisibleLanes = lanes ? new Set(lanes) : null;
      repaint();
    },
    getSVGElement: () => svgEl,
    getData: () => ({ events: lastEvents, lanes: currentLanes }),
    dispose: () => {
      svgEl.removeEventListener("click", handleClick);
      window.removeEventListener("resize", handleResize);
      if (resizeTimer) clearTimeout(resizeTimer);
      if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
      if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
    },
  };
}

/** Resolve the "all" preset bounds from the loaded events. Empty data
 *  falls back to the 1y default so the chart canvas isn't degenerate. */
function rangeFromEvents(
  events: ReadonlyArray<TimelineEvent>,
  todayISO: string,
): { from: string; to: string } {
  let minMs: number | null = null;
  let maxMs: number | null = null;
  for (const ev of events) {
    if (!ev.date) continue;
    const ms = parseISODay(ev.date);
    if (ms === null) continue;
    if (minMs === null || ms < minMs) minMs = ms;
    if (maxMs === null || ms > maxMs) maxMs = ms;
  }
  if (minMs === null || maxMs === null) {
    return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
  }
  // Pad +30d at the right so the last event isn't flush against the edge.
  const fromDate = new Date(minMs);
  const toDate = new Date(maxMs + 30 * 86_400_000);
  return {
    from: toISO(fromDate),
    to: toISO(toDate),
  };
}

function toISO(d: Date): string {
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}

function today(): string {
  const d = new Date();
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const dd = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${dd}`;
}

function shiftYears(iso: string, delta: number): string {
  const ms = parseISODay(iso);
  if (ms === null) return iso;
  const d = new Date(ms);
  return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
