import { describe, expect, test } from "bun:test";
import { layout, type ChartViewport } from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";

// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
// into deterministic SVG-ready geometry. Tests pin the math so subtle
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.

const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
  width: 1000,
  height: 400,
  laneLabelWidth: 200,
  dateAxisHeight: 40,
  todayISO: "2026-06-15",
  rangeFrom: "2026-01-01",
  rangeTo: "2026-12-31",
  density: "standard",
  ...overrides,
});

const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
  kind: "deadline",
  status: "open",
  track: "parent",
  date: "2026-06-15",
  title: "Test event",
  ...overrides,
});

describe("layout — base geometry", () => {
  test("chart canvas sits to the right of lane labels and below date axis", () => {
    const out = layout([], [], vp());
    expect(out.chartLeft).toBe(200);
    expect(out.chartTop).toBe(40);
    expect(out.chartWidth).toBe(800);
    expect(out.chartHeight).toBeGreaterThan(0);
  });

  test("pxPerDay = chartWidth / total_days", () => {
    // 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
    const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
    expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
  });

  test("invalid range (to before from) falls back to a 1-day span", () => {
    const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
    // Sanity: pxPerDay finite, no division-by-zero.
    expect(Number.isFinite(out.pxPerDay)).toBe(true);
    expect(out.pxPerDay).toBeGreaterThan(0);
  });
});

describe("layout — today rule", () => {
  test("today inside range produces a non-null todayX in the chart canvas", () => {
    const out = layout([], [], vp({ todayISO: "2026-06-15" }));
    expect(out.todayX).not.toBeNull();
    expect(out.todayX!).toBeGreaterThan(out.chartLeft);
    expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
  });

  test("today before range.from → todayX is null", () => {
    const out = layout([], [], vp({ todayISO: "2025-12-15" }));
    expect(out.todayX).toBeNull();
  });

  test("today after range.to → todayX is null", () => {
    const out = layout([], [], vp({ todayISO: "2027-01-15" }));
    expect(out.todayX).toBeNull();
  });

  test("today equals range.from → todayX sits at chartLeft", () => {
    const out = layout([], [], vp({ todayISO: "2026-01-01" }));
    expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
  });
});

describe("layout — lane stacking", () => {
  test("empty lanes synthesises a single 'self' lane", () => {
    const out = layout([], [], vp());
    expect(out.laneRows).toHaveLength(1);
    expect(out.laneRows[0].id).toBe("self");
  });

  test("multiple lanes stack vertically in input order", () => {
    const lanes: LaneInfo[] = [
      { id: "self", label: "Hauptverfahren" },
      { id: "counterclaim:abc", label: "Widerklage" },
      { id: "parent_context:xyz", label: "Parent" },
    ];
    const out = layout([], lanes, vp());
    expect(out.laneRows).toHaveLength(3);
    expect(out.laneRows[0].y).toBe(out.chartTop);
    expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
    expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
    // All same height.
    expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
    expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
  });

  test("density compact gives smaller lane height than spacious", () => {
    const compact = layout([], [], vp({ density: "compact" }));
    const spacious = layout([], [], vp({ density: "spacious" }));
    expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
  });
});

describe("layout — marks", () => {
  test("single deadline maps to one mark in the self lane", () => {
    const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
    const out = layout(events, [], vp());
    expect(out.marks).toHaveLength(1);
    expect(out.marks[0].eventIndex).toBe(0);
    expect(out.marks[0].laneId).toBe("self");
    expect(out.marks[0].undated).toBe(false);
  });

  test("event's x position matches its date offset from range.from", () => {
    // June 15 is day 165 of 2026 (0-indexed from Jan 1).
    const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
    const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
    const expectedX = out.chartLeft + 165 * out.pxPerDay;
    expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
  });

  test("event bucketed by lane_id matches the corresponding lane row", () => {
    const lanes: LaneInfo[] = [
      { id: "self", label: "Self" },
      { id: "ccr", label: "CCR" },
    ];
    const events: TimelineEvent[] = [
      ev({ date: "2026-06-15", lane_id: "ccr" }),
    ];
    const out = layout(events, lanes, vp());
    const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
    expect(out.marks[0].laneId).toBe("ccr");
    expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
  });

  test("unknown lane_id falls back to the first lane (defensive)", () => {
    const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
    const events: TimelineEvent[] = [
      ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
    ];
    const out = layout(events, lanes, vp());
    expect(out.marks[0].laneId).toBe("self");
  });

  test("events outside range are clipped (not emitted)", () => {
    const events: TimelineEvent[] = [
      ev({ date: "2025-01-01", title: "before" }),
      ev({ date: "2026-06-15", title: "inside" }),
      ev({ date: "2027-12-31", title: "after" }),
    ];
    const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
    expect(out.marks).toHaveLength(1);
    expect(out.marks[0].eventIndex).toBe(1);
  });

  test("undated events go to the undated zone with undated=true", () => {
    const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
    const out = layout(events, [], vp());
    expect(out.marks).toHaveLength(1);
    expect(out.marks[0].undated).toBe(true);
    // Undated marks sit in the lane label gutter (x < chartLeft).
    expect(out.marks[0].x).toBeLessThan(out.chartLeft);
  });
});

describe("layout — mark shapes by kind+status", () => {
  test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
    const events: TimelineEvent[] = [
      ev({ kind: "deadline", status: "done" }),
      ev({ kind: "deadline", status: "open" }),
      ev({ kind: "deadline", status: "overdue" }),
    ];
    const out = layout(events, [], vp());
    expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
  });

  test("milestone → diamond", () => {
    const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
    const out = layout(events, [], vp());
    expect(out.marks[0].shape).toBe("diamond");
  });

  test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
    const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
    const out = layout(events, [], vp());
    expect(out.marks[0].shape).toBe("dot");
  });

  test("projected.predicted → hatched-dot", () => {
    const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
    const out = layout(events, [], vp());
    expect(out.marks[0].shape).toBe("hatched-dot");
  });

  test("projected.court_set → dashed-dot", () => {
    const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
    const out = layout(events, [], vp());
    expect(out.marks[0].shape).toBe("dashed-dot");
  });
});

describe("layout — axis ticks", () => {
  test("short range (<90d) emits month ticks", () => {
    const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
    const kinds = new Set(out.axisTicks.map((t) => t.kind));
    expect(kinds.has("month")).toBe(true);
  });

  test("medium range (90-730d) emits quarter ticks", () => {
    const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
    const kinds = new Set(out.axisTicks.map((t) => t.kind));
    expect(kinds.has("quarter")).toBe(true);
  });

  test("long range (>730d) emits year ticks", () => {
    const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
    const kinds = new Set(out.axisTicks.map((t) => t.kind));
    expect(kinds.has("year")).toBe(true);
  });

  test("year-boundary ticks are flagged", () => {
    const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
    const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
    expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
  });

  test("all ticks fall inside the chart canvas horizontally", () => {
    const out = layout([], [], vp());
    for (const tick of out.axisTicks) {
      expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
      expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
    }
  });
});

describe("layout — undated counting", () => {
  test("undated marks tallied separately from inside-range count", () => {
    const events: TimelineEvent[] = [
      ev({ date: "2026-06-15" }),
      ev({ date: null }),
      ev({ date: null }),
      ev({ date: "2025-01-01" }), // out of range
    ];
    const out = layout(events, [], vp());
    expect(out.undatedCount).toBe(2);
    expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
  });
});
