/**
 * Test driver: plays full games through reduce() with virtual time, like a tiny game server.
 * Every state handed to reduce() is deep-frozen first, so any mutation of the input throws.
 */
import {
  createSession,
  needsRound,
  nextDeadline,
  reduce,
  selectRoundQuestions,
  upcomingRound,
  type CategoryDef,
  type Command,
  type DomainEvent,
  type GameState,
  type Id,
  type Position,
  type QuestionInput,
  type ReduceResult,
  type RoundKind,
  type RoundSetup,
  type RoundState,
  type SessionInit,
} from '../src/index';

export const T0 = 1_750_000_000_000;

export const CATS: CategoryDef[] = ['pop', 'hist', 'sport', 'sci', 'geo', 'music', 'food', 'film', 'gen'].map((key) => ({
  key,
  label: key.toUpperCase(),
}));

export const MUSIC_CATS: CategoryDef[] = ['80s', '90s', 'hiphop', 'rock', 'country', 'pop', 'stage', 'theory', 'bts'].map((key) => ({
  key,
  label: `Music ${key}`,
}));

export interface Pack {
  categories: CategoryDef[];
  questions: QuestionInput[];
}

const MC_OPTIONS = ['a', 'b', 'c', 'd'];

export function mcQuestion(id: Id, category: string, extra: Partial<QuestionInput> = {}): QuestionInput {
  return {
    id,
    category,
    difficulty: 'STANDARD',
    type: 'MULTIPLE_CHOICE',
    prompt: `Prompt for ${id}?`,
    options: MC_OPTIONS.map((o) => ({ id: o, text: `Option ${o} of ${id}` })),
    correctOptionId: 'a',
    ...extra,
  };
}

export function tfQuestion(id: Id, category: string, extra: Partial<QuestionInput> = {}): QuestionInput {
  return {
    id,
    category,
    difficulty: 'STANDARD',
    type: 'TRUE_FALSE',
    prompt: `True or false: ${id}?`,
    options: [
      { id: 't', text: 'True' },
      { id: 'f', text: 'False' },
    ],
    correctOptionId: 't',
    ...extra,
  };
}

/** A varied pack: mixed difficulty, some true/false, some wildcard-eligible. */
export function makePack(categories: CategoryDef[], perCategory: number, prefix: string): Pack {
  const questions: QuestionInput[] = [];
  for (const c of categories) {
    for (let i = 0; i < perCategory; i++) {
      const id = `${prefix}-${c.key}-${i}`;
      const difficulty = i % 4 === 1 ? 'DIFFICULT' : 'STANDARD';
      const wildcardEligible = i % 3 === 0;
      questions.push(
        i % 5 === 4
          ? tfQuestion(id, c.key, { difficulty, wildcardEligible, correctOptionId: i % 2 === 0 ? 't' : 'f' })
          : mcQuestion(id, c.key, { difficulty, wildcardEligible, correctOptionId: MC_OPTIONS[i % 4] as string }),
      );
    }
  }
  return { categories, questions };
}

/** Board round with questions in category order (q0..q8) and a wildcard; all STANDARD MC, answer 'a'. */
export function boardSetup(
  id: Id,
  opts: { kind?: RoundKind; categories?: CategoryDef[]; tweak?: (i: number) => Partial<QuestionInput> } = {},
): RoundSetup {
  const categories = opts.categories ?? CATS;
  const questions = categories.map((c, i) => mcQuestion(`${id}-q${i}`, c.key, opts.tweak?.(i)));
  questions.push(mcQuestion(`${id}-wild`, (categories[0] as CategoryDef).key, { wildcardEligible: true, ...opts.tweak?.(9) }));
  return { id, kind: opts.kind ?? 'RUSH', label: 'The Rush', packKey: 'test', seed: `${id}-seed`, categories, questions };
}

export function tableWarsSetup(id: Id, tweak?: (i: number) => Partial<QuestionInput>): RoundSetup {
  const questions = Array.from({ length: 10 }, (_, i) => mcQuestion(`${id}-q${i}`, (CATS[i % 9] as CategoryDef).key, tweak?.(i)));
  return { id, kind: 'TABLE_WARS', label: 'Table Wars', packKey: 'test', seed: `${id}-seed`, categories: CATS, questions };
}

export function finalRushSetup(id: Id): RoundSetup {
  const questions = Array.from({ length: 5 }, (_, i) => mcQuestion(`${id}-q${i}`, (CATS[i] as CategoryDef).key, { difficulty: 'DIFFICULT' }));
  return { id, kind: 'FINAL_RUSH', label: 'Final Rush', packKey: 'test', seed: `${id}-seed`, categories: CATS, questions };
}

export function deepFreeze<T>(value: T): T {
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
    Object.freeze(value);
    for (const v of Object.values(value as Record<string, unknown>)) deepFreeze(v);
  }
  return value;
}

export interface AnswerDecision {
  optionId: string;
  delayMs: number;
  wildcardPosition?: Position | null;
}

export type AnswerStrategy = (args: {
  state: GameState;
  round: RoundState;
  question: QuestionInput;
  qIndex: number;
  playerId: Id;
}) => AnswerDecision | null;

export type EventHook = (game: Game, event: DomainEvent) => void;

interface Scheduled {
  at: number;
  seq: number;
  build: (now: number) => Command;
}

export class Game {
  state: GameState;
  now: number;
  present: Id[] = [];
  events: DomainEvent[] = [];
  commands: Command[] = [];
  results: ReduceResult[] = [];
  rejections: { command: Command; code: string }[] = [];
  readonly initial: GameState;
  strategy: AnswerStrategy | null = null;
  hooks: EventHook[] = [];
  packs: Record<string, Pack> = {};
  usedQuestionIds = new Set<Id>();
  /** Called after every accepted command (views/leak checks). */
  observe: ((game: Game) => void) | null = null;
  private scheduled: Scheduled[] = [];
  private seq = 0;
  private roundSeq = 0;

  constructor(state: GameState, now = T0) {
    this.state = deepFreeze(state);
    this.initial = state;
    this.now = now;
  }

  static create(init: Partial<SessionInit> = {}, now = T0): Game {
    return new Game(
      createSession({ sessionId: 'session-1', eventId: 'event-1', venueId: 'venue-1', roomId: 'room-1', mode: 'ALWAYS_ON', ...init }),
      now,
    );
  }

  send(command: Command): ReduceResult {
    const result = reduce(this.state, command);
    this.state = deepFreeze(result.state);
    this.commands.push(command);
    this.results.push(result);
    this.events.push(...result.events);
    if (!result.reply.ok) this.rejections.push({ command, code: result.reply.error?.code ?? '?' });
    for (const ev of result.events) this.onEvent(ev);
    if (result.reply.ok) this.observe?.(this);
    return result;
  }

  must(command: Command): ReduceResult {
    const result = this.send(command);
    if (!result.reply.ok) throw new Error(`${command.type} rejected: ${result.reply.error?.code} — ${result.reply.error?.message}`);
    return result;
  }

  at(now: number): this {
    this.now = now;
    return this;
  }

  later(ms: number): this {
    this.now += ms;
    return this;
  }

  tick(now = this.now): ReduceResult {
    this.now = now;
    return this.must({ type: 'TICK', now, present: this.present });
  }

  start(): ReduceResult {
    return this.must({ type: 'START_SESSION', now: this.now });
  }

  join(id: Id, nickname = id, team?: { id: Id; name: string } | null, isBot = false): ReduceResult {
    const result = this.must({ type: 'JOIN', now: this.now, player: { id, nickname, ...(isBot ? { isBot } : {}) }, team: team ?? null });
    if (!this.present.includes(id)) this.present.push(id);
    return result;
  }

  startRound(round: RoundSetup): ReduceResult {
    return this.must({ type: 'START_ROUND', now: this.now, round });
  }

  answer(playerId: Id, optionId: string, extra: { wildcardPosition?: Position | null; key?: string; questionId?: Id } = {}): ReduceResult {
    const r = this.state.round;
    const questionId = extra.questionId ?? (r && r.qIndex >= 0 ? r.questions[r.qIndex]?.id : undefined) ?? 'none';
    return this.send({
      type: 'SUBMIT_ANSWER',
      now: this.now,
      playerId,
      questionId,
      optionId,
      wildcardPosition: extra.wildcardPosition ?? null,
      idempotencyKey: extra.key ?? `ans:${playerId}:${questionId}`,
    });
  }

  schedule(at: number, build: (now: number) => Command): void {
    this.scheduled.push({ at, seq: this.seq++, build });
  }

  get round(): RoundState {
    const r = this.state.round;
    if (!r) throw new Error('No round running');
    return r;
  }

  get question(): QuestionInput {
    const q = this.round.questions[this.round.qIndex];
    if (!q) throw new Error('No current question');
    return q;
  }

  eventsOf<T extends DomainEvent['type']>(type: T): Extract<DomainEvent, { type: T }>[] {
    return this.events.filter((e): e is Extract<DomainEvent, { type: T }> => e.type === type);
  }

  /** Round setup for the next due round, from the registered packs. */
  buildRound(): RoundSetup {
    const next = upcomingRound(this.state);
    if (!next) throw new Error('No round due');
    const pack = this.packs[next.packKey];
    if (!pack) throw new Error(`No pack ${next.packKey} registered`);
    const seed = `${this.state.sessionId}:${this.state.roundsStarted}`;
    const questions = selectRoundQuestions({ kind: next.kind, pool: pack.questions, categories: pack.categories, seed, exclude: this.usedQuestionIds });
    for (const q of questions) this.usedQuestionIds.add(q.id);
    return { id: `round-${++this.roundSeq}`, kind: next.kind, label: next.label, packKey: next.packKey, seed, categories: pack.categories, questions };
  }

  /** One driver step: start a due round, deliver the next scheduled command, or TICK at the next deadline. */
  step(): void {
    if (needsRound(this.state) && Object.keys(this.packs).length > 0) {
      this.must({ type: 'START_ROUND', now: this.now, round: this.buildRound() });
      return;
    }
    const deadline = nextDeadline(this.state);
    this.scheduled.sort((a, b) => a.at - b.at || a.seq - b.seq);
    const next = this.scheduled[0];
    if (next && (deadline === null || next.at <= deadline)) {
      this.scheduled.shift();
      this.now = Math.max(this.now, next.at);
      this.send(next.build(this.now));
      return;
    }
    if (deadline === null) throw new Error(`Game stalled at ${this.now - T0} ms (status ${this.state.status})`);
    this.tick(Math.max(this.now, deadline));
  }

  runUntil(stop: (g: Game) => boolean, maxSteps = 200_000): void {
    for (let i = 0; i < maxSteps; i++) {
      if (stop(this)) return;
      this.step();
    }
    throw new Error('runUntil: step limit reached');
  }

  /** Tick through deadlines until the round reaches `phase` at question `qIndex`. */
  advanceTo(phase: RoundState['phase'], qIndex?: number): void {
    this.runUntil((g) => g.state.round?.phase === phase && (qIndex === undefined || g.state.round.qIndex === qIndex), 1_000);
  }

  private onEvent(ev: DomainEvent): void {
    if (ev.type === 'QuestionOpened' && this.strategy) {
      const round = this.state.round;
      const question = round?.questions[ev.qIndex];
      if (round && question) {
        for (const playerId of round.participants) {
          const d = this.strategy({ state: this.state, round, question, qIndex: ev.qIndex, playerId });
          if (!d) continue;
          const roundId = round.id;
          this.schedule(ev.openedAt + d.delayMs, (now) => ({
            type: 'SUBMIT_ANSWER',
            now,
            playerId,
            questionId: question.id,
            optionId: d.optionId,
            wildcardPosition: d.wildcardPosition ?? null,
            idempotencyKey: `auto:${roundId}:${question.id}:${playerId}`,
          }));
        }
      }
    }
    for (const hook of this.hooks) hook(this, ev);
  }
}

/** Sum of ScoreAwarded points per player (optionally for one round). */
export function pointsByPlayer(events: readonly DomainEvent[], roundId?: Id): Record<Id, number> {
  const out: Record<Id, number> = {};
  for (const e of events) {
    if (e.type !== 'ScoreAwarded' || (roundId !== undefined && e.roundId !== roundId)) continue;
    out[e.playerId] = (out[e.playerId] ?? 0) + e.points;
  }
  return out;
}

export function wrongOption(q: QuestionInput): string {
  const w = q.options.find((o) => o.id !== q.correctOptionId);
  if (!w) throw new Error('No wrong option');
  return w.id;
}
