/** Authenticated HTTP + SSE adapter for the in-memory realtime service. */

import type { FastifyInstance, FastifyReply } from "fastify";
import type { InterestCell } from "../../../src/realtime/types.ts";
import type { ResumeRequest } from "../../../src/realtime/types.ts";
import { validateClientRealtimeMessage } from "../../../src/realtime/protocol.ts";
import type { RealtimeFailureCode } from "../realtime/index.ts";
import type { Services } from "../services.ts";

const BODY_LIMIT = 32 * 1024;
const UNAUTHORIZED = { error: "unauthorized", message: "Realtime requires a signed-in member." };

/** Public, statically bundled demos that exist even when TERA_OFFICES_DIR is empty. */
export const BUNDLED_REALTIME_OFFICE_IDS: ReadonlySet<string> = new Set([
  "lumbridge-hq",
  "frontier-valley",
  "mateo-court",
]);

interface CredentialBody {
  sessionId?: unknown;
  token?: unknown;
}

interface PoseBody extends CredentialBody {
  snapshot?: unknown;
}

function text(value: unknown): value is string {
  return typeof value === "string" && value.length > 0 && value.length <= 512;
}

function failureStatus(code: RealtimeFailureCode): number {
  switch (code) {
    case "unauthorized":
    case "expired": return 401;
    case "capacity":
    case "rate-limited": return 429;
    case "conflict":
    case "sequence":
    case "interest":
    case "ownership":
    case "motion": return 409;
    case "invalid": return 400;
  }
}

function sendFailure(reply: FastifyReply, result: { code: RealtimeFailureCode; message: string }) {
  const status = failureStatus(result.code);
  if (status === 401) reply.header("www-authenticate", "Bearer");
  return reply.code(status).send({ error: result.code, message: result.message });
}

async function interestsExist(interests: readonly InterestCell[], services: Services): Promise<boolean> {
  for (const cell of interests) {
    if (cell.kind === "california-tile" || cell.kind === "city") continue;
    const doc = await services.offices.get(cell.officeId);
    if (doc === null) {
      // Only the office envelope is public in a client bundle. Without a
      // server pack we have no authoritative floor or room catalogue, so those
      // finer cells remain unavailable rather than guessed.
      if (cell.kind === "office" && BUNDLED_REALTIME_OFFICE_IDS.has(cell.officeId)) continue;
      return false;
    }
    if (cell.kind === "office") continue;
    const level = doc.floor.levels.find((candidate) => candidate.id === cell.floorId);
    if (!level) return false;
    if (
      cell.kind === "room" &&
      !level.floorplan.rooms.some((room) => room.id === cell.roomId)
    ) return false;
  }
  return true;
}

export function registerRealtime(app: FastifyInstance, services: Services): void {
  app.post<{ Body: unknown }>(
    "/api/v1/realtime/join",
    { bodyLimit: BODY_LIMIT },
    async (req, reply) => {
      // Authentication precedes interest parsing so anonymous callers cannot
      // use office cell errors to learn private identifiers.
      const viewer = await services.auth.resolve(req);
      if (!viewer.authenticated || viewer.subject === null) {
        return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
      }
      const parsed = validateClientRealtimeMessage(req.body);
      if (!parsed.ok) {
        return reply.code(400).send({ error: "invalid", message: "Realtime join request is invalid." });
      }
      const request = parsed.value;
      const interests = request.interests;
      if (!(await interestsExist(interests, services))) {
        return reply.code(400).send({ error: "invalid", message: "Realtime interest is unavailable." });
      }
      if (request.type === "resume-request") {
        const resumed = services.realtime.resume(request, viewer.subject);
        if (!resumed.ok) return sendFailure(reply, resumed);
        return reply.send(resumed.value);
      }
      const joined = services.realtime.join({
        requestId: request.requestId,
        actorId: request.actorId,
        subject: viewer.subject,
        role: viewer.admin ? "admin" : "member",
        interests,
      });
      if (!joined.ok) return sendFailure(reply, joined);
      return reply.code(201).send(joined.value.grant);
    },
  );

  // POST is deliberate: usable session tokens must never enter URLs, reverse-
  // proxy logs, referrers, or browser history. Clients consume this SSE body
  // through fetch streaming rather than the URL-only EventSource constructor.
  app.post<{ Body: unknown }>(
    "/api/v1/realtime/events",
    { bodyLimit: 4 * 1024 },
    async (req, reply) => {
      const viewer = await services.auth.resolve(req);
      if (!viewer.authenticated || viewer.subject === null) {
        return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
      }
      const parsed = validateClientRealtimeMessage(req.body);
      if (!parsed.ok || parsed.value.type !== "resume-request") {
        return reply.code(400).send({ error: "invalid", message: "Realtime resume request is invalid." });
      }
      const request: ResumeRequest = parsed.value;
      if (!(await interestsExist(request.interests, services))) {
        return reply.code(400).send({ error: "invalid", message: "Realtime interest is unavailable." });
      }
      const connection = services.realtime.connectResume(request, viewer.subject, (message) => {
        if (!reply.raw.destroyed) reply.raw.write(`event: ${message.type}\ndata: ${JSON.stringify(message)}\n\n`);
      });
      if (!connection.ok) return sendFailure(reply, connection);

      reply.hijack();
      reply.raw.statusCode = 200;
      reply.raw.setHeader("content-type", "text/event-stream; charset=utf-8");
      reply.raw.setHeader("cache-control", "private, no-store");
      reply.raw.setHeader("connection", "keep-alive");
      reply.raw.setHeader("x-accel-buffering", "no");
      reply.raw.flushHeaders();
      reply.raw.write(`event: resume-grant\ndata: ${JSON.stringify(connection.value.grant)}\n\n`);
      const keepalive = setInterval(() => {
        if (!reply.raw.destroyed) reply.raw.write(`: keepalive ${Date.now()}\n\n`);
      }, 15_000);
      keepalive.unref();
      // The POST request body finishes immediately; listening on IncomingMessage
      // `close` would therefore tear the stream down as soon as credentials
      // were parsed. ServerResponse stays open for the lifetime of the SSE feed.
      reply.raw.once("close", () => {
        clearInterval(keepalive);
        connection.value.disconnect();
      });
    },
  );

  app.post<{ Body: PoseBody }>(
    "/api/v1/realtime/pose",
    { bodyLimit: BODY_LIMIT },
    async (req, reply) => {
      const body = req.body;
      if (!body || !text(body.sessionId) || !text(body.token)) {
        return reply.code(400).send({ error: "invalid", message: "Realtime credentials are required." });
      }
      const submitted = services.realtime.submitPose(body.sessionId, body.token, body.snapshot);
      if (!submitted.ok) return sendFailure(reply, submitted);
      return reply.code(202).send(submitted.value);
    },
  );

  app.post<{ Body: CredentialBody }>(
    "/api/v1/realtime/leave",
    { bodyLimit: 4 * 1024 },
    async (req, reply) => {
      const body = req.body;
      if (!body || !text(body.sessionId) || !text(body.token)) {
        return reply.code(400).send({ error: "invalid", message: "Realtime credentials are required." });
      }
      const left = services.realtime.leave(body.sessionId, body.token);
      if (!left.ok) return sendFailure(reply, left);
      return reply.code(204).send();
    },
  );
}
