/**
 * The marker feed: the refusal that makes the `member` tier real, and a file an
 * operator can actually use.
 *
 * The first half is one assertion said several ways. `src/access.ts` has three
 * tiers and the middle one gated nothing — `member` was a word in a type union
 * with no server behaviour behind it. A tier that never refuses anybody is not a
 * tier, so a configured feed takes a session, and the test that matters is the
 * negative: an anonymous caller gets 401 and no rows, with no query parameter,
 * header or cleared cookie that changes it.
 *
 * The second half is the file itself — what a malformed row does to the rest of
 * the snapshot, and the reload path, which has to work without a restart because
 * the sync oneshot runs on a timer and nothing signals this process.
 *
 * Follows `offices.test.ts`: a temp directory, `buildApp` over a fake
 * environment, `inject()` rather than a socket, and HS256 by hand.
 */

import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { ErrorBody, MarkersBody } from "../../../src/server/wire.ts";

const SECRET = "not-a-real-secret-and-never-was";

const FERRY = {
  id: "ferry-building",
  label: "Ferry Building",
  colorKey: "sector.civic",
  lat: 37.7955,
  lng: -122.3937,
  provenance: "hand-placed",
};

let file = "";

before(async () => {
  const dir = await mkdtemp(join(tmpdir(), "tera-markers-"));
  file = join(dir, "markers.json");
});

beforeEach(async () => {
  await write({ generatedAt: "2026-08-05T09:00:00Z", markers: [FERRY] });
});

async function write(snapshot: unknown): Promise<void> {
  await writeFile(file, JSON.stringify(snapshot));
}

/** A feed that is configured, with an issuer that can produce a member. */
const feedEnv = {
  TERA_MARKERS_SOURCE: "file",
  TERA_AUTH_MODE: "jwt",
  TERA_AUTH_JWT_SECRET: SECRET,
};

function appWith(env: Record<string, string>) {
  const config = loadConfig({ TERA_MARKERS_FILE: file, ...env });
  config.logLevel = "silent";
  return buildApp(config);
}

function member(): string {
  const encode = (value: unknown): string =>
    Buffer.from(JSON.stringify(value)).toString("base64url");
  const claims = { sub: "someone", exp: Math.floor(Date.now() / 1000) + 600 };
  const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
  return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}

function asMember(app: ReturnType<typeof buildApp>) {
  return app.inject({
    method: "GET",
    url: "/api/v1/markers",
    headers: { authorization: `Bearer ${member()}` },
  });
}

describe("TERA_MARKERS_ACCESS", () => {
  it("defaults to members, so wiring real data up is safe by omission", async () => {
    const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv });
    assert.equal(config.markers.access, "members");
  });

  it("serves anonymous callers when an operator says public out loud", async () => {
    const app = appWith({ ...feedEnv, TERA_MARKERS_ACCESS: "public" });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
    assert.equal(res.statusCode, 200);
    assert.ok(res.body.includes("Ferry"));
    // Public means publicly cacheable. The members-only body deliberately is not.
    assert.match(res.headers["cache-control"] as string, /max-age/);
  });

  it("announces a public feed in degraded, so health shows it without reading the env", async () => {
    const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv, TERA_MARKERS_ACCESS: "public" });
    assert.ok(config.degraded.some((line) => line.includes("TERA_MARKERS_ACCESS=public")));
  });

  it("keeps the feed shut on a value it does not understand", async () => {
    const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv, TERA_MARKERS_ACCESS: "yes" });
    assert.equal(config.markers.access, "members");
    assert.ok(config.degraded.some((line) => line.includes("is not one of members, public")));
  });
});

describe("a configured marker feed", () => {
  it("refuses an anonymous caller", async () => {
    const app = appWith(feedEnv);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
    assert.equal(res.statusCode, 401);
    assert.equal(res.json<ErrorBody>().error, "unauthorized");
    assert.equal(res.headers["www-authenticate"], "Bearer");
    // No rows anywhere in the refusal, and nothing shared may keep it.
    assert.ok(!res.body.includes("Ferry"));
    assert.equal(res.headers["cache-control"], "private, no-store");
  });

  it("refuses every shape of not-being-signed-in", async () => {
    const app = appWith(feedEnv);
    after(() => app.close());

    const attempts = [
      {},
      { authorization: "Bearer not-a-jwt" },
      { authorization: `Bearer ${member()}tampered` },
      { authorization: "Basic Zm9vOmJhcg==" },
      { cookie: "tera_session=" },
      { cookie: "tera_session=%zz" },
    ];
    for (const headers of attempts) {
      const res = await app.inject({ method: "GET", url: "/api/v1/markers", headers });
      assert.equal(res.statusCode, 401, `${JSON.stringify(headers)} must not get the feed`);
    }
  });

  it("serves the snapshot to a member", async () => {
    const app = appWith(feedEnv);
    after(() => app.close());

    const res = await asMember(app);
    assert.equal(res.statusCode, 200);
    const body = res.json<MarkersBody>();
    assert.equal(body.markers.length, 1);
    assert.equal(body.markers[0]?.id, "ferry-building");
    assert.equal(body.generatedAt, "2026-08-05T09:00:00Z");
  });

  it("never lets a shared cache keep a member's copy", async () => {
    const app = appWith(feedEnv);
    after(() => app.close());
    assert.equal((await asMember(app)).headers["cache-control"], "private, no-store");
  });

  it("is unreachable, loudly, on a box where nobody can sign in", async () => {
    const config = loadConfig({ TERA_MARKERS_FILE: file, TERA_MARKERS_SOURCE: "file" });
    config.logLevel = "silent";
    const app = buildApp(config);
    after(() => app.close());

    // Fail-closed, and it says so rather than leaving an operator to work it out
    // from an empty map.
    assert.equal(config.auth.mode, "none");
    assert.ok(config.degraded.some((line) => line.includes("TERA_MARKERS_SOURCE=file")));

    const anonymous = await app.inject({ method: "GET", url: "/api/v1/markers" });
    assert.equal(anonymous.statusCode, 401);
    // Even a token that would be valid elsewhere: mode=none verifies nothing.
    assert.equal((await asMember(app)).statusCode, 401);
  });

  it("still answers the public empty body on a box with no feed", async () => {
    // The acceptance test's box. There is nothing here to protect, and making a
    // stranger sign in to be told "no markers" would gain nobody anything.
    const app = appWith({ TERA_MARKERS_SOURCE: "none" });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
    assert.equal(res.statusCode, 200);
    assert.deepEqual(res.json<MarkersBody>().markers, []);
    assert.match(String(res.headers["cache-control"]), /^public, max-age=/);
  });
});

describe("the marker file itself", () => {
  it("accepts a bare array, because that is what a person writes first", async () => {
    await write([FERRY]);
    const app = appWith(feedEnv);
    after(() => app.close());

    assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);
  });

  it("refuses a bad row without losing the good ones, and reports the count", async () => {
    await write({
      markers: [
        FERRY,
        { ...FERRY, id: "osm-row", provenance: "nominatim" },
        { ...FERRY, id: "leaky", ownerEmail: "someone@example.com" },
        { ...FERRY, id: "broken", lat: 200 },
      ],
    });
    const app = appWith(feedEnv);
    after(() => app.close());

    const body = (await asMember(app)).json<MarkersBody>();
    assert.deepEqual(
      body.markers.map((marker) => marker.id),
      ["ferry-building"],
    );
    assert.equal(body.refused.length, 3);
    assert.ok(body.refused.some((entry) => entry.reason.includes("allowlist")));
    assert.ok(body.refused.some((entry) => entry.reason.includes("ownerEmail")));
  });

  it("picks up a rewritten file without a restart", async () => {
    // TERA_MARKERS_TTL is the reload interval. Zero means every request checks,
    // which is what makes this assertable without sleeping for five minutes.
    const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" });
    after(() => app.close());

    assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);

    await write({ markers: [FERRY, { ...FERRY, id: "coit-tower", label: "Coit Tower" }] });
    // A zero TTL means "check on the next millisecond", not "check twice inside
    // the same one" — `inject()` is fast enough that both requests can land on
    // the same `Date.now()`, which is a property of the test and not of the
    // reload.
    await sleep(5);
    const reloaded = (await asMember(app)).json<MarkersBody>();
    assert.deepEqual(
      reloaded.markers.map((marker) => marker.id),
      ["ferry-building", "coit-tower"],
    );
  });

  it("keeps the last good snapshot when a read fails", async () => {
    const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" });
    after(() => app.close());

    assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);

    // The file goes away mid-flight — a rewrite by something less careful than
    // the sync oneshot, a permissions change, a full disk. Emptying the map for
    // a whole TTL is the expensive mistake; serving what was there is the cheap
    // one.
    await rm(file);
    await sleep(5);
    const survived = (await asMember(app)).json<MarkersBody>();
    assert.equal(survived.markers.length, 1);

    // And it recovers on its own once the file comes back.
    await write({ markers: [{ ...FERRY, id: "coit-tower" }] });
    await sleep(5);
    const recovered = (await asMember(app)).json<MarkersBody>();
    assert.deepEqual(
      recovered.markers.map((marker) => marker.id),
      ["coit-tower"],
    );
  });

  it("says it cannot read the file rather than pretending there are no markers", async () => {
    await rm(file);
    const app = appWith(feedEnv);
    after(() => app.close());

    const body = (await asMember(app)).json<MarkersBody>();
    assert.deepEqual(body.markers, []);
    assert.equal(body.refused.length, 1);
    assert.match(body.refused[0]?.reason ?? "", /could not be read/);
  });

  it("survives a file that is not JSON at all", async () => {
    await writeFile(file, "{ this is not json");
    const app = appWith(feedEnv);
    after(() => app.close());

    const res = await asMember(app);
    assert.equal(res.statusCode, 200);
    assert.deepEqual(res.json<MarkersBody>().markers, []);
  });
});
