import type { Server as HttpServer } from 'node:http';
import { Server, type Socket } from 'socket.io';
import { CLIENT_EVENTS, HOST_EVENTS, MAX_CONNECTIONS_PER_IP, RATE_LIMIT } from '@pintrush/protocol';
import type { z } from 'zod';
import type { TokenService } from './auth';
import type { ServerConfig } from './config';
import { log } from './logger';
import { send } from './outgoing';
import { ConnectionCounter, JOINS_PER_IP, JOINS_PER_SOCKET, KeyedBuckets, TokenBucket } from './rateLimit';
import type { RoomActor } from './rooms/RoomActor';
import type { RoomManager } from './rooms/RoomManager';
import type { Attached, HandshakeAuth, Identity } from './rooms/types';

type Result = { ok: true; data?: unknown } | { ok: false; error: { code: string; message: string } };
const fail = (code: string, message: string): Result => ({ ok: false, error: { code, message } });

interface SocketCtx {
  actor: RoomActor | null;
  attached: Attached;
  /** Set by auth middleware when a guest token was rotated during this handshake. */
  rotated?: { token: string; expiresAt: number; playerSessionId: string; gameSessionId: string };
  /** Why a presented token was not accepted (sent to the client so it can clean up). */
  authError?: { code: string; message: string };
}

/**
 * The client address for per-IP limits. Behind `hops` trusted proxies, each proxy APPENDS the address
 * it saw, so the real client is the hops-th entry from the right. The left-most entries are whatever
 * the client sent and must never be trusted.
 */
export function clientIp(socket: Pick<Socket, 'handshake'>, hops: number): string {
  if (hops > 0) {
    const raw = socket.handshake.headers['x-forwarded-for'];
    const entries = (Array.isArray(raw) ? raw.join(',') : (raw ?? ''))
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean);
    const candidate = entries[entries.length - hops];
    if (candidate) return candidate;
  }
  return socket.handshake.address || 'unknown';
}

export function createIo(httpServer: HttpServer, deps: { config: ServerConfig; tokens: TokenService; rooms: RoomManager }): Server {
  const { config, tokens, rooms } = deps;
  const io = new Server(httpServer, {
    cors: { origin: config.webOrigins, credentials: false },
    serveClient: false,
    pingInterval: 20_000,
    pingTimeout: 25_000,
    maxHttpBufferSize: 64 * 1024,
    connectionStateRecovery: undefined,
  });
  const ipCounter = new ConnectionCounter(MAX_CONNECTIONS_PER_IP);
  const joinLimiter = new KeyedBuckets(JOINS_PER_IP.perSecond, JOINS_PER_IP.burst);
  const ctxs = new WeakMap<Socket, SocketCtx>();
  setInterval(() => joinLimiter.prune(rooms.clock.now()), 5 * 60_000).unref();

  // ── Handshake: identify the socket from the tokens it presents. Bad tokens fall back to anonymous. ──
  io.use(async (socket, rawNext) => {
    const ip = clientIp(socket, config.trustProxyHops);
    if (!ipCounter.tryAdd(ip)) return rawNext(new Error('RATE_LIMITED'));
    // Release the per-IP slot exactly once on whichever exit happens: a refused handshake (Socket.IO
    // never emits 'disconnect' for those), the client vanishing mid-handshake, or a normal disconnect.
    let released = false;
    const release = () => {
      if (released) return;
      released = true;
      ipCounter.remove(ip);
    };
    socket.once('disconnect', release);
    socket.conn.once('close', release);
    const next = (err?: Error) => {
      if (err) release();
      rawNext(err);
    };
    const now = rooms.clock.now();
    const attached: Attached = { socket, identity: { role: 'anon' }, ip, bucket: new TokenBucket(RATE_LIMIT.perSecond, RATE_LIMIT.burst, () => rooms.clock.now()) };
    const ctx: SocketCtx = { actor: null, attached };
    ctxs.set(socket, ctx);
    const auth = (socket.handshake.auth ?? {}) as HandshakeAuth;
    try {
      if (typeof auth.tvToken === 'string') {
        const v = await tokens.verify(auth.tvToken, now);
        if (v?.kind !== 'tv') return next(new Error('UNAUTHORIZED'));
        const room = await rooms.catalog.findRoom(v.claims.roomId);
        if (!room || room.venueId !== v.claims.venueId || room.tvTokenVersion !== v.claims.v) return next(new Error('UNAUTHORIZED'));
        ctx.actor = await rooms.getActor(room.id);
        if (!ctx.actor) return next(new Error('NOT_FOUND'));
        ctx.actor.updateRecords(room, (await rooms.catalog.findVenue(room.venueId))!);
        attached.identity = { role: 'tv' };
        return next();
      }
      if (typeof auth.hostToken === 'string') {
        const v = await tokens.verify(auth.hostToken, now);
        if (v?.kind !== 'host') return next(new Error('UNAUTHORIZED'));
        if (typeof auth.eventId !== 'string') return next(new Error('BAD_REQUEST'));
        // A removed user (or a cancelled venue) loses control even with an unexpired token.
        if (!(await rooms.catalog.hostAllowed(v.claims.sub, v.claims.venueId))) return next(new Error('UNAUTHORIZED'));
        const event = await rooms.catalog.findEvent(auth.eventId);
        // Tenant isolation: a host token only ever reaches its own venue's events.
        if (!event || event.venueId !== v.claims.venueId) return next(new Error('FORBIDDEN'));
        ctx.actor = await rooms.getActor(event.roomId);
        if (!ctx.actor || ctx.actor.venueId !== v.claims.venueId) return next(new Error('FORBIDDEN'));
        attached.identity = { role: 'host', venueUserId: v.claims.sub, venueId: v.claims.venueId, eventId: event.id };
        return next();
      }
      const presented = [...(Array.isArray(auth.tokens) ? auth.tokens : []), ...(typeof auth.token === 'string' ? [auth.token] : [])]
        .filter((t): t is string => typeof t === 'string')
        .slice(0, 6);
      if (presented.length > 0) {
        const verified = (await Promise.all(presented.map((t) => tokens.verify(t, now)))).flatMap((v) => (v?.kind === 'guest' ? [v.claims] : []));
        // Prefer the token for the room's currently active session.
        let chosen = verified[0];
        for (const c of verified) {
          const actor = await rooms.getActor(c.roomId);
          if (actor && actor.venueId === c.venueId && actor.activeSession()?.id === c.gsid) chosen = c;
        }
        if (!chosen) {
          ctx.authError = { code: 'UNAUTHORIZED', message: 'Your game link has expired — please rejoin.' };
          return next();
        }
        const actor = await rooms.getActor(chosen.roomId);
        if (!actor || actor.venueId !== chosen.venueId) {
          ctx.authError = { code: 'NOT_FOUND', message: 'That room is no longer available.' };
          return next();
        }
        const r = await actor.authenticateGuest(chosen);
        if (!r.ok) {
          // Stay connected but anonymous (not subscribed to any room) so the phone can rejoin.
          ctx.authError = r.error;
          return next();
        }
        ctx.actor = actor;
        attached.identity = r.identity;
        if (r.identity.role === 'guest') {
          ctx.rotated = { token: r.token, expiresAt: r.expiresAt, playerSessionId: r.identity.playerId, gameSessionId: r.identity.gameSessionId };
        }
      }
      return next();
    } catch (err) {
      log.error('handshake failed', { err });
      return next(new Error('INTERNAL'));
    }
  });

  io.on('connection', (socket) => {
    const ctx = ctxs.get(socket);
    if (!ctx) return socket.disconnect(true);
    const a = ctx.attached;
    if (ctx.actor) ctx.actor.attach(a);
    if (ctx.rotated) send(socket, 'session.token', { type: 'session.token', seq: 0, serverNow: rooms.clock.now(), data: ctx.rotated });
    if (ctx.authError) send(socket, 'error', { type: 'error', seq: 0, serverNow: rooms.clock.now(), data: ctx.authError });

    const handle = <S extends z.ZodType>(
      event: string,
      schema: S,
      fn: (payload: z.infer<S>, now: number) => Promise<Result> | Result,
    ) => {
      socket.on(event, async (payload: unknown, ack?: (res: Result) => void) => {
        const reply = typeof ack === 'function' ? ack : () => undefined;
        const now = rooms.clock.now(); // server receive time: the only clock scoring ever uses
        if (!a.bucket.take()) return reply(fail('RATE_LIMITED', 'Slow down a little'));
        const parsed = schema.safeParse(payload);
        if (!parsed.success) return reply(fail('BAD_REQUEST', parsed.error.issues[0]?.message ?? 'Invalid request'));
        try {
          reply(await fn(parsed.data, now));
        } catch (err) {
          log.error('handler failed', { event, err });
          reply(fail('INTERNAL', 'Something went wrong'));
        }
      });
    };

    const joinBucket = new TokenBucket(JOINS_PER_SOCKET.perSecond, JOINS_PER_SOCKET.burst, () => rooms.clock.now());
    const requireActor = (): RoomActor | null => ctx.actor;
    const isGuest = () => a.identity.role === 'guest';
    const isHost = () => a.identity.role === 'host';

    // ── Players ──
    handle('clock.sync', CLIENT_EVENTS['clock.sync'], (p) => ({ ok: true, data: { t0: p.t0, serverNow: rooms.clock.now() } }));

    handle('room.join', CLIENT_EVENTS['room.join'], async (p, now) => {
      if (a.identity.role === 'tv' || a.identity.role === 'host') return fail('FORBIDDEN', 'This screen cannot join as a player');
      // Joins create players: limit them per socket and per client address, on top of the general bucket.
      if (!joinBucket.take() || !joinLimiter.take(a.ip, now)) return fail('RATE_LIMITED', 'Too many join attempts — wait a moment and try again.');
      const actor = await rooms.resolveJoin(p.venueCode, p.roomId ?? undefined);
      if (!actor) return fail('NOT_FOUND', "We couldn't find that bar. Check the code on the TV.");
      const previous = ctx.actor;
      // The socket is only attached to a room once the join succeeds (actor.join re-identifies it).
      const res = await actor.join(a, { nickname: p.nickname, teamName: p.teamName ?? null, deviceId: p.deviceId, idempotencyKey: p.idempotencyKey });
      if (res.ok) {
        if (previous && previous !== actor) previous.detach(socket.id);
        ctx.actor = actor;
      }
      return res;
    });

    handle('state.request', CLIENT_EVENTS['state.request'], () => {
      const actor = requireActor();
      if (!actor || a.identity.role === 'anon') return fail('UNAUTHORIZED', 'Join the game first');
      return { ok: true, data: actor.snapshotFor(a) };
    });

    handle('answer.submit', CLIENT_EVENTS['answer.submit'], async (p, now) => {
      const actor = requireActor();
      if (!actor || !isGuest()) return fail('UNAUTHORIZED', 'Join the game first');
      return actor.guestCommand(a, (playerId) => ({
        type: 'SUBMIT_ANSWER',
        now,
        playerId,
        questionId: p.questionId,
        optionId: p.optionId,
        wildcardPosition: p.wildcardPosition ?? null,
        idempotencyKey: p.idempotencyKey,
      }));
    });

    handle('powerup.activate', CLIENT_EVENTS['powerup.activate'], async (p, now) => {
      const actor = requireActor();
      if (!actor || !isGuest()) return fail('UNAUTHORIZED', 'Join the game first');
      return actor.guestCommand(
        a,
        (playerId) => ({
          type: 'USE_POWERUP',
          now,
          playerId,
          powerUp: p.type,
          ...(p.questionId ? { questionId: p.questionId } : {}),
          ...('from' in p && p.from !== undefined ? { from: p.from } : {}),
          ...('to' in p && p.to !== undefined ? { to: p.to } : {}),
          idempotencyKey: p.idempotencyKey,
        }),
        (r) => (r.reply.data?.removedOptionId ? { removedOptionId: r.reply.data.removedOptionId } : {}),
      );
    });

    handle('team.set', CLIENT_EVENTS['team.set'], async (p, now) => {
      const actor = requireActor();
      if (!actor || !isGuest()) return fail('UNAUTHORIZED', 'Join the game first');
      return actor.setTeam(a, p.teamName, now);
    });

    handle('final.predict', CLIENT_EVENTS['final.predict'], async (p, now) => {
      const actor = requireActor();
      if (!actor || !isGuest()) return fail('UNAUTHORIZED', 'Join the game first');
      return actor.guestCommand(a, (playerId) => ({ type: 'PREDICT', now, playerId, finalistId: p.finalistId, idempotencyKey: p.idempotencyKey }));
    });

    // ── Hosts (TV tokens and guests can never reach these) ──
    const hostAction = (event: string, action: 'open' | 'start' | 'pause' | 'resume' | 'end' | 'advance') =>
      handle(event, HOST_EVENTS[event as keyof typeof HOST_EVENTS] as z.ZodType<{ eventId: string; idempotencyKey: string }>, async (p, now) => {
        const actor = requireActor();
        if (!actor || !isHost()) return fail('FORBIDDEN', 'Host access required');
        if (!(await hostOwnsEvent(a.identity, p.eventId, actor))) return fail('FORBIDDEN', 'Not your event');
        return actor.hostCommand(a, action, p.eventId, now, p.idempotencyKey);
      });
    hostAction('event.open', 'open');
    hostAction('event.start', 'start');
    hostAction('event.pause', 'pause');
    hostAction('event.resume', 'resume');
    hostAction('event.end', 'end');
    hostAction('round.advance', 'advance');

    handle('nickname.moderate', HOST_EVENTS['nickname.moderate'], async (p, now) => {
      const actor = requireActor();
      if (!actor || !isHost()) return fail('FORBIDDEN', 'Host access required');
      if (!(await hostOwnsEvent(a.identity, p.eventId, actor))) return fail('FORBIDDEN', 'Not your event');
      return actor.moderate(a, { eventId: p.eventId, playerSessionId: p.playerSessionId, action: p.action, nickname: p.nickname }, now);
    });

    handle('host.sync', HOST_EVENTS['host.sync'], async (p) => {
      const actor = requireActor();
      if (!actor || !isHost()) return fail('FORBIDDEN', 'Host access required');
      const eventId = p.eventId ?? (a.identity.role === 'host' ? a.identity.eventId : null);
      if (eventId && !(await hostOwnsEvent(a.identity, eventId, actor))) return fail('FORBIDDEN', 'Not your event');
      return { ok: true, data: actor.hostSnapshot(eventId) };
    });

    socket.on('disconnect', () => {
      ctx.actor?.detach(socket.id);
    });
  });

  /** The event must belong to the host's venue AND to the room this socket is bound to. */
  async function hostOwnsEvent(identity: Identity, eventId: string, actor: RoomActor): Promise<boolean> {
    if (identity.role !== 'host') return false;
    const event = await rooms.catalog.findEvent(eventId);
    return !!event && event.venueId === identity.venueId && event.roomId === actor.roomId;
  }

  return io;
}
