import { describe, expect, it } from 'vitest';
import {
  CLIENT_EVENTS,
  EVENT_ACCESS,
  HOST_EVENTS,
  INBOUND_EVENTS,
  canCall,
  isClientEventName,
  isHostEventName,
  type InboundEventName,
  type InboundPayloadInput,
} from '../src';
import { IDS, KEY } from './fixtures';

const VALID: { [E in InboundEventName]: InboundPayloadInput<E>[] } = {
  'room.join': [
    { venueCode: 'DEMO', nickname: 'Mike', deviceId: IDS.device, idempotencyKey: KEY },
    { venueCode: 'demo', roomId: IDS.room, nickname: 'Mike', teamName: 'Quizzly Bears', deviceId: IDS.device, idempotencyKey: KEY },
    { venueCode: 'DEMO', nickname: 'Mike', teamName: null, deviceId: IDS.device, idempotencyKey: crypto.randomUUID() },
  ],
  'answer.submit': [
    { questionId: IDS.question, optionId: 'c', idempotencyKey: KEY },
    { questionId: IDS.question, optionId: 'a', wildcardPosition: 8, idempotencyKey: KEY },
    { questionId: IDS.question, optionId: 'true', wildcardPosition: null, idempotencyKey: KEY },
  ],
  'powerup.activate': [
    { type: 'DOUBLE_DOWN', idempotencyKey: KEY },
    { type: 'DOUBLE_DOWN', questionId: IDS.question, idempotencyKey: KEY },
    { type: 'SECOND_CHANCE', questionId: IDS.question, idempotencyKey: KEY },
    { type: 'CATEGORY_SWAP', from: 0, to: 8, idempotencyKey: KEY },
  ],
  'state.request': [{}, { lastSeq: 0 }, { lastSeq: 1234 }],
  'team.set': [
    { teamName: 'Quizzly Bears', idempotencyKey: KEY },
    { teamName: null, idempotencyKey: KEY },
  ],
  'final.predict': [{ finalistId: IDS.bob, idempotencyKey: KEY }],
  'clock.sync': [{ t0: 1_761_750_000_000 }, { t0: 12_345.678 }],
  'event.open': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'event.start': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'event.pause': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'event.resume': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'event.end': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'round.advance': [{ eventId: IDS.event, idempotencyKey: KEY }],
  'nickname.moderate': [
    { eventId: IDS.event, playerSessionId: IDS.alice, action: 'rename', nickname: 'Player 7', idempotencyKey: KEY },
    { eventId: IDS.event, playerSessionId: IDS.alice, action: 'kick', idempotencyKey: KEY },
  ],
  'host.sync': [{}, { eventId: IDS.event }, { eventId: IDS.event, idempotencyKey: KEY }],
};

const INVALID: { [E in InboundEventName]: [label: string, payload: unknown][] } = {
  'room.join': [
    ['bad deviceId uuid', { venueCode: 'DEMO', nickname: 'Mike', deviceId: 'device-1', idempotencyKey: KEY }],
    ['bad roomId uuid', { venueCode: 'DEMO', roomId: 'room-1', nickname: 'Mike', deviceId: IDS.device, idempotencyKey: KEY }],
    ['venue code with symbols', { venueCode: 'DE-MO', nickname: 'Mike', deviceId: IDS.device, idempotencyKey: KEY }],
    ['empty nickname', { venueCode: 'DEMO', nickname: '', deviceId: IDS.device, idempotencyKey: KEY }],
    ['41-char nickname', { venueCode: 'DEMO', nickname: 'x'.repeat(41), deviceId: IDS.device, idempotencyKey: KEY }],
    ['missing idempotencyKey', { venueCode: 'DEMO', nickname: 'Mike', deviceId: IDS.device }],
    ['extra key', { venueCode: 'DEMO', nickname: 'Mike', deviceId: IDS.device, idempotencyKey: KEY, isHost: true }],
  ],
  'answer.submit': [
    ['wildcard position 9', { questionId: IDS.question, optionId: 'c', wildcardPosition: 9, idempotencyKey: KEY }],
    ['bad questionId', { questionId: 'q1', optionId: 'c', idempotencyKey: KEY }],
    ['option id too long', { questionId: IDS.question, optionId: 'abcdefghi', idempotencyKey: KEY }],
    ['missing optionId', { questionId: IDS.question, idempotencyKey: KEY }],
    ['client-supplied time', { questionId: IDS.question, optionId: 'c', idempotencyKey: KEY, now: 1 }],
  ],
  'powerup.activate': [
    ['CATEGORY_SWAP from == to', { type: 'CATEGORY_SWAP', from: 3, to: 3, idempotencyKey: KEY }],
    ['CATEGORY_SWAP missing to', { type: 'CATEGORY_SWAP', from: 3, idempotencyKey: KEY }],
    ['CATEGORY_SWAP missing both', { type: 'CATEGORY_SWAP', idempotencyKey: KEY }],
    ['CATEGORY_SWAP to = 9', { type: 'CATEGORY_SWAP', from: 0, to: 9, idempotencyKey: KEY }],
    ['DOUBLE_DOWN with tiles', { type: 'DOUBLE_DOWN', from: 0, to: 1, idempotencyKey: KEY }],
    ['SECOND_CHANCE with from', { type: 'SECOND_CHANCE', from: 0, idempotencyKey: KEY }],
    ['unknown type', { type: 'FREEZE', idempotencyKey: KEY }],
    ['missing idempotencyKey', { type: 'DOUBLE_DOWN' }],
  ],
  'state.request': [
    ['negative lastSeq', { lastSeq: -1 }],
    ['fractional lastSeq', { lastSeq: 1.5 }],
    ['extra key', { lastSeq: 1, full: true }],
  ],
  'team.set': [
    ['missing teamName', { idempotencyKey: KEY }],
    ['team name too long', { teamName: 'x'.repeat(41), idempotencyKey: KEY }],
  ],
  'final.predict': [['bad finalistId', { finalistId: 'p1', idempotencyKey: KEY }]],
  'clock.sync': [
    ['string t0', { t0: '123' }],
    ['infinite t0', { t0: Number.POSITIVE_INFINITY }],
    ['missing t0', {}],
  ],
  'event.open': [['missing eventId', { idempotencyKey: KEY }]],
  'event.start': [['missing idempotencyKey', { eventId: IDS.event }]],
  'event.pause': [['bad eventId', { eventId: 'e1', idempotencyKey: KEY }]],
  'event.resume': [['extra key', { eventId: IDS.event, idempotencyKey: KEY, venueId: IDS.venue }]],
  'event.end': [['null eventId', { eventId: null, idempotencyKey: KEY }]],
  'round.advance': [['missing everything', {}]],
  'nickname.moderate': [
    ['rename without nickname', { eventId: IDS.event, playerSessionId: IDS.alice, action: 'rename', idempotencyKey: KEY }],
    ['kick with nickname', { eventId: IDS.event, playerSessionId: IDS.alice, action: 'kick', nickname: 'x', idempotencyKey: KEY }],
    ['unknown action', { eventId: IDS.event, playerSessionId: IDS.alice, action: 'ban', idempotencyKey: KEY }],
    ['bad playerSessionId', { eventId: IDS.event, playerSessionId: 'p1', action: 'kick', idempotencyKey: KEY }],
  ],
  'host.sync': [['bad eventId', { eventId: 'nope' }]],
};

describe('inbound payload schemas', () => {
  const names = Object.keys(INBOUND_EVENTS) as InboundEventName[];

  it('cover exactly the spec + architecture event set', () => {
    expect(Object.keys(CLIENT_EVENTS).sort()).toEqual(
      ['answer.submit', 'clock.sync', 'final.predict', 'powerup.activate', 'room.join', 'state.request', 'team.set'].sort(),
    );
    expect(Object.keys(HOST_EVENTS).sort()).toEqual(
      [
        'event.end',
        'event.open',
        'event.pause',
        'event.resume',
        'event.start',
        'host.sync',
        'nickname.moderate',
        'round.advance',
      ].sort(),
    );
  });

  describe.each(names)('%s', (name) => {
    const schema = INBOUND_EVENTS[name];

    it.each(VALID[name].map((p, i) => [i, p] as const))('accepts valid sample #%i', (_i, payload) => {
      const result = schema.safeParse(payload);
      expect(result.error?.issues).toBeUndefined();
      expect(result.data).toEqual(payload);
    });

    it.each(INVALID[name])('rejects %s', (_label, payload) => {
      expect(schema.safeParse(payload).success).toBe(false);
    });

    it('rejects non-object payloads', () => {
      for (const payload of [null, undefined, 'x', 1, []]) expect(schema.safeParse(payload).success).toBe(false);
    });
  });

  it('reports CATEGORY_SWAP from == to on the `to` field', () => {
    const result = CLIENT_EVENTS['powerup.activate'].safeParse({ type: 'CATEGORY_SWAP', from: 2, to: 2, idempotencyKey: KEY });
    expect(result.success).toBe(false);
    expect(result.error?.issues.map((i) => i.path.join('.'))).toEqual(['to']);
  });
});

describe('event name guards', () => {
  it('recognise own names only (no prototype keys)', () => {
    expect(isClientEventName('room.join')).toBe(true);
    expect(isClientEventName('event.start')).toBe(false);
    expect(isHostEventName('event.start')).toBe(true);
    expect(isClientEventName('toString')).toBe(false);
    expect(isHostEventName('__proto__')).toBe(false);
  });
});

describe('EVENT_ACCESS', () => {
  const names = Object.keys(INBOUND_EVENTS) as InboundEventName[];

  it('defines access for every inbound event', () => {
    expect(Object.keys(EVENT_ACCESS).sort()).toEqual([...names].sort());
  });

  it('lets anonymous sockets call only room.join and clock.sync', () => {
    expect(names.filter((n) => canCall('anonymous', n)).sort()).toEqual(['clock.sync', 'room.join']);
  });

  it('keeps TV tokens display-only', () => {
    expect(names.filter((n) => canCall('tv', n)).sort()).toEqual(['clock.sync', 'state.request']);
  });

  it('requires a host token for every host event', () => {
    for (const name of Object.keys(HOST_EVENTS) as InboundEventName[]) {
      expect(EVENT_ACCESS[name]).toEqual(['host']);
    }
  });

  it('never lets hosts submit player actions', () => {
    for (const name of ['answer.submit', 'powerup.activate', 'final.predict', 'team.set'] as const) {
      expect(canCall('host', name)).toBe(false);
      expect(canCall('guest', name)).toBe(true);
    }
  });
});
