/**
 * Weather, once a source is actually turned on.
 *
 * `api.weather.gov` is stood up as a stub here rather than called, for the
 * obvious reason and for a less obvious one: the assertions that matter are
 * about **which URL this server constructs**, and a real upstream would answer
 * the right way for the wrong reason. The load-bearing one is that a caller
 * asking for Berkeley causes a fetch of San Francisco's point and never of
 * Berkeley's — the difference between a map and an open proxy pointed at a
 * public-good API with this deployment's contact address on it.
 *
 * Global `fetch` is replaced for the file. `node --test` runs each test file in
 * its own process, so nothing here leaks into another one, and `after` puts the
 * real one back anyway.
 */

import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";

const CONTACT = "ops@example.com";

const nwsEnv = { TERA_WEATHER_SOURCE: "nws", TERA_WEATHER_CONTACT: CONTACT };

interface Call {
  url: string;
  userAgent: string;
}

const realFetch = globalThis.fetch;
let calls: Call[] = [];
/** Flipped by a test that wants to watch the upstream go away mid-flight. */
let upstreamIsUp = true;
/**
 * Flipped by a test that wants the upstream to stay *up* and answer 200 with
 * something the parser cannot walk. `cloudLayers` becomes a number, which is
 * the shape that used to throw straight through the cache.
 */
let upstreamIsGarbled = false;

/**
 * The three hops NWS makes you take, and nothing else: an unrecognised URL is a
 * 404, so a request this server should not be making shows up as a failure
 * rather than as a plausible answer.
 */
function nws(url: string): unknown | undefined {
  if (!upstreamIsUp) return undefined;

  const point = /\/points\/(-?[\d.]+),(-?[\d.]+)$/.exec(url);
  if (point !== null) {
    return { properties: { observationStations: `https://api.weather.gov/zones/${point[1]}` } };
  }
  const zone = /\/zones\/(-?[\d.]+)$/.exec(url);
  if (zone !== null) {
    // One station id per latitude, so a body can be traced back to the point
    // that was asked about.
    return { features: [{ properties: { stationIdentifier: `K${zone[1]}` } }] };
  }
  const station = /\/stations\/K(-?[\d.]+)\/observations\/latest$/.exec(url);
  if (station !== null) {
    return {
      properties: {
        timestamp: "2026-08-05T09:00:00+00:00",
        temperature: { value: Number(station[1]), unitCode: "wmoUnit:degC" },
        windSpeed: { value: 9, unitCode: "wmoUnit:km_h-1" },
        cloudLayers: upstreamIsGarbled ? 7 : [{ amount: "BKN" }],
        presentWeather: [],
        visibility: { value: 16_000, unitCode: "wmoUnit:m" },
      },
    };
  }
  return undefined;
}

globalThis.fetch = (async (input: unknown, init?: { headers?: Record<string, string> }) => {
  const url = String(input);
  // `http.ts` always passes a plain object, so this needs no `Headers` dance.
  calls.push({ url, userAgent: init?.headers?.["user-agent"] ?? "" });

  const body = nws(url);
  if (body === undefined) return new Response("nope", { status: 503 });
  return new Response(JSON.stringify(body), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}) as unknown as typeof globalThis.fetch;

after(() => {
  globalThis.fetch = realFetch;
});

beforeEach(() => {
  calls = [];
  upstreamIsUp = true;
  upstreamIsGarbled = false;
});

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

function observations(): string[] {
  return calls.filter((call) => call.url.includes("/observations/")).map((call) => call.url);
}

describe("a configured weather source", () => {
  it("fetches the region centre and never the coordinate the caller sent", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    // Berkeley. Inside the Bay Area region, and not a point this deployment
    // serves — so it selects San Francisco and San Francisco is what gets asked.
    const res = await app.inject({
      method: "GET",
      url: "/api/v1/weather?lat=37.8715&lng=-122.2730",
    });
    assert.equal(res.statusCode, 200);

    assert.ok(
      calls.every((call) => !call.url.includes("37.8715")),
      `the caller's coordinate reached the upstream: ${calls.map((c) => c.url).join(" ")}`,
    );
    assert.ok(calls.some((call) => call.url.endsWith("/points/37.7749,-122.4194")));
    assert.deepEqual(res.json<WeatherBody>().location, { lat: 37.7749, lng: -122.4194 });
  });

  it("holds one observation per city rather than one per box", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    const sf = (
      await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" })
    ).json<WeatherBody>();
    const socal = (
      await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" })
    ).json<WeatherBody>();

    // The stub encodes the latitude in the temperature, so two different
    // numbers here means two different stations were actually consulted.
    assert.equal(sf.temperatureC, 37.7749);
    assert.equal(socal.temperatureC, 33.82);
    assert.equal(observations().length, 2);
    assert.equal(sf.source, "nws");
    assert.equal(sf.synthetic, false);
  });

  it("asks once per region per TTL however many callers turn up", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    const ask = () => app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
    // Concurrent, then sequential: the first is the single-flight collapse, the
    // second is the TTL. Both used to be one fetch each and only one of them was.
    await Promise.all([ask(), ask(), ask(), ask()]);
    await ask();
    await ask();

    assert.equal(observations().length, 1);
  });

  it("keeps the cities apart under load, not merely on the first request", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    const bodies = await Promise.all(
      ["sf", "socal", "sf", "socal", "sf"].map(async (city) =>
        (await app.inject({ method: "GET", url: `/api/v1/weather?city=${city}` })).json<WeatherBody>(),
      ),
    );
    assert.deepEqual(
      bodies.map((body) => body.temperatureC),
      [37.7749, 33.82, 37.7749, 33.82, 37.7749],
    );
    assert.equal(observations().length, 2);
  });

  /**
   * A source that is *up* and answering in a shape this build cannot read is a
   * different failure from one that is down, and it used to be a much worse
   * one: the parser threw, `upstream.ts` never reached its clock stamp, and the
   * TTL — the only thing standing between a public-good API and one outbound
   * request per inbound request — stopped existing. `current()` also stopped
   * being the thing its own header calls it, which is a function that never
   * throws.
   */
  it("treats a body it cannot parse as a source that did not answer", async () => {
    upstreamIsGarbled = true;
    const app = appWith(nwsEnv);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
    assert.equal(res.statusCode, 200);
    // The clear day, which is what "nobody answered" has always meant here.
    assert.equal(res.json<WeatherBody>().synthetic, true);
  });

  it("keeps the TTL when the body is garbled, not just when the socket dies", async () => {
    upstreamIsGarbled = true;
    const app = appWith(nwsEnv);
    after(() => app.close());

    for (let i = 0; i < 5; i++) {
      await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
    }
    assert.equal(observations().length, 1);
  });

  it("identifies the operator to a source that requires it", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    await app.inject({ method: "GET", url: "/api/v1/weather" });
    assert.ok(calls.length > 0);
    for (const call of calls) assert.match(call.userAgent, new RegExp(CONTACT));
  });

  it("serves the last good observation when the upstream goes away", async () => {
    // TTL 0 makes every request a refetch, which is what makes the failure
    // reachable in a test without waiting ten minutes for one.
    const app = appWith({ ...nwsEnv, TERA_WEATHER_TTL: "0" });
    after(() => app.close());

    const first = (
      await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" })
    ).json<WeatherBody>();
    assert.equal(first.temperatureC, 37.7749);

    upstreamIsUp = false;
    const second = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
    assert.equal(second.statusCode, 200);
    const body = second.json<WeatherBody>();
    // Not a 503, not a clear day: the observation from a minute ago, which is
    // the only answer that keeps the sky looking like the sky.
    assert.equal(body.temperatureC, 37.7749);
    assert.equal(body.synthetic, false);
  });

  it("falls back to a clear day for a city that has never answered", async () => {
    upstreamIsUp = false;
    const app = appWith(nwsEnv);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" });
    assert.equal(res.statusCode, 200);
    const body = res.json<WeatherBody>();
    assert.equal(body.synthetic, true);
    assert.equal(body.condition, "clear");
    assert.deepEqual(body.location, { lat: 33.82, lng: -118.05 });
  });

  it("retries a dead source on the TTL, not on every request", async () => {
    upstreamIsUp = false;
    const app = appWith(nwsEnv);
    after(() => app.close());

    for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/weather" });
    // One attempt, one failure, one clock stamp. Somebody else's outage must not
    // turn into this box's outbound flood.
    assert.equal(calls.length, 1);
  });

  it("makes no outbound request at all until somebody asks", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());
    await app.inject({ method: "GET", url: "/api/v1/health" });
    assert.deepEqual(calls, []);
  });

  it("never calls anybody when the source is off", async () => {
    const app = appWith({});
    after(() => app.close());

    await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
    await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" });
    assert.deepEqual(calls, []);
  });

  it("refuses the request before it would have fetched anything", async () => {
    const app = appWith(nwsEnv);
    after(() => app.close());

    const res = await app.inject({
      method: "GET",
      url: "/api/v1/weather?lat=51.5072&lng=-0.1276",
    });
    assert.equal(res.statusCode, 400);
    // The point of the allowlist: a refused request costs the upstream nothing.
    assert.deepEqual(calls, []);
  });
});
