/**
 * `Plan`: the wall split, and everything it refuses to throw on.
 *
 * This file exists because `plan.ts` was built to be tested and never was. Two
 * of its own design notes are promises to a test that did not exist:
 *
 *   - "**`Plan` imports no three.js**. That keeps the splitting pass testable
 *     without a WebGL context" — so this runs under a plain `node --test`, with
 *     no DOM, no canvas and no bundler.
 *   - "It drops rather than throws … Every one of those is reported through
 *     `problems`" — an array whose entire purpose is to be asserted on, and
 *     which nothing asserted on.
 *
 * The second is the more valuable half. A self-hoster authoring their first
 * office will write a polygon that crosses itself, a door that runs off the end
 * of its wall, and two things with the same id, and the contract says they get a
 * building anyway plus a list of what was wrong with theirs. Everything below is
 * that promise, one clause at a time.
 *
 * The wall pass gets its own section because CONTRACT.md §2 chose an explicit
 * wall list with 1-D openings over walls inferred from room edges on the grounds
 * that one decomposition yields both the drawing and the collider — "two
 * products, one pass, no second list to keep in sync". That is a claim about
 * geometry that can be checked, so it is.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Plan } from "../interiors/plan.ts";
import { DEFAULT_WALKER_RADIUS } from "../interiors/walker.ts";
import type { Level, Office, Room, Transition, Wall } from "../interiors/types.ts";

/** Silent: these packs are wrong on purpose and the warnings are the point, not noise. */
const QUIET = { warn: false } as const;

const SQUARE: Room["outline"] = [
  { x: 0, z: 0 },
  { x: 10, z: 0 },
  { x: 10, z: 6 },
  { x: 0, z: 6 },
];

function level(over: Partial<Level> = {}): Level {
  return {
    id: "level-1",
    name: "Level 1",
    elevation: 0,
    wallHeight: 2.8,
    wallThickness: 0.1,
    floorplan: { rooms: [], walls: [] },
    ...over,
  };
}

function office(levels: Level[]): Office {
  return { id: "test", name: "Test", levels, viewpoints: [] };
}

/** One level, one room, and whatever walls the case is about. */
function withWalls(walls: Wall[], rooms: Room[] = [{ id: "r", name: "Room", outline: SQUARE, floor: "floor" as never }]) {
  return new Plan(office([level({ floorplan: { rooms, walls } })]), QUIET);
}

describe("it drops rather than throws", () => {
  it("survives a pack that is wrong in five ways at once and still builds", () => {
    const plan = withWalls(
      [
        { id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } },
        { id: "w", from: { x: 0, z: 6 }, to: { x: 10, z: 6 } },
        { id: "nowhere", from: { x: 3, z: 3 }, to: { x: 3, z: 3 } },
      ],
      [
        { id: "r", name: "Room", outline: SQUARE, floor: "floor" as never },
        { id: "r", name: "Twin", outline: SQUARE, floor: "floor" as never },
      ],
    );
    // The good half is standing.
    assert.equal(plan.levels.length, 1);
    assert.equal(plan.levels[0]?.rooms.length, 1);
    // And every casualty is on the record rather than only in the console.
    assert.ok(plan.problems.length >= 3, JSON.stringify(plan.problems));
    assert.ok(plan.problems.every((p) => p.where !== "" && p.message !== ""));
  });

  it("drops a duplicate id and keeps the first, per kind", () => {
    const plan = withWalls([
      { id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } },
      { id: "w", from: { x: 0, z: 6 }, to: { x: 10, z: 6 } },
    ]);
    assert.deepEqual([...new Set(plan.levels[0]?.runs.map((r) => r.wallId))], ["w"]);
    assert.ok(plan.problems.some((p) => /duplicate/i.test(p.message)));
  });

  it("drops a zero-length wall instead of dividing by it", () => {
    const plan = withWalls([{ id: "nowhere", from: { x: 3, z: 3 }, to: { x: 3, z: 3 } }]);
    assert.equal(plan.levels[0]?.runs.length, 0);
    assert.ok(plan.problems.some((p) => /zero length/i.test(p.message)));
    // The real assertion: nothing is NaN downstream of it.
    assert.ok(Number.isFinite(plan.bounds.width));
  });

  it("keeps an empty office finite, so nothing downstream divides by a bound", () => {
    const plan = new Plan(office([]), QUIET);
    assert.equal(plan.levels.length, 0);
    for (const n of [plan.bounds.width, plan.bounds.depth, plan.bounds.center.x]) {
      assert.ok(Number.isFinite(n), `${n}`);
    }
  });

  it("takes a pack with the required arrays missing, because HTTP will send one", () => {
    // `levels` and `viewpoints` are required by the type, and a pack arriving as
    // JSON has been through no type checker at all.
    const plan = new Plan({ id: "x", name: "X" } as unknown as Office, QUIET);
    assert.equal(plan.levels.length, 0);
    assert.equal(plan.viewpoints.length, 0);
  });
});

describe("the wall pass", () => {
  /** A 10 m wall with a 1 m door starting 4 m along. */
  const withDoor: Wall = {
    id: "w",
    from: { x: 0, z: 0 },
    to: { x: 10, z: 0 },
    openings: [{ kind: "door", start: 4, width: 1, sill: 0, head: 2.1 }],
  };

  it("splits a wall around its opening and leaves the hole empty", () => {
    const plan = withWalls([withDoor]);
    const solid = plan.levels[0]?.runs.filter((r) => r.role === "solid") ?? [];
    assert.equal(solid.length, 2);
    // 0..4 and 5..10, so the drawn length is the wall minus the door exactly.
    const drawn = solid.reduce((sum, r) => sum + r.length, 0);
    assert.ok(Math.abs(drawn - 9) < 1e-6, `${drawn}`);
    assert.equal(plan.levels[0]?.openings.length, 1);
  });

  it("draws a lintel over a door and does not collide with it", () => {
    const plan = withWalls([withDoor]);
    const runs = plan.levels[0]?.runs ?? [];
    assert.ok(runs.some((r) => r.role === "lintel"), "no lintel over the door");
    // The claim CONTRACT.md §2 makes: one pass, two products. The collider has
    // a gap exactly where the door is, so a walker crosses the centreline there
    // and nowhere else along this wall.
    const level1 = plan.levels[0]?.id ?? "";
    const through = { x: 4.5, z: -0.5 };
    const inside = { x: 4.5, z: 0.5 };
    assert.equal(plan.blocked(level1, through, inside, 0.05), false, "the door is shut");
    const wall = { x: 1, z: -0.5 };
    const wallInside = { x: 1, z: 0.5 };
    assert.equal(plan.blocked(level1, wall, wallInside, 0.05), true, "the wall is open");
  });

  it("draws a window's apron and still collides with it", () => {
    // The apron under a window is drawn and *is* collided with — a person cannot
    // walk through a window, and that is the distinction the roles carry.
    const plan = withWalls([
      {
        id: "w",
        from: { x: 0, z: 0 },
        to: { x: 10, z: 0 },
        openings: [{ kind: "window", start: 4, width: 2, sill: 0.9, head: 2.2 }],
      },
    ]);
    const runs = plan.levels[0]?.runs ?? [];
    assert.ok(runs.some((r) => r.role === "apron"), "no apron under the window");
    assert.equal(
      plan.blocked(plan.levels[0]?.id ?? "", { x: 5, z: -0.5 }, { x: 5, z: 0.5 }, 0.05),
      true,
      "walked through a window",
    );
  });

  it("merges adjacent blocking stretches, so a run of windows is not eleven segments", () => {
    const plan = withWalls([
      {
        id: "w",
        from: { x: 0, z: 0 },
        to: { x: 10, z: 0 },
        openings: [
          { kind: "window", start: 1, width: 1, sill: 0.9, head: 2.2 },
          { kind: "window", start: 3, width: 1, sill: 0.9, head: 2.2 },
          { kind: "window", start: 5, width: 1, sill: 0.9, head: 2.2 },
        ],
      },
    ]);
    // Every one of them is passable-at-no-height, so the whole wall is one
    // uninterrupted collision segment rather than four.
    assert.equal(plan.collisionAt(plan.levels[0]?.id ?? "").length, 1);
  });

  it("refuses an opening that runs off the end of its wall", () => {
    const plan = withWalls([
      {
        id: "w",
        from: { x: 0, z: 0 },
        to: { x: 10, z: 0 },
        openings: [{ kind: "door", start: 9.5, width: 3, sill: 0, head: 2.1 }],
      },
    ]);
    assert.ok(plan.problems.length > 0, "an overhanging door was accepted silently");
    // Whatever it decided, the wall is still a wall and nothing escaped it.
    for (const run of plan.levels[0]?.runs ?? []) {
      assert.ok(run.start >= -1e-6 && run.end <= 10 + 1e-6, `${run.start}..${run.end}`);
    }
  });

  it("puts a run's centre where its own yaw says it is", () => {
    // The invariant every consumer depends on and nothing stated: a run lies
    // along its local +X, which for yaw φ points at (cos φ, -sin φ). Get the
    // sign wrong and every wall in the building mirrors about its own centre —
    // invisible on a square, obvious on anything else. `officeMinimap.ts` draws
    // straight from these numbers.
    const plan = withWalls([{ id: "w", from: { x: 0, z: 0 }, to: { x: 0, z: 8 } }]);
    const run = plan.levels[0]?.runs[0];
    assert.ok(run, "no run");
    assert.ok(Math.abs(run.center.x - 0) < 1e-6);
    assert.ok(Math.abs(run.center.z - 4) < 1e-6);
    const endX = run.center.x + Math.cos(run.yaw) * (run.length / 2);
    const endZ = run.center.z - Math.sin(run.yaw) * (run.length / 2);
    assert.ok(Math.abs(endX - 0) < 1e-6, `${endX}`);
    assert.ok(Math.abs(endZ - 8) < 1e-6, `${endZ}`);
  });
});

describe("depth is a resolution pass, not a visibility flag", () => {
  const mixed = () =>
    office([
      level({
        floorplan: {
          rooms: [{ id: "r", name: "Room", outline: SQUARE, floor: "floor" as never }],
          walls: [{ id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } }],
          seats: [
            { id: "open", position: { x: 2, z: 2 }, facing: 0, pose: "sit" },
            { id: "secret", position: { x: 4, z: 2 }, facing: 0, pose: "sit", audience: "private" },
          ],
        },
      }),
    ]);

  it("leaves a private item out of the build product entirely at public depth", () => {
    const pub = new Plan(mixed(), { ...QUIET, depth: "public" });
    // Not hidden — absent. A private item that is built and then hidden is still
    // in `scene.traverse`, in the devtools graph and in a `JSON.stringify` of
    // this object, which is a data leak with a checkbox in front of it.
    assert.equal(pub.seat("secret"), null);
    assert.deepEqual(
      pub.allSeats().map((s) => s.id),
      ["open"],
    );
    assert.equal(JSON.stringify(pub.levels).includes("secret"), false, "leaked into the tree");
  });

  it("keeps it at full depth, and says which build it is", () => {
    const full = new Plan(mixed(), { ...QUIET, depth: "full" });
    assert.equal(full.depth, "full");
    assert.ok(full.seat("secret"));
    // Absence is not reported as a problem: the pack is not wrong, it is being
    // read at a depth that does not include the item.
    assert.equal(new Plan(mixed(), { ...QUIET, depth: "public" }).problems.length, 0);
  });
});

describe("queries", () => {
  it("lets a later room win, because that is the order a pack lays them down", () => {
    const plan = new Plan(
      office([
        level({
          floorplan: {
            rooms: [
              { id: "floor", name: "Open floor", outline: SQUARE, floor: "floor" as never },
              {
                id: "meeting",
                name: "Meeting",
                outline: [
                  { x: 1, z: 1 },
                  { x: 4, z: 1 },
                  { x: 4, z: 4 },
                  { x: 1, z: 4 },
                ],
                floor: "floor" as never,
              },
            ],
            walls: [],
          },
        }),
      ]),
      QUIET,
    );
    assert.equal(plan.roomAt("level-1", { x: 2, z: 2 })?.id, "meeting");
    assert.equal(plan.roomAt("level-1", { x: 8, z: 5 })?.id, "floor");
    assert.equal(plan.roomAt("level-1", { x: 50, z: 50 }), null);
    assert.equal(plan.roomAt("no-such-level", { x: 2, z: 2 }), null);
  });

  it("adds the level elevation exactly once, so a mezzanine is not in the basement", () => {
    const plan = new Plan(
      office([
        level({ id: "l1", elevation: 0, floorplan: { rooms: [], walls: [{ id: "a", from: { x: 0, z: 0 }, to: { x: 4, z: 0 } }] } }),
        level({ id: "l2", elevation: 4.2, floorplan: { rooms: [], walls: [{ id: "b", from: { x: 0, z: 0 }, to: { x: 4, z: 0 } }] } }),
      ]),
      QUIET,
    );
    assert.equal(plan.level("l1")?.floorY, 0);
    assert.equal(plan.level("l2")?.floorY, 4.2);
    assert.equal(plan.level("l1")?.runs[0]?.bottom, 0);
    assert.equal(plan.level("l2")?.runs[0]?.bottom, 4.2);
  });
});

// ---- Transitions -----------------------------------------------------------

/**
 * A two-storey pack whose transition is whatever the case is about.
 *
 * Both levels are the same 10 x 6 room so that a footprint failing on one and
 * passing on the other is always the *transition's* doing rather than the
 * building's — except in the one case that deliberately shrinks the upper floor.
 */
function twoStorey(
  transitions: Transition[],
  upperRoom: Room["outline"] = SQUARE,
  lowerWalls: Wall[] = [],
): Plan {
  const lower = level({
    id: "level-1",
    floorplan: { rooms: [{ id: "l1", name: "Ground", outline: SQUARE, floor: "floor" as never }], walls: lowerWalls },
  });
  const upper = level({
    id: "level-2",
    name: "Level 2",
    elevation: 4,
    floorplan: { rooms: [{ id: "l2", name: "Upper", outline: upperRoom, floor: "floor" as never }], walls: [] },
  });
  return new Plan(
    { id: "test", name: "Test", levels: [lower, upper], viewpoints: [], transitions },
    QUIET,
  );
}

/** A 2 x 2 patch of floor with its own corner at (x, z). */
function patch(x: number, z: number, size = 2): Room["outline"] {
  return [
    { x, z },
    { x: x + size, z },
    { x: x + size, z: z + size },
    { x, z: z + size },
  ];
}

const STAIR: Transition = {
  id: "stair",
  kind: "stair",
  lower: { levelId: "level-1", footprint: patch(1, 1), landing: { x: 2, z: 2 } },
  upper: { levelId: "level-2", footprint: patch(6, 1), landing: { x: 7, z: 2 } },
};

describe("Plan transitions", () => {
  it("resolves both ends, adds each level's elevation once, and answers transitionAt", () => {
    const plan = twoStorey([STAIR]);
    assert.deepEqual(plan.problems, []);
    assert.equal(plan.transitions.length, 1);

    const resolved = plan.transition("stair")!;
    assert.equal(resolved.rise, 4);
    assert.equal(resolved.lower.floorY, 0);
    assert.equal(resolved.upper.floorY, 4);
    assert.equal(resolved.width, 1.2);

    // Absent legs mean one straight flight, foot to head, with the office-world
    // height at both ends.
    assert.deepEqual(resolved.path, [
      { x: 2, y: 0, z: 2 },
      { x: 7, y: 4, z: 2 },
    ]);

    const up = plan.transitionAt("level-1", { x: 2, z: 2 })!;
    assert.equal(up.ascending, true);
    assert.equal(up.to.levelId, "level-2");
    assert.deepEqual(up.to.landing, { x: 7, z: 2 });

    const down = plan.transitionAt("level-2", { x: 7, z: 2 })!;
    assert.equal(down.ascending, false);
    assert.equal(down.to.levelId, "level-1");

    // The other end's footprint is not a way up on the level you are standing on.
    assert.equal(plan.transitionAt("level-1", { x: 7, z: 2 }), null);
    assert.equal(plan.transitionAt("level-2", { x: 2, z: 2 }), null);
    assert.equal(plan.transitionAt("level-1", { x: 9, z: 5 }), null);
  });

  it("normalises leg rise shares and gives every point a height", () => {
    const plan = twoStorey([{
      ...STAIR,
      // A dog-leg whose author's shares do not add up, which is the ordinary
      // case: two flights and a flat landing between them.
      legs: [
        { to: { x: 5, z: 2 }, rise: 2 },
        { to: { x: 5, z: 4 }, rise: 0 },
        { to: { x: 7, z: 2 }, rise: 2 },
      ],
    }]);
    assert.deepEqual(plan.problems, []);
    const path = plan.transition("stair")!.path;
    assert.equal(path.length, 4);
    assert.deepEqual(path[0], { x: 2, y: 0, z: 2 });
    assert.deepEqual(path[1], { x: 5, y: 2, z: 2 });
    // The landing is flat: same height as the foot of the second flight.
    assert.deepEqual(path[2], { x: 5, y: 2, z: 4 });
    assert.deepEqual(path[3], { x: 7, y: 4, z: 2 });
  });

  it("drops a transition WHOLE when only one of its two ends resolves", () => {
    // The upper floor is a 4 x 6 strip in the west; the upper footprint at x = 6
    // is off the end of it. The lower end is impeccable and must not survive.
    const plan = twoStorey([STAIR], [
      { x: 0, z: 0 },
      { x: 4, z: 0 },
      { x: 4, z: 6 },
      { x: 0, z: 6 },
    ]);
    assert.equal(plan.transitions.length, 0);
    assert.equal(plan.transition("stair"), null);
    assert.equal(plan.transitionAt("level-1", { x: 2, z: 2 }), null);
    assert.equal(plan.transitionAt("level-2", { x: 7, z: 2 }), null);

    const problem = plan.problems.find((p) => p.where === "transitions[0].upper");
    assert.ok(problem, JSON.stringify(plan.problems));
    assert.equal(problem.action, "dropped");
    assert.match(problem.message, /footprint outside level "level-2"/);
  });

  it("refuses a footprint with a wall running through it", () => {
    const plan = twoStorey([STAIR], SQUARE, [
      { id: "divider", from: { x: 2, z: 0 }, to: { x: 2, z: 6 } },
    ]);
    assert.equal(plan.transitions.length, 0);
    const problem = plan.problems.find((p) => p.where === "transitions[0].lower");
    assert.ok(problem, JSON.stringify(plan.problems));
    assert.match(problem.message, /wall "divider" running through its footprint/);
  });

  it("accepts a footprint that merely runs along a wall", () => {
    // Stairs go against walls. A wall on the footprint's own edge is the
    // ordinary case and must not be refused, or no stair is authorable.
    const plan = twoStorey([STAIR], SQUARE, [
      { id: "edge", from: { x: 1, z: 0 }, to: { x: 1, z: 6 } },
    ]);
    assert.deepEqual(plan.problems, []);
    assert.equal(plan.transitions.length, 1);
  });

  it("refuses a landing outside its own footprint, or inside a wall", () => {
    const outside = twoStorey([{
      ...STAIR,
      lower: { levelId: "level-1", footprint: patch(1, 1), landing: { x: 8, z: 5 } },
    }]);
    assert.equal(outside.transitions.length, 0);
    assert.match(
      outside.problems.find((p) => p.where === "transitions[0].lower")!.message,
      /landing outside its own footprint/,
    );

    // A wall along the footprint's edge is fine; a landing pressed against it is
    // not, because that is a place nobody can stand.
    const buried = twoStorey([{
      ...STAIR,
      lower: { levelId: "level-1", footprint: patch(1, 1), landing: { x: 1.05, z: 2 } },
    }], SQUARE, [{ id: "edge", from: { x: 1, z: 0 }, to: { x: 1, z: 6 } }]);
    assert.equal(buried.transitions.length, 0);
    assert.match(
      buried.problems.find((p) => p.where === "transitions[0].lower")!.message,
      /a walker cannot stand on/,
    );
  });

  it("refuses a transition that does not go up, names a level twice, or repeats an id", () => {
    const flat = twoStorey([{
      ...STAIR,
      upper: { levelId: "level-1", footprint: patch(6, 1), landing: { x: 7, z: 2 } },
    }]);
    assert.equal(flat.transitions.length, 0);
    assert.match(flat.problems[0]!.message, /joins level "level-1" to itself/);

    const backwards = twoStorey([{
      ...STAIR,
      lower: { levelId: "level-2", footprint: patch(6, 1), landing: { x: 7, z: 2 } },
      upper: { levelId: "level-1", footprint: patch(1, 1), landing: { x: 2, z: 2 } },
    }]);
    assert.equal(backwards.transitions.length, 0);
    assert.match(backwards.problems[0]!.message, /does not rise/);

    const twice = twoStorey([STAIR, { ...STAIR }]);
    assert.equal(twice.transitions.length, 1);
    assert.match(twice.problems[0]!.message, /duplicate transition id "stair"/);

    const unknown = twoStorey([{ ...STAIR, kind: "escalator" as never }]);
    assert.equal(unknown.transitions.length, 0);
    assert.match(unknown.problems[0]!.message, /unknown kind "escalator"/);
  });

  it("skips a private transition at public depth without calling it a problem", () => {
    const pack: Office = {
      id: "test",
      name: "Test",
      levels: [
        level({ id: "level-1", floorplan: { rooms: [{ id: "l1", name: "Ground", outline: SQUARE, floor: "floor" as never }], walls: [] } }),
        level({ id: "level-2", name: "Level 2", elevation: 4, floorplan: { rooms: [{ id: "l2", name: "Upper", outline: SQUARE, floor: "floor" as never }], walls: [] } }),
      ],
      viewpoints: [],
      transitions: [{ ...STAIR, audience: "private" }],
    };
    const publicBuild = new Plan(pack, { ...QUIET, depth: "public" });
    assert.equal(publicBuild.transitions.length, 0);
    assert.deepEqual(publicBuild.problems, []);
    assert.equal(new Plan(pack, QUIET).transitions.length, 1);
  });

  it("checks a landing against the same radius the walker uses", () => {
    // `plan.ts` restates `DEFAULT_WALKER_RADIUS` rather than importing it, so
    // that `walker.ts` stays this file's dependent and not the other way round.
    // A restated constant is one that drifts, so it is pinned here.
    const buried = twoStorey([{
      ...STAIR,
      lower: {
        levelId: "level-1",
        footprint: patch(1, 1),
        // Exactly `DEFAULT_WALKER_RADIUS + thickness / 2` from the wall would be
        // clear; a hair inside it is not, and this asserts the boundary is that
        // number rather than some other one.
        landing: { x: 1 + DEFAULT_WALKER_RADIUS + 0.05 - 0.01, z: 2 },
      },
    }], SQUARE, [{ id: "edge", from: { x: 1, z: 0 }, to: { x: 1, z: 6 }, thickness: 0.1 }]);
    assert.equal(buried.transitions.length, 0);

    const clear = twoStorey([{
      ...STAIR,
      lower: {
        levelId: "level-1",
        footprint: patch(1, 1),
        landing: { x: 1 + DEFAULT_WALKER_RADIUS + 0.05 + 0.01, z: 2 },
      },
    }], SQUARE, [{ id: "edge", from: { x: 1, z: 0 }, to: { x: 1, z: 6 }, thickness: 0.1 }]);
    assert.equal(clear.transitions.length, 1);
  });

  it("has no transitions when a pack authors none", () => {
    assert.deepEqual(withWalls([]).transitions, []);
    assert.equal(withWalls([]).transitionAt("level-1", { x: 1, z: 1 }), null);
  });
});
