/**
 * The marker snapshot: read from disk, put through the gate, cached.
 *
 * The API serves a file. It does not hold a Workie credential, does not have a
 * database, and does not proxy anything — the sync oneshot writes the snapshot
 * and this reads it, which is why a compromise of the public box yields a file
 * that was already public.
 *
 * Private per-user markers never appear here at all. An authenticated browser
 * calls Workie directly with its own token, so private rows never transit this
 * process. CONTRACT.md §5.
 *
 * ### What the file looks like
 *
 * ```json
 * { "generatedAt": "2026-08-05T09:00:00Z",
 *   "markers": [
 *     { "id": "ferry-building", "label": "Ferry Building", "colorKey": "sector.civic",
 *       "lat": 37.7955, "lng": -122.3937, "provenance": "hand-placed" }
 *   ] }
 * ```
 *
 * A bare array is accepted too, because it is the obvious thing to hand-write
 * and a self-hoster's first marker file should not need a wrapper object. Every
 * row goes through `gate.ts`, which refuses — row by row, never repairing —
 * anything carrying a field nobody reviewed and anything whose `provenance` is
 * not on the allowlist. The counts come back on the wire in `refused`, because a
 * silent drop looks exactly like an empty database.
 *
 * ### Reloading without a restart
 *
 * `TERA_MARKERS_TTL` is the reload interval, not just a cache lifetime: once it
 * expires, the next request `stat`s the file and re-reads it only if the mtime
 * or the size moved. That is what makes a short TTL affordable — five seconds on
 * an unchanged file costs one `stat` every five seconds — so `npm run sync`
 * followed by its atomic rename is visible on the map within seconds, with
 * nothing restarted and nothing signalled.
 *
 * The other half is that **a read that fails keeps the last good snapshot**.
 * This used to replace the cache with an empty body, so a single unreadable read
 * — the file being rewritten by something less careful than the sync oneshot, a
 * permissions change, a full disk — emptied the map for a whole TTL. A stale
 * marker set is a far cheaper mistake than a map that quietly lost its markers,
 * which is the same trade the sync oneshot makes when it refuses to write.
 */

import { readFile, stat } from "node:fs/promises";
import type { Config } from "../config.ts";
import type { MarkersBody } from "../../../src/server/wire.ts";
import { assertPublicShape } from "./gate.ts";

export interface MarkerStore {
  current(): Promise<MarkersBody>;
}

export interface MarkerLog {
  warn(msg: string): void;
}

interface Snapshot {
  generatedAt?: string;
  markers?: unknown;
}

/**
 * What a configured-but-unreadable file serves before it has ever been read.
 *
 * It goes out as a refusal rather than as a plain empty list on purpose: an
 * operator looking at `/api/v1/markers` and seeing nothing is owed the
 * difference between "there are no markers" and "this box cannot read the file
 * you pointed it at". Same reasoning as the gate's counts.
 */
function unreadable(file: string): MarkersBody {
  return {
    markers: [],
    generatedAt: new Date().toISOString(),
    refused: [{ reason: `the snapshot at ${file} could not be read`, count: 1 }],
  };
}

export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore {
  const { source, file, provenanceAllowlist, ttlSeconds } = config.markers;

  let cached: MarkersBody | null = null;
  let readAt = 0;
  /** mtime and size of the file `cached` was built from. Empty means "unknown". */
  let signature = "";

  /** `null` when the file could not be read or parsed; the caller keeps what it has. */
  async function load(): Promise<MarkersBody | null> {
    let parsed: unknown;
    try {
      parsed = JSON.parse(await readFile(file, "utf8"));
    } catch (err) {
      log.warn(
        `markers: cannot read ${file} (${String(err)}); serving ` +
          `${cached === null ? "no markers" : "the last snapshot"}`,
      );
      return null;
    }

    const snapshot: Snapshot =
      Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot);

    const { accepted, refused } = assertPublicShape(snapshot.markers ?? [], provenanceAllowlist);
    for (const entry of refused) {
      log.warn(`markers: refused ${entry.count} row(s) — ${entry.reason}`);
    }

    return {
      markers: accepted,
      generatedAt: snapshot.generatedAt ?? new Date().toISOString(),
      refused,
    };
  }

  /**
   * Whether the file on disk differs from the one behind `cached`.
   *
   * A failed `stat` answers "yes", so the read is attempted and reports the real
   * error once, rather than this function inventing a reason of its own.
   */
  async function changed(): Promise<boolean> {
    try {
      const info = await stat(file);
      const next = `${info.mtimeMs}:${info.size}`;
      if (next === signature) return false;
      signature = next;
      return true;
    } catch {
      signature = "";
      return true;
    }
  }

  return {
    async current(): Promise<MarkersBody> {
      if (source === "none") {
        return { markers: [], generatedAt: new Date().toISOString(), refused: [] };
      }

      if (cached === null || Date.now() - readAt > ttlSeconds * 1000) {
        if (cached === null || (await changed())) {
          const fresh = await load();
          if (fresh !== null) cached = fresh;
        }
        readAt = Date.now();
      }

      return cached ?? unreadable(file);
    },
  };
}
