/**
 * The LA studio's content, held to the bar the SF studio set.
 *
 * `mateo-court` was never the smaller pack — it is sixteen rooms and roughly
 * 250 props against four rooms and thirty. What it lacked was **fidelity per
 * square metre and authoring generation**, and that had four measurable
 * symptoms, every one of which is a check in this file:
 *
 *   1. Ninety-eight of its props were ceiling troffers, including an eight-by-
 *      three grid in a room declared `ceiling: null`.
 *   2. It bound **zero** props to seats, so ten hand-authored addresses had no
 *      furniture and an occupancy layer had nothing to dim.
 *   3. It placed **none** of the habitat kit past the kitchen: no sofa, no bed,
 *      no wardrobe, and — in a building whose every fitting was overhead — no
 *      floor lamp anywhere.
 *   4. Twelve of its sixteen rooms had no viewpoint at all, and a room nobody
 *      has framed is a room nobody has looked at since they authored it.
 *
 * None of the four fails a build, none of them throws, and all four look
 * completely fine in a screenshot of the one room somebody did frame. That is
 * what makes them worth asserting rather than remembering.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Plan, type PropPlacement } from "../../interiors/plan.ts";
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
import MATEO_COURT from "../../offices/mateo-court.ts";

const plan = new Plan(MATEO_COURT, { warn: false });
const props: PropPlacement[] = plan.levels.flatMap((level) => [...level.props]);

/** A light fitting, by id convention: `tera:light.*` is the whole family. */
function isFitting(prop: PropPlacement): boolean {
  return prop.kind.startsWith("tera:light.");
}

/**
 * Non-light props per square metre of authored floor, building-wide.
 *
 * Lights are excluded because they are the thing that was being counted instead
 * of furniture — a room can hit any density target with a ceiling grid and still
 * be empty. Fittings are measured separately, below, and capped.
 */
function densityOf(plan: Plan): { overall: number; byRoom: Map<string, number> } {
  let area = 0;
  let count = 0;
  const byRoom = new Map<string, number>();
  for (const level of plan.levels) {
    for (const room of level.rooms) {
      const inside = level.props.filter(
        (prop) =>
          !isFitting(prop) &&
          plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
      );
      area += room.area;
      count += inside.length;
      byRoom.set(room.id, inside.length / room.area);
    }
  }
  return { overall: count / area, byRoom };
}

describe("the LA studio binds its furniture to its addresses", () => {
  /**
   * A seat is an address and a chair is a prop, and the whole point of the
   * `seat` field is that something outside this repo can say "review-03" and
   * have it mean a chair. Ten seats were authored here by hand and not one of
   * them had a prop pointing at it.
   */
  it("gives every hand-authored seat at least one prop that names it", () => {
    const bound = new Set(props.map((prop) => prop.seat).filter((id) => id !== undefined));
    const authored = MATEO_COURT.levels
      .flatMap((level) => level.floorplan.seats ?? [])
      .map((seat) => seat.id);
    assert.ok(authored.length >= 10, "the pack stopped authoring seats by hand");
    assert.deepEqual(
      authored.filter((id) => !bound.has(id)),
      [],
      "seats with no furniture bound to them",
    );
  });

  it("keeps the bindings on the props that are actually at those places", () => {
    // Named pairs rather than a count, because the failure this catches is a
    // chair bound to the seat on the other side of the table — which is invisible
    // until an occupancy layer dims the wrong one.
    for (const [propId, seatId] of [
      ["front-chair", "front-01"],
      ["mess-stool-01", "commons-01"],
      ["mess-stool-02", "commons-02"],
      ["hewitt-chair-n-01", "review-01"],
      ["hewitt-chair-n-02", "review-02"],
      ["hewitt-chair-s-01", "review-03"],
      ["hewitt-chair-s-02", "review-04"],
      ["loggia-chair-01", "loggia-01"],
    ] as const) {
      const prop = plan.prop(propId);
      const seat = plan.seat(seatId);
      assert.ok(prop, `${propId} is gone`);
      assert.ok(seat, `${seatId} is gone`);
      assert.equal(prop.seat, seatId);
      // Bound and co-located: a binding that is right and a chair that is two
      // metres away is a different bug with the same symptom.
      assert.ok(
        Math.hypot(prop.position.x - seat.position.x, prop.position.z - seat.position.z) < 1.0,
        `${propId} is nowhere near ${seatId}`,
      );
    }
  });
});

describe("the LA studio is furnished, not merely lit", () => {
  /**
   * Mirrors the SF assertion in `office.test.ts`. Four of these seven assets
   * were written, tested and placed by nobody — the kit existed and the second
   * studio used none of it.
   */
  it("places all seven of the habitat kit", () => {
    const kinds = new Set(props.map((prop) => prop.kind));
    for (const id of [
      "tera:bed.platform",
      "tera:sofa.modular",
      "tera:kitchen.run",
      "tera:kitchen.island",
      "tera:storage.wardrobe",
      "tera:seat.stool",
      "tera:light.floor",
    ]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
  });

  /**
   * The troffer cap, and the reason it is a cap and not a target.
   *
   * `furnish.ts` batches per kind, so the ninety-eighth troffer buys nothing the
   * eye reads while costing exactly as much authoring attention as a piece of
   * furniture would. Two of the grids hung from rooms declared `ceiling: null`.
   * The baseline was 98; the cap is 55 and the pack currently sits at 48.
   */
  it("stops hanging a hundred troffers from ceilings that are not there", () => {
    const troffers = props.filter((prop) => prop.kind === "tera:light.troffer");
    assert.ok(
      troffers.length <= 55,
      `${troffers.length} troffers, which is more than the 55 this building is allowed`,
    );
    // And the fittings that remain are still fittings: every one is authored at
    // an elevation, because a troffer on the floor is a box in the middle of the
    // room. This is the check that stops the cap being met by moving them.
    for (const prop of troffers) {
      assert.ok(prop.position.y > 2.5, `${prop.id} is a ceiling fitting at y ${prop.position.y}`);
    }
  });

  /**
   * Soft light exists at all, which is the other half of the troffer argument. A
   * building lit exclusively from a ceiling grid reads as a rendering of an
   * office rather than as a place, and this pack had 108 fittings and not one of
   * them below head height.
   */
  it("lights something with a lamp somebody could turn off", () => {
    const lamps = props.filter((prop) => prop.kind === "tera:light.floor");
    assert.ok(lamps.length >= 5, `only ${lamps.length} floor lamps in sixteen rooms`);
    const rooms = new Set(
      lamps.map(
        (lamp) =>
          plan.roomAt(lamp.levelId, { x: lamp.position.x, z: lamp.position.z })?.id ?? "nowhere",
      ),
    );
    assert.ok(rooms.size >= 5, `every floor lamp is in one of ${rooms.size} rooms`);
    assert.equal(rooms.has("nowhere"), false, "a floor lamp stands outside every room");
  });

  /**
   * ### Density, and the target it is measured against
   *
   * The bar is the SF studio's own figure — 0.28 non-light props/m² over its
   * whole floor — and the gate is 0.26 building-wide with no room over 20 m²
   * below 0.15. This pack reached 0.288 with the studio kit; it was at 0.135
   * when the pass started and 0.149 before those twelve assets existed.
   *
   * Lights are excluded on purpose. A ceiling grid will satisfy any prop count
   * you like while leaving the floor bare, and this building's first version
   * proved it: ninety-eight of its props were troffers, two of the grids hung in
   * rooms declared `ceiling: null`, and it still looked empty from every
   * viewpoint. Fittings are capped separately, above.
   */
  it("carries at least as much furniture per square metre as the SF studio", () => {
    const { overall, byRoom } = densityOf(plan);
    assert.ok(overall >= 0.26, `density fell to ${overall.toFixed(3)} non-light props/m²`);
    for (const level of plan.levels) {
      for (const room of level.rooms) {
        if (room.area < 20) continue;
        const density = byRoom.get(room.id) ?? 0;
        assert.ok(
          density >= 0.15,
          `${room.id} is at ${density.toFixed(3)} props/m² over ${room.area.toFixed(0)} m²`,
        );
      }
    }
  });

  /** The bar this pack is being measured against, stated rather than assumed. */
  it("measures the SF studio the same way, so the target is a real number", () => {
    const sf = densityOf(new Plan(LUMBRIDGE_HQ, { warn: false }));
    assert.ok(sf.overall >= 0.26, `the SF studio itself fell to ${sf.overall.toFixed(3)}`);
    const { overall } = densityOf(plan);
    assert.ok(
      overall >= sf.overall,
      `LA is at ${overall.toFixed(3)} against SF's ${sf.overall.toFixed(3)}`,
    );
  });

  /**
   * ### The check that actually stops this pack going thin again
   *
   * `furnish.ts` batches props by **(asset, colorKey)** and draws `ctx.rand`
   * once per batch, so every instance of a kind is geometrically identical —
   * same seeded jitter, same books on the same shelf. Ten more shelves in a room
   * are one shelf drawn ten times. That makes the density figure above gameable
   * by exactly the move that would not change a single pixel a viewer resolves,
   * and it is why the courtyard could sit at thirteen props of six kinds and
   * read as a car park.
   *
   * So the real assertion is **distinct kinds per room**, and it is deliberately
   * a floor per room rather than a building-wide count: a pack can put thirty
   * kinds in reception and leave the yard bare, and the yard is the room every
   * viewpoint looks across.
   */
  it("furnishes its big rooms out of many kinds and not many copies", () => {
    const thin: string[] = [];
    for (const level of plan.levels) {
      for (const room of level.rooms) {
        if (room.area < 40) continue;
        const kinds = new Set(
          level.props
            .filter(
              (prop) =>
                !isFitting(prop) &&
                plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
            )
            .map((prop) => prop.kind),
        );
        // Seven is the number the *reference* pack's one big room manages, and
        // it is the floor rather than the aspiration: `works`, `loft` and `court`
        // are all well past it.
        if (kinds.size < 7) thin.push(`${room.id} (${kinds.size} kinds over ${room.area.toFixed(0)} m²)`);
      }
    }
    assert.deepEqual(thin, [], "big rooms furnished out of too few distinct assets");
  });

  /**
   * The studio kit is placed at all, mirroring the habitat assertion above.
   *
   * Twelve assets arrived in `src/assets/office/studio.ts` for this pack and no
   * other, and an asset nobody places is an asset nobody has looked at since it
   * was written — which is precisely the state four of the seven habitat assets
   * were found in.
   */
  it("places every one of the twelve studio assets", () => {
    const kinds = new Set(props.map((prop) => prop.kind));
    for (const id of [
      "tera:planter.trough",
      "tera:bench.slat",
      "tera:canopy.parasol",
      "tera:bench.lab",
      "tera:rack.equipment",
      "tera:cart.tool",
      "tera:dock.robot",
      "tera:case.stack",
      "tera:light.softbox",
      "tera:camera.tripod",
      "tera:acoustic.baffle",
      "tera:divider.slat",
    ]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
  });

  /**
   * A robot charge station stands on a dock, and not on bare floor.
   *
   * `operations/mateo-court.ts` parks a humanoid at `la-l1-dock` and
   * `la-l2-dock`. Before `tera:dock.robot` existed, the ground-floor one was a
   * point at (18.4, 13.2) — underneath the courtyard's long table. That was
   * invisible for as long as nothing was drawn there, which is the whole problem
   * with an address that names no furniture.
   */
  it("stands its charge stations on something", () => {
    const docks = props.filter((prop) => prop.kind === "tera:dock.robot");
    assert.ok(docks.length >= 4, `only ${docks.length} robot docks in a building with a robot`);
    for (const [levelId, x, z] of [
      ["level-1", 10.9, 16.9],
      ["level-2", 22.6, 1.5],
    ] as const) {
      const near = docks.filter(
        (dock) =>
          dock.levelId === levelId &&
          Math.hypot(dock.position.x - x, dock.position.z - z) < 1.2,
      );
      assert.ok(near.length >= 1, `the ${levelId} charge station stands on nothing`);
    }
  });
});

describe("the LA studio frames every room it has", () => {
  it("declares at least twelve viewpoints, arriving at the passage", () => {
    assert.ok(plan.viewpoints.length >= 12, `${plan.viewpoints.length} viewpoints`);
    // `viewpoints[0]` is the arrival pose *and* the walk spawn, so its identity
    // is load-bearing in two places at once.
    assert.equal(plan.viewpoints[0]?.id, "paseo");
  });

  it("puts a viewpoint inside every room over twenty square metres", () => {
    const unframed: string[] = [];
    for (const level of plan.levels) {
      for (const room of level.rooms) {
        if (room.area < 20) continue;
        const framed = plan.viewpoints.some(
          (view) =>
            view.levelId === level.id && plan.roomAt(level.id, view.focus.at)?.id === room.id,
        );
        if (!framed) unframed.push(`${room.id} (${room.area.toFixed(0)} m²)`);
      }
    }
    assert.deepEqual(unframed, [], "rooms with no viewpoint");
  });

  it("writes a description for each one that says something", () => {
    for (const view of plan.viewpoints) {
      // The length band is the existing six, which were written to sit on two
      // lines of the legend. A one-word description is a placeholder somebody
      // meant to come back to.
      assert.ok(view.description, `${view.id} has no description`);
      assert.ok(
        (view.description?.length ?? 0) >= 110 && (view.description?.length ?? 0) <= 200,
        `${view.id} description is ${view.description?.length} characters`,
      );
      assert.ok(view.shortLabel, `${view.id} has no short label`);
    }
    const numbers = plan.viewpoints.map((view) => view.number);
    assert.equal(new Set(numbers).size, numbers.length, "two viewpoints share a number");
  });
});

/**
 * Two sets of prop ids in this pack are addresses something outside it holds.
 *
 * Media screens are named by hosted screen grants (`server/src/media/bindings.ts`
 * treats every `tera:screen.*` prop as a shareable surface), and robot stations
 * are anchored to props by id in `operations/mateo-court.ts` — where a miss
 * throws rather than degrades. A content pass renumbers a `scatter` without
 * noticing; this is what notices.
 */
describe("the pinned ids survive a content pass", () => {
  it("keeps every media screen id a screen", () => {
    for (const id of [
      "front-monitor",
      "paseo-directory",
      "hewitt-display",
      "willow-display",
      "palmetto-display",
      "works-display",
      "loft-display",
    ]) {
      const prop = plan.prop(id);
      assert.ok(prop, `media screen ${id} is gone`);
      assert.match(prop.kind, /^tera:screen\./, `${id} is no longer a screen`);
    }
  });

  it("keeps every prop a robot station is anchored to", () => {
    for (const id of [
      "store-shelf-02",
      "works-locker-02",
      "loft-locker-02",
      "jesse-shelf-01",
      "jesse-board-02",
    ]) assert.ok(plan.prop(id), `robot station anchor ${id} is gone`);
  });
});

/**
 * The upper floor, and whether anybody can get to it.
 *
 * Level 2 of this pack — the Model Loft, the Model Bay, the Materials Room, two
 * desk banks, twenty-four props and six viewpoints — was authored and then
 * unreachable on foot for as long as it has existed, because the walk controller
 * refused any state on a storey other than the one it spawned on. `README.md`
 * carried a warning telling pack authors not to bother modelling a staircase.
 *
 * The engine change is tested in `walker.test.ts` and `officeWalker.test.ts`.
 * What is tested *here* is the thing those two cannot see: that this building's
 * way up actually resolves, and that both ends of it are places a walker can
 * reach on foot. A transition that resolves onto a landing nobody can walk to is
 * a staircase in a locked room, and every test in the engine would still pass.
 */
describe("mateo-court's way upstairs", () => {
  const stair = plan.transition("stair");

  it("resolves, with no problems and both storeys under it", () => {
    assert.deepEqual(plan.problems.filter((p) => p.where.startsWith("transitions")), []);
    assert.ok(stair, "the pack authors no way between its two storeys");
    assert.equal(stair.kind, "stair");
    assert.equal(stair.lower.levelId, "level-1");
    assert.equal(stair.upper.levelId, "level-2");
    // Floor to floor, which is the storey height plus the timber between.
    assert.ok(Math.abs(stair.rise - 5) < 1e-9, `${stair.rise}`);
  });

  it("is a dog-leg with a flat half landing, and the treads follow it", () => {
    assert.ok(stair);
    assert.equal(stair.path.length, 4);
    const [foot, turn, landing, head] = stair.path;
    assert.equal(foot!.y, 0);
    assert.equal(head!.y, 5);
    // The half landing is flat: two flights of equal rise with a level between.
    assert.equal(turn!.y, landing!.y);
    assert.ok(Math.abs(turn!.y - 2.5) < 1e-9, `${turn!.y}`);
    // The first flight runs east, the landing turns south, the second runs west
    // and arrives through the gap in the balustrade.
    assert.ok(turn!.x > foot!.x, "the first flight does not run east");
    assert.ok(landing!.z > turn!.z, "the half landing does not turn");
    assert.ok(head!.x < landing!.x, "the second flight does not come back west");
  });

  it("arrives through the gap in the balustrade and nowhere else", () => {
    assert.ok(stair);
    // The two rail segments leave 1.2 m of nothing between them at z 9.8-11.0 on
    // the yard's west line, and the upper footprint covers it. That is what stops
    // a walker who reaches the loggia strolling off a 5 m drop onto pavers.
    assert.ok(stair.upper.bounds.minZ > 9.8, `${stair.upper.bounds.minZ}`);
    assert.ok(stair.upper.bounds.maxZ < 11, `${stair.upper.bounds.maxZ}`);
    assert.ok(stair.upper.bounds.minX < 9.6 && stair.upper.bounds.maxX > 9.6);
    // And standing there is what triggers it, from either side.
    assert.ok(plan.transitionAt("level-2", stair.upper.landing));
    assert.ok(plan.transitionAt("level-1", stair.lower.landing));
    // The middle of the courtyard is not a way anywhere.
    assert.equal(plan.transitionAt("level-1", { x: 20, z: 13 }), null);
    assert.equal(plan.transitionAt("level-2", { x: 3, z: 3 }), null);
  });

  it("puts both landings somewhere a walker can actually stand", () => {
    assert.ok(stair);
    for (const end of [stair.lower, stair.upper]) {
      assert.equal(
        plan.blocked(end.levelId, end.landing, end.landing, 0.3),
        false,
        `${end.levelId} landing is inside a wall`,
      );
    }
  });

  /**
   * The check the engine cannot do for itself: a route.
   *
   * A flood fill on a 0.15 m grid with the walker's own radius, using the same
   * `Plan.blocked` the controller sweeps against. It is slow and it is worth it
   * — the failure it catches is a staircase behind a wall, which looks perfect
   * in every unit test and in every screenshot of the courtyard.
   */
  it("can be walked to from the arrival spawn, and leads to the Model Loft", () => {
    assert.ok(stair);
    const spawn = MATEO_COURT.viewpoints[0]!;
    assert.equal(spawn.levelId, "level-1", "the walk spawn moved off the ground floor");
    assert.ok(reachable("level-1", spawn.focus.at, stair.lower.landing), "the stair foot");
    // And from the head of it, the room this whole exercise was for.
    assert.ok(reachable("level-2", stair.upper.landing, { x: 13, z: 3 }), "the Model Loft");
    assert.ok(reachable("level-2", stair.upper.landing, { x: 3.6, z: 14.4 }), "the Materials Room");
  });
});

/** Flood fill over one storey at the default walker radius. */
function reachable(
  levelId: string,
  from: { x: number; z: number },
  to: { x: number; z: number },
): boolean {
  const level = plan.level(levelId);
  if (!level) return false;
  const step = 0.15;
  const radius = 0.3;
  const key = (x: number, z: number) => `${Math.round(x / step)},${Math.round(z / step)}`;
  const queue: { x: number; z: number }[] = [{ ...from }];
  const seen = new Set([key(from.x, from.z)]);
  let visited = 0;
  while (queue.length > 0) {
    const at = queue.shift()!;
    visited += 1;
    if (Math.hypot(at.x - to.x, at.z - to.z) < 0.35) return true;
    // A courtyard block is about 60,000 cells; this is a guard against a bug
    // here, not against the building.
    if (visited > 200_000) return false;
    for (const [dx, dz] of [[step, 0], [-step, 0], [0, step], [0, -step]] as const) {
      const next = { x: at.x + dx, z: at.z + dz };
      if (
        next.x < level.bounds.minX + radius || next.x > level.bounds.maxX - radius ||
        next.z < level.bounds.minZ + radius || next.z > level.bounds.maxZ - radius
      ) continue;
      const k = key(next.x, next.z);
      if (seen.has(k)) continue;
      if (plan.blocked(levelId, at, next, radius)) continue;
      seen.add(k);
      queue.push(next);
    }
  }
  return false;
}
