import path from 'node:path';
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';

const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);

const bearer = (req: FastifyRequest): string | undefined => {
  const h = req.headers.authorization;
  return h?.startsWith('Bearer ') ? h.slice(7) : undefined;
};

// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
// Protocol-level keepalive: ping each socket on an interval and terminate one
// that misses a pong, so a vanished player (closed laptop, dead network) is
// detected and drops out of the GM's roster within ~one interval.
const HEARTBEAT_MS = 30_000;

export function buildServer() {
  const app = Fastify({ bodyLimit: BODY_LIMIT });
  const hub = new RoomHub();
  const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
  void accounts.load();
  const cloud = new CloudStore(DATA_DIR);
  void cloud.load();
  const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
  app.addHook('onClose', async () => clearInterval(sweeper));

  // Simple per-IP throttle for the account/cloud API.
  const ipHits = new Map<string, { n: number; reset: number }>();
  app.addHook('onRequest', async (req, reply) => {
    if (!req.url.startsWith('/api/')) return;
    const now = Date.now();
    const e = ipHits.get(req.ip);
    if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
    else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
  });

  // ---- accounts + cloud backup sync ----
  app.post('/api/register', async (req, reply) => {
    const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
    const r = await accounts.register(String(username ?? ''), String(password ?? ''));
    if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message });
    return { token: r.token, username: r.username };
  });
  app.post('/api/login', async (req, reply) => {
    const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
    const r = await accounts.login(String(username ?? ''), String(password ?? ''));
    if (!r.ok) return reply.code(401).send({ error: r.code, message: r.message });
    return { token: r.token, username: r.username };
  });
  app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); return { ok: true }; });
  app.put('/api/save', async (req, reply) => {
    const u = await accounts.userByToken(bearer(req));
    if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const blob = (req.body as { blob?: string } | undefined)?.blob;
    if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
    await accounts.saveBlob(u.id, blob);
    return { ok: true, size: blob.length };
  });
  app.get('/api/save', async (req, reply) => {
    const u = await accounts.userByToken(bearer(req));
    if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const blob = await accounts.loadBlob(u.id);
    if (blob === null) return reply.code(404).send({ error: 'no-save' });
    return reply.header('content-type', 'application/json').send(blob);
  });

  // ---- shared cloud campaigns + member-owned characters ----
  const userOf = (req: FastifyRequest) => accounts.userByToken(bearer(req));
  app.post('/api/campaigns', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const { name, system } = (req.body ?? {}) as { name?: string; system?: string };
    const c = await cloud.createCampaign(u.id, String(name ?? ''), String(system ?? '5e'));
    return { id: c.id, name: c.name, system: c.system, inviteCode: c.inviteCode, role: 'owner' };
  });
  app.get('/api/campaigns', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const list = await cloud.listForUser(u.id);
    return list.map((c) => ({ id: c.id, name: c.name, system: c.system, role: c.role, ...(c.role === 'owner' ? { inviteCode: c.inviteCode } : {}) }));
  });
  app.post('/api/campaigns/join', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const { inviteCode } = (req.body ?? {}) as { inviteCode?: string };
    const c = await cloud.joinByInvite(u.id, String(inviteCode ?? ''));
    if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' });
    return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' };
  });
  app.put('/api/characters', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
    if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' });
    const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data });
    if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' });
    return { ok: true, updatedAt: r.updatedAt };
  });
  app.get('/api/campaigns/:id/characters', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const id = (req.params as { id: string }).id;
    await cloud.load();
    if (!cloud.isMember(id, u.id)) return reply.code(403).send({ error: 'forbidden' });
    const chars = await cloud.listCharacters(id);
    return chars.map((c) => ({ id: c.id, name: c.name, ownerUserId: c.ownerUserId, mine: c.ownerUserId === u.id, data: c.data, updatedAt: c.updatedAt }));
  });
  app.delete('/api/characters/:id', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const ok = await cloud.removeCharacter(u.id, (req.params as { id: string }).id);
    return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
  });
  app.get('/api/usage', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
    return { bytes, admin: accounts.isAdmin(u.username) };
  });

  // ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
  app.get('/api/admin/users', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
    const users = await accounts.listUsers();
    const rows = await Promise.all(users.map(async (x) => ({
      username: x.username,
      usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
      quotaBytes: x.quotaBytes ?? 0,
    })));
    return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
  });
  app.post('/api/admin/quota', async (req, reply) => {
    const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
    if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
    const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
    const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
    return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
  });

  void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });

  void app.register(async (instance) => {
    instance.get('/ws', { websocket: true }, (socket, req) => {
      // Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
      const origin = req.headers.origin;
      if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
        socket.close(1008, 'origin');
        return;
      }
      const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
      let tokens = RATE.capacity;
      const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);

      // Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
      let alive = true;
      socket.on('pong', () => { alive = true; });
      const heartbeat = setInterval(() => {
        if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
        alive = false;
        try { socket.ping(); } catch { /* closed */ }
      }, HEARTBEAT_MS);

      socket.on('message', (raw: Buffer) => {
        if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
        let parsed;
        try { parsed = clientMessageSchema.safeParse(JSON.parse(raw.toString())); } catch { return; }
        if (!parsed.success) return;
        const m = parsed.data;
        switch (m.t) {
          case 'host': hub.host(sender, m.password, m.resume); break;
          case 'join': hub.join(sender, m.joinCode, m.password); break;
          case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
          case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
          case 'requestImage': hub.requestImage(sender, m.id); break;
          case 'claimSeat': hub.claimSeat(sender, m.characterId, m.offlineSnapshot); break;
          case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
          case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
          case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
          case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
          case 'privateState': hub.privateState(sender, m.gmSecret, m.targetPlayerId, m.handout); break;
          case 'chat': hub.chat(sender, m.body, m.to); break;
          case 'setName': hub.setName(sender, m.name); break;
        }
      });
      socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
    });
  });

  app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() }));

  // Serve the built SPA with history fallback (so /play?room=... deep-links work).
  void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
  app.setNotFoundHandler((req, reply) => {
    if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
    return reply.sendFile('index.html');
  });

  return app;
}

// Start unless imported by a test.
if (process.env.NODE_ENV !== 'test') {
  const app = buildServer();
  app.listen({ port: PORT, host: '0.0.0.0' })
    .then((addr) => console.log(`ttrpg server on ${addr} (static: ${STATIC_DIR})`))
    .catch((e) => { console.error(e); process.exit(1); });
}
