import type { HostState, PrivateState, PublicState } from '@pintrush/engine';
import type { RoomSnapshot } from '@pintrush/protocol';
import { io, type Socket } from 'socket.io-client';

/** What the client sends in the Socket.IO handshake (see apps/game-server/src/rooms/types.ts). */
export interface HandshakeAuth {
  tokens?: string[];
  tvToken?: string;
  hostToken?: string;
  eventId?: string;
}

export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'denied';

export interface GameViewState {
  status: ConnectionStatus;
  snapshot: RoomSnapshot | null;
  public: PublicState | null;
  me: PrivateState | null;
  host: HostState | null;
  /** Type of the last message received ('question.open', 'answer.result', …) — drives animations. */
  lastType: string | null;
  /** Increments on every message so effects can react to repeats of the same type. */
  messageCount: number;
  /** `data` of the last message that carried any (round.complete summary, event.complete standings, …). */
  lastData: unknown;
  /** Type of the message that carried `lastData`. */
  lastDataType: string | null;
  seq: number;
  /** serverNow ≈ Date.now() + offsetMs (measured with clock.sync, lowest round-trip wins). */
  offsetMs: number;
  error: { code: string; message: string } | null;
  kicked: boolean;
}

export type Ack<T = unknown> = { ok: true; data?: T } | { ok: false; error: { code: string; message: string } };

interface Envelope {
  type: string;
  seq: number;
  serverNow: number;
  public?: PublicState | null;
  me?: PrivateState | null;
  host?: HostState | null;
  snapshot?: RoomSnapshot;
  data?: unknown;
}

export interface ConnectionOptions {
  url: string;
  auth: () => HandshakeAuth;
  /** A rotated or new guest token arrived; persist it. */
  onToken?: (data: { token: string; expiresAt: number; playerSessionId: string; gameSessionId: string }) => void;
  /** The server refused the presented credentials (expired link, removed by host, …). */
  onAuthError?: (error: { code: string; message: string }) => void;
}

const PERSONAL_TYPES = new Set(['board.updated', 'score.updated', 'session.token', 'error', 'player.kicked']);
const DENIED = new Set(['UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND']);

/**
 * One Socket.IO connection to the game server plus the latest views it has received.
 * Every server message carries complete views, so rendering never depends on message order;
 * seq gaps still trigger a full resync to refresh room-level info.
 */
export class GameConnection {
  private socket: Socket | null = null;
  private listeners = new Set<() => void>();
  private syncTimer: ReturnType<typeof setInterval> | null = null;
  private bestRtt = Number.POSITIVE_INFINITY;
  private state: GameViewState = {
    status: 'connecting',
    snapshot: null,
    public: null,
    me: null,
    host: null,
    lastType: null,
    messageCount: 0,
    lastData: null,
    lastDataType: null,
    seq: 0,
    offsetMs: 0,
    error: null,
    kicked: false,
  };

  constructor(private readonly opts: ConnectionOptions) {}

  getState = (): GameViewState => this.state;

  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  serverNow(): number {
    return Date.now() + this.state.offsetMs;
  }

  connect(): void {
    if (this.socket) return;
    const socket = io(this.opts.url, {
      auth: (cb) => cb({ ...this.opts.auth() }),
      transports: ['websocket', 'polling'],
      reconnection: true,
      reconnectionDelay: 500,
      reconnectionDelayMax: 5_000,
      timeout: 10_000,
    });
    this.socket = socket;

    socket.on('connect', () => {
      this.set({ status: 'connected', error: null });
      this.bestRtt = Number.POSITIVE_INFINITY;
      void this.syncClock(5);
    });
    socket.on('disconnect', (reason) => {
      // 'io server disconnect' means the server closed us on purpose; don't spin.
      this.set({ status: reason === 'io server disconnect' ? 'offline' : 'reconnecting' });
    });
    socket.on('connect_error', (err) => {
      if (DENIED.has(err.message)) {
        this.set({ status: 'denied', error: { code: err.message, message: deniedMessage(err.message) } });
        socket.io.reconnection(false);
        socket.disconnect();
      } else {
        this.set({ status: 'reconnecting' });
      }
    });
    socket.onAny((type: string, env: Envelope) => this.onEnvelope(type, env));

    this.syncTimer = setInterval(() => void this.syncClock(2), 60_000);
  }

  disconnect(): void {
    if (this.syncTimer) clearInterval(this.syncTimer);
    this.syncTimer = null;
    this.socket?.disconnect();
    this.socket = null;
  }

  /** Reconnect with fresh credentials (e.g. after joining a different session). */
  reconnect(): void {
    if (!this.socket) return this.connect();
    this.socket.disconnect();
    this.socket.io.reconnection(true);
    this.socket.connect();
  }

  async emit<T = unknown>(event: string, payload: unknown, timeoutMs = 8_000): Promise<Ack<T>> {
    const socket = this.socket;
    if (!socket || !socket.connected) return { ok: false, error: { code: 'OFFLINE', message: 'Reconnecting… try again in a moment.' } };
    try {
      return (await socket.timeout(timeoutMs).emitWithAck(event, payload)) as Ack<T>;
    } catch {
      return { ok: false, error: { code: 'TIMEOUT', message: 'No answer from the game server.' } };
    }
  }

  /** For idempotent mutations (they carry an idempotencyKey): retry through brief disconnects. */
  async emitWithRetry<T = unknown>(event: string, payload: unknown, attempts = 3): Promise<Ack<T>> {
    let last: Ack<T> = { ok: false, error: { code: 'OFFLINE', message: 'Reconnecting…' } };
    for (let i = 0; i < attempts; i++) {
      last = await this.emit<T>(event, payload);
      if (last.ok || !['TIMEOUT', 'OFFLINE'].includes(last.error.code)) return last;
      await new Promise((r) => setTimeout(r, 400 * (i + 1)));
    }
    return last;
  }

  async requestState(): Promise<void> {
    const res = await this.emit<RoomSnapshot>('state.request', { lastSeq: this.state.seq });
    if (!res.ok || !res.data) return;
    // A broadcast may have overtaken the ack: never let an older snapshot of the same session win.
    const sameSession = res.data.public?.sessionId === this.state.public?.sessionId;
    if (sameSession && res.data.seq < this.state.seq) return;
    this.applySnapshot(res.data, 'room.state');
  }

  private async syncClock(samples: number): Promise<void> {
    // Each sync round picks its own best sample, so a clock correction on the device is picked up.
    this.bestRtt = Number.POSITIVE_INFINITY;
    for (let i = 0; i < samples; i++) {
      const t0 = Date.now();
      const res = await this.emit<{ t0: number; serverNow: number }>('clock.sync', { t0 }, 4_000);
      const t1 = Date.now();
      if (!res.ok || !res.data) return;
      const rtt = t1 - t0;
      if (rtt <= this.bestRtt) {
        this.bestRtt = rtt;
        this.set({ offsetMs: res.data.serverNow - (t0 + rtt / 2) });
      }
      await new Promise((r) => setTimeout(r, 120));
    }
  }

  private applySnapshot(snapshot: RoomSnapshot, type: string): void {
    this.set({
      snapshot,
      public: snapshot.public,
      me: snapshot.me,
      seq: snapshot.seq,
      lastType: type,
      messageCount: this.state.messageCount + 1,
    });
  }

  private onEnvelope(type: string, env: Envelope): void {
    if (!env || typeof env !== 'object') return;
    if (type === 'session.token' && env.data) {
      this.opts.onToken?.(env.data as Parameters<NonNullable<ConnectionOptions['onToken']>>[0]);
      return;
    }
    if (type === 'error') {
      const err = (env.data ?? { code: 'INTERNAL', message: 'Something went wrong' }) as { code: string; message: string };
      this.set({ error: err });
      this.opts.onAuthError?.(err);
      return;
    }
    if (type === 'player.kicked') {
      this.set({ kicked: true, me: null, lastType: type, messageCount: this.state.messageCount + 1 });
      this.opts.onAuthError?.({ code: 'FORBIDDEN', message: 'Removed by the host' });
      return;
    }
    if (env.snapshot) {
      if (env.host !== undefined) this.set({ host: env.host });
      return this.applySnapshot(env.snapshot, type);
    }

    // answer.result is the players' copy of the reveal broadcast, so it advances seq like any broadcast.
    const isPublic = !PERSONAL_TYPES.has(type);
    if (env.seq < this.state.seq && isPublic) return; // stale broadcast from before a resync
    const gap = isPublic && this.state.seq > 0 && env.seq > this.state.seq + 1;
    const patch: Partial<GameViewState> = { lastType: type, messageCount: this.state.messageCount + 1 };
    if (env.public !== undefined) patch.public = env.public;
    if (env.me !== undefined) patch.me = env.me;
    if (env.host !== undefined) patch.host = env.host;
    if (env.data !== undefined) {
      patch.lastData = env.data;
      patch.lastDataType = type;
    }
    // Only broadcasts advance seq; personal updates reuse it and must not hide a missed broadcast.
    if (isPublic && env.seq > this.state.seq) patch.seq = env.seq;
    this.set(patch);
    if (gap) void this.requestState();
  }

  private set(patch: Partial<GameViewState>): void {
    this.state = { ...this.state, ...patch };
    for (const l of this.listeners) l();
  }
}

function deniedMessage(code: string): string {
  switch (code) {
    case 'FORBIDDEN':
      return 'This screen is not allowed to open that game.';
    case 'NOT_FOUND':
      return 'That room no longer exists.';
    default:
      return 'This link has expired. Ask the host for a fresh one.';
  }
}
