import { describe, expect, it } from 'vitest';
import {
  ACK_ERROR_CODES,
  CLIENT_ACK_DATA,
  ENGINE_ERROR_CODES,
  GuestClaimsSchema,
  HOST_ACK_DATA,
  HostClaimsSchema,
  JWT_AUDIENCE,
  JWT_ISSUER,
  MAX_CONNECTIONS_PER_IP,
  PROTOCOL_ERROR_CODES,
  RATE_LIMIT,
  RoomSnapshotSchema,
  SessionTokenDataSchema,
  TOKEN_TTL,
  TokenClaimsSchema,
  TvClaimsSchema,
  ackSchema,
  fail,
  isAckErrorCode,
  ok,
  type Ack,
} from '../src';
import { IDS, NOW, hostState, snapshot } from './fixtures';

describe('RoomSnapshotSchema', () => {
  it('accepts a full snapshot', () => {
    expect(RoomSnapshotSchema.parse(snapshot())).toEqual(snapshot());
  });

  it('rejects a roomId that disagrees with room.id', () => {
    expect(RoomSnapshotSchema.safeParse({ ...snapshot(), roomId: IDS.venue }).success).toBe(false);
  });

  it('is strict, including nested attract data', () => {
    expect(RoomSnapshotSchema.safeParse({ ...snapshot(), tvToken: 'x' }).success).toBe(false);
    const s = structuredClone(snapshot()) as Record<string, any>;
    s.attract.nextChampionship.prize = '$100';
    expect(RoomSnapshotSchema.safeParse(s).success).toBe(false);
  });

  it('requires every key (missing seq fails)', () => {
    const { seq: _seq, ...rest } = snapshot();
    expect(RoomSnapshotSchema.safeParse(rest).success).toBe(false);
  });
});

describe('acks', () => {
  it('ok() omits data when there is none, fail() builds an error', () => {
    expect(ok()).toEqual({ ok: true });
    expect(ok({ removedOptionId: 'b' })).toEqual({ ok: true, data: { removedOptionId: 'b' } });
    expect(fail('TOO_LATE', 'The question closed')).toEqual({ ok: false, error: { code: 'TOO_LATE', message: 'The question closed' } });
    // @ts-expect-error unknown codes are a compile error
    fail('WHATEVER', 'x');
  });

  it('error codes = protocol codes + every engine code, without overlap', () => {
    expect(PROTOCOL_ERROR_CODES).toEqual([
      'BAD_REQUEST',
      'UNAUTHORIZED',
      'FORBIDDEN',
      'RATE_LIMITED',
      'NOT_FOUND',
      'ROOM_CLOSED',
      'NO_ACTIVE_SESSION',
      'NICKNAME_INVALID',
      'NICKNAME_PROFANE',
      'TEAM_NAME_INVALID',
      'INTERNAL',
    ]);
    expect(new Set(ACK_ERROR_CODES).size).toBe(PROTOCOL_ERROR_CODES.length + ENGINE_ERROR_CODES.length);
    expect(isAckErrorCode('ALREADY_ANSWERED')).toBe(true);
    expect(isAckErrorCode('NOPE')).toBe(false);
  });

  it('ackSchema validates replies against the per-event data schema', () => {
    const join = ackSchema(CLIENT_ACK_DATA['room.join']);
    const data = {
      token: 'jwt',
      playerSessionId: IDS.alice,
      gameSessionId: IDS.session,
      expiresAt: NOW + 1,
      roomId: IDS.room,
      nickname: 'Mike 2',
      teamName: null,
      snapshot: snapshot(),
    };
    expect(join.safeParse(ok(data)).success).toBe(true);
    expect(join.safeParse(fail('NICKNAME_PROFANE', 'Pick another name')).success).toBe(true);
    expect(join.safeParse({ ok: true, data: { ...data, snapshot: undefined } }).success).toBe(false);
    expect(join.safeParse({ ok: false }).success).toBe(false);
    expect(join.safeParse({ ok: false, error: { code: 'NOPE', message: '' } }).success).toBe(false);
  });

  it('events without success data reject a data payload', () => {
    const answer = ackSchema(CLIENT_ACK_DATA['answer.submit']);
    expect(answer.safeParse(ok()).success).toBe(true);
    expect(answer.safeParse({ ok: true, data: { correct: true } }).success).toBe(false);
  });

  it('host.sync and clock.sync acks parse', () => {
    const sync = ackSchema(HOST_ACK_DATA['host.sync']);
    expect(sync.safeParse(ok({ eventId: IDS.event, roomId: IDS.room, seq: 3, host: hostState() })).success).toBe(true);
    expect(sync.safeParse(ok({ eventId: null, roomId: null, seq: 0, host: null })).success).toBe(true);
    const clock = ackSchema(CLIENT_ACK_DATA['clock.sync']);
    expect(clock.safeParse(ok({ t0: 1.5, serverNow: NOW })).success).toBe(true);
  });

  it('Ack<T> narrows on ok', () => {
    const reply: Ack<{ removedOptionId?: string }> = ok({ removedOptionId: 'a' });
    if (reply.ok) expect(reply.data?.removedOptionId).toBe('a');
    else expect.unreachable();
  });
});

describe('token claims', () => {
  const registered = { iss: JWT_ISSUER, aud: JWT_AUDIENCE, iat: 1_761_750_000, exp: 1_761_771_600 };

  it('parse verified JWT payloads and strip registered claims', () => {
    const guest = { scope: 'guest', sub: IDS.alice, gsid: IDS.session, roomId: IDS.room, venueId: IDS.venue, tv: 0 } as const;
    expect(GuestClaimsSchema.parse({ ...guest, ...registered })).toEqual(guest);

    const tv = { scope: 'tv', roomId: IDS.room, venueId: IDS.venue, v: 3 } as const;
    expect(TvClaimsSchema.parse({ ...tv, iat: registered.iat })).toEqual(tv);

    const host = { scope: 'host', sub: IDS.venueUser, venueId: IDS.venue, role: 'OWNER' } as const;
    expect(HostClaimsSchema.parse({ ...host, ...registered })).toEqual(host);
  });

  it('discriminate on scope', () => {
    const tv = TokenClaimsSchema.parse({ scope: 'tv', roomId: IDS.room, venueId: IDS.venue, v: 1 });
    expect(tv.scope).toBe('tv');
    // A TV payload relabelled as host lacks host claims.
    expect(TokenClaimsSchema.safeParse({ scope: 'host', roomId: IDS.room, venueId: IDS.venue, v: 1 }).success).toBe(false);
    expect(TokenClaimsSchema.safeParse({ scope: 'admin', sub: IDS.venueUser }).success).toBe(false);
  });

  it('reject malformed claims', () => {
    const guest = { scope: 'guest', sub: IDS.alice, gsid: IDS.session, roomId: IDS.room, venueId: IDS.venue, tv: 0 };
    expect(GuestClaimsSchema.safeParse({ ...guest, sub: 'player-1' }).success).toBe(false);
    expect(GuestClaimsSchema.safeParse({ ...guest, tv: -1 }).success).toBe(false);
    expect(GuestClaimsSchema.safeParse({ ...guest, scope: 'tv' }).success).toBe(false);
    const { gsid: _gsid, ...noSession } = guest;
    expect(GuestClaimsSchema.safeParse(noSession).success).toBe(false);
    expect(HostClaimsSchema.safeParse({ scope: 'host', sub: IDS.venueUser, venueId: IDS.venue, role: 'ADMIN' }).success).toBe(false);
  });

  it('session.token data is strict and uses ms timestamps', () => {
    const data = { token: 'jwt', playerSessionId: IDS.alice, gameSessionId: IDS.session, expiresAt: NOW };
    expect(SessionTokenDataSchema.safeParse(data).success).toBe(true);
    expect(SessionTokenDataSchema.safeParse({ ...data, venueId: IDS.venue }).success).toBe(false);
    expect(SessionTokenDataSchema.safeParse({ ...data, token: '' }).success).toBe(false);
  });

  it('constants match the architecture', () => {
    expect(JWT_ISSUER).toBe('pintrush');
    expect(JWT_AUDIENCE).toBe('pintrush-game');
    expect(TOKEN_TTL).toEqual({ guestSeconds: 21_600, hostSeconds: 43_200, tvSeconds: null });
    expect(RATE_LIMIT).toEqual({ perSecond: 8, burst: 16 });
    expect(MAX_CONNECTIONS_PER_IP).toBe(300);
  });
});
