/**
 * Every shipped pack still resolves, and still resolves to the same thing over
 * the wire as it does in the bundle.
 *
 * This is the file that stands between "deprioritised" and "broken". Two of the
 * three packs in this repo are not being invested in — `frontier-valley` is
 * published as `building` and `lumbridge-hq` is finished — and the LA content
 * pass edits shared helpers, shared constants and the schema all three are
 * authored against. A pack nothing asserts on is a pack that stops resolving
 * the first time somebody changes a helper two directories away, and the
 * failure is silent: `Plan` never throws, it drops the offending item and
 * records a line in `problems` that nothing reads.
 *
 * The JSON round trip is here for the same reason and caught a real defect.
 * CONTRACT.md §2 says a pack hand-written as a `.ts` module and a pack arriving
 * as a `.json` body over HTTP have to be **literally the same thing**. Both
 * `mateo-court.ts` and `frontier-valley.ts` had a `scatter()` helper assigning
 * `elevation: opts.elevation` unconditionally, which put 283 and 106
 * undefined-valued keys into their exported values respectively. `JSON.stringify`
 * drops those keys, so the served pack was a different object from the bundled
 * one — invisible in every renderer, and exactly the sort of thing that turns
 * into a two-day bug the first time a self-hoster round-trips a pack through the
 * office API and finds their props have moved.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Plan } from "../../interiors/plan.ts";
import type { Office } from "../../interiors/types.ts";
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
import FRONTIER_VALLEY from "../../offices/frontier-valley.ts";
import MATEO_COURT from "../../offices/mateo-court.ts";
import { OFFICE_SITES } from "../../offices/sites.ts";

const PACKS: readonly Office[] = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];

/** Every path in `value` whose key is present and whose value is `undefined`. */
function undefinedKeys(value: unknown, path: string, out: string[]): void {
  if (Array.isArray(value)) {
    value.forEach((item, i) => undefinedKeys(item, `${path}[${i}]`, out));
    return;
  }
  if (value === null || typeof value !== "object") return;
  for (const [key, entry] of Object.entries(value)) {
    if (entry === undefined) out.push(`${path}.${key}`);
    else undefinedKeys(entry, `${path}.${key}`, out);
  }
}

describe("every shipped pack resolves", () => {
  for (const pack of PACKS) {
    /**
     * At **both** depths, because they are different builds and only one of them
     * is the one a visitor gets. `PlanOptions.depth` skips every item marked
     * `audience: "private"` before it is resolved, so a pack can be clean at
     * `"full"` and drop something at `"public"` — a device standing on a private
     * prop is the case this build introduced.
     */
    for (const depth of ["full", "public"] as const) {
      it(`${pack.id} at ${depth} depth reports no problems`, () => {
        const plan = new Plan(pack, { depth, warn: false });
        assert.deepEqual(
          plan.problems.map((p) => `${p.where}: ${p.message} (${p.action})`),
          [],
        );
      });
    }

    it(`${pack.id} survives a JSON round trip unchanged`, () => {
      // Strict deep equality, which treats `{ a: undefined }` and `{}` as
      // different objects. That is the whole point: they *are* different
      // objects, and only one of them survives `JSON.stringify`.
      assert.deepEqual(JSON.parse(JSON.stringify(pack)), pack);
      const stray: string[] = [];
      undefinedKeys(pack, pack.id, stray);
      assert.deepEqual(stray, [], `${pack.id} carries undefined-valued keys`);
    });

    /**
     * `main.ts` spawns the walker at `viewpoints[0].focus.at` — that is not a
     * camera target in this one case, it is a coordinate a person stands on. A
     * pack whose first viewpoint frames a shot from outside the building looks
     * fine in the legend and spawns the visitor inside a wall.
     */
    it(`${pack.id} arrives somewhere a walker can stand`, () => {
      const plan = new Plan(pack, { warn: false });
      const arrival = plan.arrival();
      assert.ok(arrival, `${pack.id} declares no viewpoints`);
      const room = plan.roomAt(arrival.levelId, arrival.focus.at);
      assert.ok(room, `${pack.id} arrives at ${JSON.stringify(arrival.focus.at)}, which is in no room`);
      for (const [dx, dz] of [[0.4, 0], [-0.4, 0], [0, 0.4], [0, -0.4]] as const) {
        const step = { x: arrival.focus.at.x + dx, z: arrival.focus.at.z + dz };
        if (!plan.blocked(arrival.levelId, arrival.focus.at, step, 0.3)) return;
      }
      assert.fail(`${pack.id} arrives in a spot with no clear step in any direction`);
    });

    it(`${pack.id} keeps every viewpoint on a level that exists`, () => {
      const plan = new Plan(pack, { warn: false });
      const levels = new Set(plan.levels.map((level) => level.id));
      // `Plan` drops a viewpoint whose level does not resolve, so a count that
      // matches the authored one is the assertion that none were dropped.
      assert.equal(plan.viewpoints.length, pack.viewpoints.length);
      for (const view of plan.viewpoints) {
        assert.ok(levels.has(view.levelId), `${pack.id}/${view.id} is on nothing`);
      }
    });
  }
});

/**
 * The in-development pack stays in development.
 *
 * `frontier-valley` is deprioritised, which is a decision about investment and
 * not about correctness: it must keep resolving, and it must keep telling a
 * visitor the truth about itself. A content pass that quietly promoted it to
 * `active` would put a half-furnished hangar in the same sentence as two
 * finished studios.
 */
describe("the published runtime status", () => {
  it("still calls Frontier Valley a building site and the two studios active", () => {
    assert.deepEqual(
      OFFICE_SITES.map(({ id, status }) => `${id}:${status}`),
      ["lumbridge-hq:active", "frontier-valley:building", "mateo-court:active"],
    );
  });
});
