/**
 * Who is in the building, off disk.
 *
 * `TERA_PRESENCE_DIR` holds one `<officeId>.json` per office, in the same shape
 * and by the same rules as `offices/store.ts`: hand-written, no database, no
 * build step. The two are deliberately separate directories rather than one
 * document with a `people` field on it, and that separation is the whole design
 * rather than a filing preference.
 *
 * ### Why occupancy is not part of the office pack
 *
 * `src/interiors/types.ts` says a `Presence` binds to a `seatId` and never to a
 * coordinate, so that the office geometry can be published and the people cannot
 * be inferred from it. That rule buys nothing if the two live in the same file:
 * an operator who wants to make their floorplan public would have to strip the
 * roster out of it by hand every time, and the first time they forget, the leak
 * is permanent and public. Two directories means the safe thing is the default
 * thing — `TERA_OFFICES_DIR` can be world-readable, backed up and copied around,
 * and it still contains no people.
 *
 * It is also the honest shape for where the data comes from. A floorplan changes
 * when somebody moves a wall; a roster changes when somebody sits down. The pack
 * is a document and this is a reading, so the reading carries `observedAt` and
 * the pack does not.
 *
 * ### A box with no presence directory has no people
 *
 * That is the zero-config default and it is not an error: the reference office
 * renders perfectly with nobody in it, which is what `presence.ts` means when it
 * says the public building is "the same building, before anyone arrived". The
 * route above turns that into an empty list and a 200, never a 404 — see
 * `routes/presence.ts` for why the absence of a roster must not be reported the
 * same way as the absence of an office.
 */

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { PresenceBody } from "../../../src/server/wire.ts";
import type { Presence } from "../../../src/interiors/types.ts";

/**
 * Ids are the filename, so this is a path-traversal boundary and not a style
 * preference — the same pattern `offices/store.ts` uses, and it has to be the
 * same one, because the two directories are addressed by the same id and a
 * pattern that accepted more here would be the weaker of the two.
 */
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;

/**
 * A ceiling on how many people one office may report.
 *
 * Not a policy about office size — it is a bound on what a bad file can do to a
 * browser. Every presence is a mesh and a draw call, so a roster with fifty
 * thousand rows in it, whether by mistake or otherwise, is a tab that stops
 * responding. Dropping the tail is visible and recoverable; the alternative is
 * not.
 */
const MAX_PEOPLE = 2000;

export interface PresenceStore {
  /** Never throws and never 404s. An office with no roster has an empty one. */
  get(officeId: string): Promise<PresenceBody>;
}

export function createPresenceStore(dir: string): PresenceStore {
  return {
    async get(officeId: string): Promise<PresenceBody> {
      const empty: PresenceBody = { officeId, people: [] };
      if (dir === "" || !ID_PATTERN.test(officeId)) return empty;
      try {
        const parsed: unknown = JSON.parse(
          await readFile(join(dir, `${officeId}.json`), "utf8"),
        );
        return normalise(parsed, officeId) ?? empty;
      } catch {
        // Missing, unreadable and unparseable are the same answer: nobody is
        // recorded as being here. Distinguishing them out loud would leak the
        // directory listing one status code at a time, which is the reasoning
        // `offices/store.ts` already committed to for the same directory shape.
        return empty;
      }
    },
  };
}

function normalise(parsed: unknown, officeId: string): PresenceBody | null {
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
  const doc = parsed as { people?: unknown; observedAt?: unknown };
  if (!Array.isArray(doc.people)) return null;

  const people: Presence[] = [];
  for (const row of doc.people) {
    const person = normalisePerson(row);
    // A row that cannot be placed is dropped rather than repaired, and there is
    // no id or seat invented for it. `presence.ts` drops an unplaceable presence
    // for the same reason at the other end of the wire: putting somebody at a
    // made-up desk turns a private id into a public coordinate, which is the one
    // thing this whole split exists to stop.
    if (person !== null) people.push(person);
    if (people.length >= MAX_PEOPLE) break;
  }

  return {
    officeId,
    people,
    ...(typeof doc.observedAt === "string" ? { observedAt: doc.observedAt } : {}),
  };
}

function normalisePerson(row: unknown): Presence | null {
  if (row === null || typeof row !== "object" || Array.isArray(row)) return null;
  const p = row as Partial<Presence>;
  if (typeof p.seatId !== "string" || p.seatId === "") return null;

  // The id defaults to the seat, because a roster where one desk is listed twice
  // is a mistake in the file and not something to render twice; `presence.ts`
  // keys its meshes by presence id, so two rows sharing one would be one person.
  // The label defaults to nothing rather than to the seat id: a hover card
  // reading "eng-04" is the desk answering a question about the person.
  return {
    id: typeof p.id === "string" && p.id !== "" ? p.id : p.seatId,
    seatId: p.seatId,
    label: typeof p.label === "string" ? p.label : "",
    colorKey: typeof p.colorKey === "string" ? p.colorKey : "in",
    ...(typeof p.url === "string" ? { url: p.url } : {}),
    ...(typeof p.blurb === "string" ? { blurb: p.blurb } : {}),
  };
}
