import { describe, it, expect } from 'vitest';
import { parseDice, rollDice, applyRollMode, DiceParseError } from './notation';
import { createRng } from '@/lib/rng';

describe('applyRollMode', () => {
  it('turns a d20 into 2d20kh1 for advantage, keeping modifiers', () => {
    expect(applyRollMode('1d20+5', 'advantage')).toBe('2d20kh1+5');
    expect(applyRollMode('d20', 'disadvantage')).toBe('2d20kl1');
  });
  it('leaves non-d20 and normal rolls unchanged', () => {
    expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3');
    expect(applyRollMode('1d20+5', 'normal')).toBe('1d20+5');
  });
  it('only transforms the first die term', () => {
    expect(applyRollMode('1d20+1d20', 'advantage')).toBe('2d20kh1+1d20');
  });
  it('applies to any single die, not just d20', () => {
    expect(applyRollMode('1d4', 'advantage')).toBe('2d4kh1');
    expect(applyRollMode('d6', 'disadvantage')).toBe('2d6kl1');
    expect(applyRollMode('1d8+3', 'advantage')).toBe('2d8kh1+3');
  });
  it('does not touch multi-die pools or terms that already keep/drop', () => {
    expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3');
    expect(applyRollMode('4d6kh3', 'advantage')).toBe('4d6kh3');
  });
});

describe('parseDice', () => {
  it('parses a simple die group', () => {
    expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]);
  });

  it('defaults count to 1', () => {
    expect(parseDice('d20')).toEqual([{ kind: 'dice', sign: 1, count: 1, sides: 20 }]);
  });

  it('parses mixed terms with signs', () => {
    expect(parseDice('2d6 + 1d4 - 2')).toEqual([
      { kind: 'dice', sign: 1, count: 2, sides: 6 },
      { kind: 'dice', sign: 1, count: 1, sides: 4 },
      { kind: 'const', sign: -1, value: 2 },
    ]);
  });

  it('parses keep-highest', () => {
    expect(parseDice('4d6kh3')).toEqual([
      { kind: 'dice', sign: 1, count: 4, sides: 6, keep: { op: 'kh', n: 3 } },
    ]);
  });

  it.each(['', 'abc', '3d', 'd', '1d6+', '0d6', '3d6kh4', '1d1'])(
    'rejects malformed input %s',
    (expr) => {
      expect(() => parseDice(expr)).toThrow(DiceParseError);
    },
  );
});

describe('rollDice', () => {
  it('is deterministic for a given seed', () => {
    const a = rollDice('3d6+2', createRng('seed-1'));
    const b = rollDice('3d6+2', createRng('seed-1'));
    expect(a.total).toBe(b.total);
    expect(a.terms[0]!.dice.map((d) => d.value)).toEqual(b.terms[0]!.dice.map((d) => d.value));
  });

  it('stays within bounds', () => {
    for (let i = 0; i < 200; i++) {
      const r = rollDice('1d20', createRng(i));
      expect(r.total).toBeGreaterThanOrEqual(1);
      expect(r.total).toBeLessThanOrEqual(20);
    }
  });

  it('keep-highest drops the lowest dice and excludes them from the total', () => {
    const r = rollDice('4d6kh3', createRng('chargen'));
    const dropped = r.terms[0]!.dice.filter((d) => !d.kept);
    expect(dropped).toHaveLength(1);
    const keptSum = r.terms[0]!.dice.filter((d) => d.kept).reduce((s, d) => s + d.value, 0);
    expect(r.total).toBe(keptSum);
    // the dropped die is <= every kept die
    const minKept = Math.min(...r.terms[0]!.dice.filter((d) => d.kept).map((d) => d.value));
    expect(dropped[0]!.value).toBeLessThanOrEqual(minKept);
  });

  it('applies constant modifiers', () => {
    const r = rollDice('1d6+5', createRng(1));
    expect(r.total).toBe(r.terms[0]!.dice[0]!.value + 5);
  });

  it('exploding dice add extra dice on a max roll', () => {
    // d2 explodes very often; verify at least one expansion occurs and total = sum
    let sawExplosion = false;
    for (let i = 0; i < 50; i++) {
      const r = rollDice('1d2!', createRng(i));
      if (r.terms[0]!.dice.length > 1) {
        sawExplosion = true;
        // all but the last die must be the max value (2)
        const dice = r.terms[0]!.dice;
        for (let j = 0; j < dice.length - 1; j++) expect(dice[j]!.value).toBe(2);
      }
      expect(r.total).toBe(r.terms[0]!.dice.reduce((s, d) => s + d.value, 0));
    }
    expect(sawExplosion).toBe(true);
  });

  it('reroll-below rerolls low dice (so results trend higher)', () => {
    // d6r5 keeps rerolling 1-5 once; values can still be low but parse + bound hold
    for (let i = 0; i < 50; i++) {
      const r = rollDice('1d6r2', createRng(i));
      expect(r.total).toBeGreaterThanOrEqual(1);
      expect(r.total).toBeLessThanOrEqual(6);
    }
  });

  it.each(['1d6r6', '1d20r20'])('rejects reroll threshold >= sides (%s)', (expr) => {
    expect(() => parseDice(expr)).toThrow(DiceParseError);
  });
});
