/**
 * Review findings at the RoomActor level, driven by a fake clock and in-memory stores so timing is
 * deterministic (no Postgres needed). Each `it.fails` asserts the CORRECT behaviour and currently
 * fails; flip it to `it` once fixed.
 */
import { randomUUID } from 'node:crypto';
import type { QuestionInput } from '@pintrush/engine';
import type { Socket } from 'socket.io';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TokenService } from '../src/auth';
import type { Clock, TimerHandle } from '../src/clock';
import { loadConfig } from '../src/config';
import { outgoingViolations, setOutgoingValidation } from '../src/outgoing';
import { TokenBucket } from '../src/rateLimit';
import { RoomActor } from '../src/rooms/RoomActor';
import type { Attached } from '../src/rooms/types';
import { MemorySessionStore } from '../src/store/memorySessionStore';
import type { Catalog, EventRecord, PackRecord, PersistBatch, RoomRecord, VenueRecord } from '../src/store/types';
import { CATEGORIES, SECRET } from './helpers';

const DATE = '2026-10-29';
const NEXT_DATE = '2026-10-30';
/** 2026-10-29 19:00 in New York. */
const START = Date.UTC(2026, 9, 29, 23, 0, 0);
const HOUR = 3_600_000;

/** Deterministic timers: advance() fires due timers in time order and lets promises settle after each. */
class FakeClock implements Clock {
  private t: number;
  private nextId = 0;
  private readonly timers = new Map<number, { at: number; fn: () => void; every: number | null }>();

  constructor(start: number) {
    this.t = start;
  }

  now = (): number => this.t;
  setTimeout = (fn: () => void, ms: number): TimerHandle => this.add(fn, ms, null);
  setInterval = (fn: () => void, ms: number): TimerHandle => this.add(fn, ms, ms);
  clearTimeout = (h: TimerHandle): void => void this.timers.delete(h as unknown as number);
  clearInterval = (h: TimerHandle): void => void this.timers.delete(h as unknown as number);

  private add(fn: () => void, ms: number, every: number | null): TimerHandle {
    const id = ++this.nextId;
    this.timers.set(id, { at: this.t + Math.max(0, ms), fn, every });
    return id as unknown as TimerHandle;
  }

  async advance(ms: number): Promise<void> {
    const target = this.t + ms;
    for (;;) {
      let dueId = -1;
      let due: { at: number; fn: () => void; every: number | null } | undefined;
      for (const [id, timer] of this.timers) {
        if (timer.at <= target && (!due || timer.at < due.at)) {
          due = timer;
          dueId = id;
        }
      }
      if (!due) break;
      this.t = Math.max(this.t, due.at);
      if (due.every === null) this.timers.delete(dueId);
      else due.at += due.every;
      due.fn();
      await settle();
    }
    this.t = target;
    await settle();
  }
}

const settle = () => new Promise<void>((resolve) => setImmediate(resolve));

/** Memory store whose persist() can be held (to let batches queue up) and made to reject "poison" batches. */
class ScriptedStore extends MemorySessionStore {
  readonly committed: PersistBatch[] = [];
  poison: (b: PersistBatch) => boolean = () => false;
  private gate: Promise<void> | null = null;

  hold(): () => void {
    let release!: () => void;
    this.gate = new Promise<void>((r) => (release = r));
    return () => {
      this.gate = null;
      release();
    };
  }

  override async persist(batches: PersistBatch[], expectedVersion: number): Promise<number> {
    if (this.gate) await this.gate;
    // Like Postgres: one bad batch aborts the whole transaction.
    if (batches.some((b) => this.poison(b))) throw new Error('simulated constraint violation');
    this.committed.push(...batches);
    return super.persist(batches, expectedVersion);
  }
}

function makePack(): PackRecord {
  const questions: QuestionInput[] = CATEGORIES.flatMap((c, ci) =>
    Array.from({ length: 6 }, (_, qi) => ({
      id: randomUUID(),
      category: c.key,
      difficulty: 'STANDARD' as const,
      type: 'MULTIPLE_CHOICE' as const,
      prompt: `${c.label} ${qi}?`,
      options: ['a', 'b', 'c', 'd'].map((id) => ({ id, text: `${c.label} ${qi} ${id}` })),
      correctOptionId: ['a', 'b', 'c', 'd'][(ci + qi) % 4]!,
      wildcardEligible: qi % 3 === 0,
    })),
  );
  return { id: randomUUID(), key: 'review-pack', categories: CATEGORIES, questions };
}

describe('review: RoomActor (fake clock)', () => {
  let clock: FakeClock;
  let venue: VenueRecord;
  let room: RoomRecord;
  let events: Map<string, EventRecord>;
  let catalog: Catalog;
  let actors: RoomActor[];

  beforeEach(() => {
    setOutgoingValidation(true);
    clock = new FakeClock(START);
    venue = {
      id: randomUUID(),
      slug: 'review-bar',
      name: 'Review Bar',
      joinCode: 'REVIEW1',
      timezone: 'America/New_York',
      settings: { alwaysOn: { enabled: true, packKey: 'review-pack', schedule: [] }, mergeSolos: false },
    };
    room = { id: randomUUID(), venueId: venue.id, name: 'Main Bar', slug: 'main', tvTokenVersion: 1, status: 'ACTIVE' };
    events = new Map();
    const pack = makePack();
    catalog = {
      findVenueByJoinCode: async () => venue,
      findVenue: async () => venue,
      listRooms: async () => [room],
      findRoom: async () => room,
      findEvent: async (id) => events.get(id) ?? null,
      listAlwaysOnVenues: async () => [venue],
      championshipsToOpen: async () => [],
      nextChampionship: async () => null,
      loadPack: async () => pack,
    };
    actors = [];
  });

  afterEach(async () => {
    for (const a of actors) await a.stop();
  });

  afterAll(() => {
    // Every envelope sent in this file matched the strict protocol schema.
    expect(outgoingViolations).toEqual([]);
  });

  function makeActor(store: MemorySessionStore): RoomActor {
    const actor = new RoomActor(
      { clock, catalog, store, tokens: new TokenService(SECRET), config: loadConfig({ authSecret: SECRET, enableScheduler: false, validateOutgoing: true }) },
      room,
      venue,
      false,
    );
    actors.push(actor);
    return actor;
  }

  function socketFor(role: Attached['identity']): Attached {
    const socket = { id: randomUUID(), emit: () => true } as unknown as Socket;
    return { socket, identity: role, ip: '127.0.0.1', bucket: new TokenBucket(8, 16, clock.now) };
  }

  async function joinPhone(actor: RoomActor, nickname: string): Promise<{ a: Attached; playerId: string }> {
    const a = socketFor({ role: 'anon' });
    const r = await actor.join(a, { nickname, teamName: null, deviceId: randomUUID(), idempotencyKey: randomUUID() });
    if (!r.ok) throw new Error(`join failed: ${r.error.code}`);
    return { a, playerId: r.data.playerSessionId };
  }

  // RoomActor.flush (lines 452-478) persists ALL pending batches in one transaction; after 5 failures
  // it drops `pendingWrites.shift()` — the OLDEST batch, not the one that fails. A poison batch queued
  // behind good ones therefore costs every good batch ahead of it (≈15 s of retries each, with nothing
  // persisted meanwhile). Observed live: the same-team-name join race (see review-races) produced
  // FK-violating JOIN batches and the server dropped a good TICK and good JOINs first.
  it('a poison batch is dropped on its own; good batches queued with it still persist', async () => {
    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
    const store = new ScriptedStore(clock.now);
    store.poison = (b) => b.command.type === 'RENAME_PLAYER';
    const actor = makeActor(store);
    await actor.ensureAlwaysOn(DATE);
    const ann = await joinPhone(actor, 'Ann');
    const bob = await joinPhone(actor, 'Bob');
    const cy = await joinPhone(actor, 'Cy');
    await settle();

    // Under load batches pile up behind an in-flight transaction: hold the next persist().
    const release = store.hold();
    await actor.setTeam(ann.a, 'Xray', clock.now()); // in flight
    await actor.setTeam(bob.a, 'Alpha', clock.now()); // good, queued
    await actor.setTeam(cy.a, 'Bravo', clock.now()); // good, queued
    const host = socketFor({ role: 'host', venueUserId: randomUUID(), venueId: venue.id, eventId: null });
    const eventId = actor.alwaysOnSession()!.event.id;
    expect((await actor.moderate(host, { eventId, playerSessionId: ann.playerId, action: 'rename', nickname: 'Annie' }, clock.now())).ok).toBe(true); // poison
    release();

    await clock.advance(120_000); // plenty for 3 × (5 attempts with 1+2+4+8 s backoff)
    errors.mockRestore();
    const persistedTeams = store.committed.filter((b) => b.command.type === 'SET_TEAM').map((b) => (b.command as { team: { name: string } | null }).team?.name);
    expect(store.committed.some((b) => b.command.type === 'RENAME_PLAYER')).toBe(false);
    expect(persistedTeams).toEqual(['Xray', 'Alpha', 'Bravo']);
  });

  // Nothing ever closes a WAITING lobby: openEvent activates it (line 305), buildRound refuses every
  // non-active session (line 421), and ensureAlwaysOn only activates a new always-on session when the
  // room has NO active session (line 259). If the host never starts a scheduled show (no-show,
  // cancelled night), the room plays no always-on games — not that night, not the next day — until
  // someone ends the event by hand. championshipsToOpen already treats shows > 6 h late as stale.
  it('a championship lobby nobody starts stops blocking always-on (at the latest the next business day)', async () => {
    const store = new MemorySessionStore(clock.now);
    const show: EventRecord = {
      id: randomUUID(),
      venueId: venue.id,
      roomId: room.id,
      kind: 'CHAMPIONSHIP',
      status: 'SCHEDULED',
      name: 'Friday Night Championship',
      scheduledAt: START + 15 * 60_000,
      businessDate: null,
      config: { plan: [{ kind: 'RUSH', packKey: 'review-pack', label: 'The Rush', segment: 0 }], mergeSolos: false },
    };
    events.set(show.id, show);
    const actor = makeActor(store);
    await actor.ensureAlwaysOn(DATE);
    await joinPhone(actor, 'Regular');
    await clock.advance(1_000);
    expect(actor.alwaysOnSession()!.state.roundsStarted).toBe(1);

    await actor.openEvent(show.id); // RoomManager.runScheduler: lobby opens 15 min early
    await clock.advance(12 * HOUR); // the host never shows up; it is now 07:00 the next day
    await actor.ensureAlwaysOn(NEXT_DATE); // scheduler: new business date
    await clock.advance(5 * 60_000);

    expect(actor.activeSession()?.event.kind).toBe('ALWAYS_ON');
    expect(actor.alwaysOnSession()!.event.businessDate).toBe(NEXT_DATE);
  }, 60_000);
});
