import { describe, expect, it } from 'vitest';

import {
  isEventEligible,
  selectNextEligibleEvent,
} from '../../src/events/v2/engine/selector.js';
import type { EventDefinition, EventPlayerContext } from '../../src/events/v2/types.js';

/**
 * Deterministic, seedable RNG (mulberry32). Returns values in [0, 1).
 * Used so weighted-random selection is reproducible in tests.
 */
function seededRng(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function makePlayer(overrides: Partial<EventPlayerContext> = {}): EventPlayerContext {
  return {
    userId: 'player-1',
    c: { ageYears: 25 },
    askedQuestions: new Set<string>(),
    eventLastFired: {},
    ...overrides,
  };
}

function event(id: string, extra: Partial<EventDefinition> = {}): EventDefinition {
  return {
    id,
    category: 'test',
    prompt: `${id} prompt`,
    choices: [{ choiceId: 'ok', text: 'Ok' }],
    ...extra,
  };
}

describe('weighted selection', () => {
  it('picks a deterministic event for a given seed', () => {
    const definitions = [event('low', { weight: 1 }), event('high', { weight: 9 })];
    const player = makePlayer();

    // Same seed -> same pick, repeatably.
    const rng = seededRng(12345);
    const first = selectNextEligibleEvent(definitions, player, undefined, rng);
    const rng2 = seededRng(12345);
    const second = selectNextEligibleEvent(definitions, player, undefined, rng2);

    expect(first?.id).toBe(second?.id);
  });

  it('picks higher-weight events more often across many seeded rolls', () => {
    const definitions = [event('low', { weight: 1 }), event('high', { weight: 9 })];
    const player = makePlayer();
    const rng = seededRng(42);

    const counts: Record<string, number> = { low: 0, high: 0 };
    for (let i = 0; i < 2000; i++) {
      const picked = selectNextEligibleEvent(definitions, player, undefined, rng);
      counts[picked!.id]++;
    }

    // Weight 9 vs 1 -> ~90% high. Allow generous slack but it must dominate.
    expect(counts.high).toBeGreaterThan(counts.low * 3);
    expect(counts.low).toBeGreaterThan(0); // low still gets picked sometimes
  });

  it('returns the single eligible event regardless of RNG (backward compatible)', () => {
    const definitions = [
      event('child_only', { minAge: 0, maxAge: 12 }),
      event('adult_only', { minAge: 18 }),
    ];
    const player = makePlayer({ c: { ageYears: 25 } });

    // Only adult_only is age-eligible; result must be deterministic.
    for (let seed = 0; seed < 5; seed++) {
      const picked = selectNextEligibleEvent(definitions, player, undefined, seededRng(seed));
      expect(picked?.id).toBe('adult_only');
    }
  });
});

describe('repeatable vs once-ever', () => {
  it('fires a non-repeatable event only once (askedQuestions gate)', () => {
    const def = event('once', { repeatable: false });
    const player = makePlayer();

    expect(isEventEligible(def, player)).toBe(true);

    // Simulate the firing/answer flow recording it in askedQuestions.
    (player.askedQuestions as Set<string>).add('once');

    expect(isEventEligible(def, player)).toBe(false);
    expect(selectNextEligibleEvent([def], player)).toBeNull();
  });

  it('fires a repeatable event more than once (skips once-ever gate)', () => {
    const def = event('repeat', { repeatable: true });
    const player = makePlayer();

    expect(isEventEligible(def, player)).toBe(true);

    // Even after being recorded in askedQuestions, a repeatable event stays eligible.
    (player.askedQuestions as Set<string>).add('repeat');

    expect(isEventEligible(def, player)).toBe(true);
    expect(selectNextEligibleEvent([def], player)?.id).toBe('repeat');
  });
});

describe('cooldown', () => {
  it('skips a cooled-down event within the window and re-enables it after', () => {
    const def = event('cooled', { repeatable: true, cooldownDays: 5 });
    const player = makePlayer();

    // Never fired -> eligible.
    expect(isEventEligible(def, player, 10)).toBe(true);

    // Fired on day 10.
    player.eventLastFired = { cooled: 10 };

    // Day 12 -> 2 days elapsed (< 5) -> ineligible.
    expect(isEventEligible(def, player, 12)).toBe(false);
    expect(selectNextEligibleEvent([def], player, 12)).toBeNull();

    // Day 14 -> 4 days elapsed (< 5) -> still ineligible.
    expect(isEventEligible(def, player, 14)).toBe(false);

    // Day 15 -> exactly 5 days elapsed (>= cooldown) -> eligible again.
    expect(isEventEligible(def, player, 15)).toBe(true);
    expect(selectNextEligibleEvent([def], player, 15)?.id).toBe('cooled');
  });

  it('treats omitted cooldown as no cooldown', () => {
    const def = event('nocd', { repeatable: true });
    const player = makePlayer({ eventLastFired: { nocd: 100 } });

    // No cooldownDays -> always eligible regardless of last-fired day.
    expect(isEventEligible(def, player, 100)).toBe(true);
    expect(isEventEligible(def, player, 101)).toBe(true);
  });
});
