import { describe, it, expect, vi } from 'vitest';
import type { PushEvent } from '../model';

// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
// so games live in the source's in-memory cache — created once, then driven without reload).
vi.mock('../dict', () => ({
  getDawg: async () => {
    const { Dawg } = await import('../dict/dawg');
    const { readFileSync } = await import('node:fs');
    return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
  },
}));

import { LocalSource, isLocalGameId } from './source';
import type { Seat } from './serialize';
import { hasAlphabet, alphabetValues } from '../alphabet';

const seats: Seat[] = [
  { kind: 'human', name: 'You' },
  { kind: 'robot', name: 'Robot' },
];

async function newSource(id: string, seed: bigint): Promise<LocalSource> {
  const src = new LocalSource();
  await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats });
  return src;
}

describe('LocalSource', () => {
  it('detects local game ids', () => {
    expect(isLocalGameId('local:abc')).toBe(true);
    expect(isLocalGameId('deadbeef')).toBe(false);
  });

  it('creates a vs_ai game with the human to move and a full rack', async () => {
    const src = await newSource('local:g1', 42n);
    const st = await src.gameState('local:g1');
    expect(st.game.vsAi).toBe(true);
    expect(st.game.status).toBe('active');
    expect(st.seat).toBe(0);
    expect(st.rack.length).toBe(7);
    expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']);
    expect(st.game.toMove).toBe(0);
  });

  it('seeds the per-variant alphabet from the ruleset, so offline tiles show real weights not zeros', async () => {
    // Offline there is no server to send the alphabet table the rack/board render from; opening a
    // local game must seed it from the static ruleset. Without this a cold offline boot draws zeros.
    const src = await newSource('local:gAlpha', 42n);
    await src.gameState('local:gAlpha');
    expect(hasAlphabet('scrabble_en')).toBe(true);
    const values = alphabetValues('scrabble_en');
    expect(values.length).toBe(26);
    expect(values[0]).toBe(1); // A = 1 (pinned to rules.go / ruleset.ts), not 0
    expect(values.every((v) => v > 0)).toBe(true);
  });

  it('replies to a human pass with a synchronous robot move, delivered via the event', async () => {
    const src = await newSource('local:g2', 999n);
    const events: PushEvent[] = [];
    src.events('local:g2', (e) => events.push(e));

    const res = await src.pass('local:g2');
    expect(res.move.action).toBe('pass');
    expect(res.move.player).toBe(0);

    // The robot has already played (synchronously) — the state reflects both moves...
    const mid = await src.gameState('local:g2');
    expect(mid.game.moveCount).toBeGreaterThanOrEqual(2);
    // ...and its move is delivered to subscribers via the per-game event.
    await Promise.resolve();
    expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
  });

  it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => {
    const src = await newSource('local:g3', 7n);
    // hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for
    // this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client
    // (lib/hints, measured with performance.now()) so a device clock change cannot defeat it.
    await src.hint('local:g3').then(
      (h) => expect(h.move).toBeDefined(),
      (e) => expect((e as { code: string }).code).toBe('no_hint'),
    );
  });

  it('records decoded moves in the history', async () => {
    const src = await newSource('local:g4', 55n);
    await src.pass('local:g4');
    const h = await src.gameHistory('local:g4');
    expect(h.moves.length).toBeGreaterThanOrEqual(2);
    expect(h.moves[0].action).toBe('pass');
    // the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words)
    const play = h.moves.find((m) => m.action === 'play');
    if (play) {
      expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true);
      expect(play.words.every((w) => w === w.toLowerCase())).toBe(true);
    }
  });

  it('persists the draft, so reloading the state keeps the composition', async () => {
    // The game screen refetches the state on several triggers (a stream reconnect, a resume). With
    // no stored draft every one of those wiped the player's arrangement back into the rack.
    const src = await newSource('local:gDraft', 42n);
    expect(await src.draftGet('local:gDraft')).toBe('');
    await src.draftSave('local:gDraft', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
    expect(await src.draftGet('local:gDraft')).toBe('{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
  });

  it('drops the draft when the turn advances, so a hotseat seat never inherits it', async () => {
    const src = await newSource('local:gDraft2', 42n);
    await src.draftSave('local:gDraft2', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
    await src.pass('local:gDraft2');
    expect(await src.draftGet('local:gDraft2')).toBe('');
  });

  it('drives a whole local game to completion', async () => {
    const src = await newSource('local:g5', 2024n);
    src.events('local:g5', () => {});
    let turns = 0;
    while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) {
      await src.pass('local:g5');
      await Promise.resolve();
      turns++;
    }
    const st = await src.gameState('local:g5');
    expect(st.game.status).toBe('finished');
    expect(turns).toBeLessThan(500);
    const winners = st.game.seats.filter((s) => s.isWinner);
    expect(winners.length).toBeLessThanOrEqual(1);
  });
});
