/**
 * The one server.
 *
 * One Fastify instance on `127.0.0.1:8431` serving `/api/v1/*`, behind Caddy,
 * with one systemd unit and one Caddy snippet. Three separate backends were
 * designed for this box — three ports, three frameworks, three deploy files that
 * would have overwritten each other — and this is the one that replaces them.
 * Weather, flights, markers and offices are route modules here, not services on
 * ports of their own. CONTRACT.md §5.
 *
 * `buildApp` never listens. Tests build one and use `inject()`; `index.ts` builds
 * one and binds it.
 */

import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { createBirdsService } from "./birds/index.ts";
import { registerCachePolicy } from "./cache.ts";
import { loadConfig, type Config } from "./config.ts";
import { createRadarService } from "./radar/index.ts";
import { registerBirds } from "./routes/birds.ts";
import { registerDevices } from "./routes/devices.ts";
import { registerFires } from "./routes/fires.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerMedia } from "./routes/media.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerRadar } from "./routes/radar.ts";
import { registerRealtime } from "./routes/realtime.ts";
import { registerSatellites } from "./routes/satellites.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts";
import type { ErrorBody } from "../../src/server/wire.ts";

export function buildApp(config: Config = loadConfig()): FastifyInstance {
  const app = Fastify({
    logger: { level: config.logLevel },
    // Caddy is the only thing that talks to this socket, so its X-Forwarded-For
    // is the client address. Nothing else can reach the port to forge one.
    trustProxy: true,
  });

  // One loud line per demotion, at boot, before anything can be served. The
  // health body carries the same list for anyone without log access.
  for (const line of config.degraded) {
    app.log.warn(`TERA DEGRADED: ${line}`);
  }

  registerCachePolicy(app);
  registerCors(app, config.corsOrigins);

  const services = createServices(config, app.log);
  registerHealth(app, services);
  registerFlights(app, services);
  registerSatellites(app, services);
  registerFires(app, services);
  // The two sky feeds are constructed here rather than on `Services`. They own
  // their own cache and their own failure behaviour exactly as every other
  // service does, and neither can throw at a route. `sources.radar` and
  // `sources.birds` are on the health body — `routes/health.ts` reads them off
  // the config rather than off a service, which is what every line in that route
  // does and is why it still touches no upstream.
  registerRadar(app, createRadarService(config, app.log));
  registerBirds(app, createBirdsService(config, app.log));
  registerWeather(app, services);
  registerMarkers(app, services);
  registerMedia(app, services);
  registerOffices(app, services);
  registerPresence(app, services);
  registerDevices(app, services);
  registerRealtime(app, services);
  registerSession(app, services);

  app.setNotFoundHandler(async (_req, reply) => {
    const body: ErrorBody = { error: "not_found", message: "No such route." };
    return reply.code(404).send(body);
  });

  /**
   * The one error handler, and the one place a client's mistake is told apart
   * from ours.
   *
   * It used to answer 500 to everything, which was right for the only thing
   * that reached it at the time — a route that threw. It stopped being right
   * the moment a route accepted a body: Fastify raises its own errors for a
   * payload that is not JSON, a content type it was not offered and a body over
   * a route's `bodyLimit`, and every one of those is the caller's mistake,
   * carries a 4xx `statusCode`, and was being reported as "something went wrong
   * on the server". An operator watching error rates cannot tell a broken box
   * from somebody POSTing nonsense at it, and the caller is told to retry
   * something that will never work.
   *
   * So a 4xx from the framework is passed through with its own status and the
   * `ErrorBody` shape every other refusal here uses; anything else is still a
   * 500 with nothing in it, because the inside of an exception is not a thing
   * to hand to the internet.
   */
  app.setErrorHandler(async (err: FastifyError, req, reply) => {
    const status = typeof err.statusCode === "number" ? err.statusCode : 500;
    if (status >= 400 && status < 500) {
      req.log.info({ err: err.message, status }, "refused a malformed request");
      const body: ErrorBody =
        status === 404
          ? { error: "not_found", message: "No such route." }
          : { error: "bad_request", message: "That request could not be read." };
      return reply.code(status).send(body);
    }
    app.log.error({ err }, "unhandled error");
    const body: ErrorBody = { error: "internal", message: "Something went wrong." };
    return reply.code(500).send(body);
  });

  return app;
}

/**
 * CORS, by hand.
 *
 * The default is an empty allowlist — the browser build is served from the same
 * origin through Caddy, so nothing needs this until somebody runs the dev server
 * on a different port and sets `TERA_CORS_ORIGIN`. A plugin whose entire job is
 * fifteen lines of header-setting is not worth the dependency, and the fifteen
 * lines being visible here is worth something on a route that decides who may
 * read the API from where.
 */
function registerCors(app: FastifyInstance, allowed: string[]): void {
  if (allowed.length === 0) return;

  app.addHook("onRequest", async (req, reply) => {
    const origin = req.headers.origin;
    if (typeof origin !== "string" || !allowed.includes(origin)) return;

    reply.header("access-control-allow-origin", origin);
    reply.header("access-control-allow-headers", "authorization, content-type");
    reply.header("access-control-allow-methods", "GET, POST, OPTIONS");
    // Whatever the response ends up being, it depended on the Origin header.
    reply.header("vary", "Origin");

    if (req.method === "OPTIONS") await reply.code(204).send();
  });
}
