import type { Rng } from '@/lib/rng';
import { createRng } from '@/lib/rng';

/** A single die-group term, e.g. `4d6kh3`, `3d6!`, `2d20r1`. */
export interface DiceGroup {
  kind: 'dice';
  sign: 1 | -1;
  count: number;
  sides: number;
  /** explode: roll an extra die each time the max is rolled */
  explode?: boolean;
  /** reroll (once) any die whose value is <= this threshold */
  rerollBelow?: number;
  /** keep/drop highest/lowest, e.g. { op: 'kh', n: 3 } */
  keep?: { op: 'kh' | 'kl' | 'dh' | 'dl'; n: number };
}

/** A flat constant term, e.g. `+3`. */
export interface ConstantTerm {
  kind: 'const';
  sign: 1 | -1;
  value: number;
}

export type DiceTerm = DiceGroup | ConstantTerm;

export interface RolledDie {
  sides: number;
  value: number;
  /** false when dropped by a keep/drop modifier */
  kept: boolean;
}

export interface TermResult {
  term: DiceTerm;
  dice: RolledDie[];
  /** signed subtotal contributed by this term */
  subtotal: number;
}

export interface RollResult {
  expression: string;
  total: number;
  terms: TermResult[];
  /** human-readable breakdown, e.g. "2d20kh1 [18, 5→18] + 3 = 21" */
  breakdown: string;
}

export class DiceParseError extends Error {}

export type RollMode = 'normal' | 'advantage' | 'disadvantage';

/**
 * Apply advantage/disadvantage by turning the first single-die term (any dN —
 * d4, d6, d20, …) into 2dN kh1 / 2dN kl1, keeping any modifiers (e.g. 1d20+5).
 * Only single-die terms (count 1 or omitted) are affected, since "advantage" on
 * a pool like 2d6 isn't well-defined; terms that already carry a keep/drop
 * modifier (e.g. 4d6kh3) are left untouched so we never double-apply.
 */
export function applyRollMode(expression: string, mode: RollMode): string {
  if (mode === 'normal') return expression;
  const keep = mode === 'advantage' ? 'kh1' : 'kl1';
  let done = false;
  return expression.replace(/(^|[+\-\s])(\d*)d(\d+)((?:kh|kl|dh|dl)\d+)?/gi, (m, pre: string, count: string, sides: string, mod: string | undefined) => {
    if (done || mod) return m;
    if (count !== '' && count !== '1') return m; // only single-die terms
    done = true;
    return `${pre}2d${sides}${keep}`;
  });
}

const MAX_DICE = 1000;
const MAX_SIDES = 1000;

const TERM_RE =
  /^(\d*)d(\d+)(!)?(?:r(\d+))?(?:(kh|kl|dh|dl)(\d+))?$/i;
const EXPLODE_CAP = 100;

/**
 * Parse a dice expression like `2d6+1d4+3` or `4d6kh3` into terms.
 * Throws DiceParseError on malformed input — never silently coerces to NaN
 * (a whole class of bugs in the old app).
 */
export function parseDice(expression: string): DiceTerm[] {
  const cleaned = expression.replace(/\s+/g, '');
  if (cleaned === '') throw new DiceParseError('Empty expression');

  // Split into signed chunks: a leading sign is optional.
  const chunks = cleaned.match(/[+-]?[^+-]+/g);
  // Reconstructing must reproduce the input exactly; otherwise a dangling or
  // doubled operator (e.g. "1d6+", "1d6++2") was silently dropped.
  if (!chunks || chunks.join('') !== cleaned) {
    throw new DiceParseError(`Cannot parse "${expression}"`);
  }

  const terms: DiceTerm[] = [];
  for (const raw of chunks) {
    let sign: 1 | -1 = 1;
    let body = raw;
    if (body.startsWith('+')) body = body.slice(1);
    else if (body.startsWith('-')) {
      sign = -1;
      body = body.slice(1);
    }
    if (body === '') throw new DiceParseError(`Dangling sign in "${expression}"`);

    if (/^\d+$/.test(body)) {
      terms.push({ kind: 'const', sign, value: Number(body) });
      continue;
    }

    const m = TERM_RE.exec(body);
    if (!m) throw new DiceParseError(`Invalid term "${raw}" in "${expression}"`);
    const count = m[1] === '' ? 1 : Number(m[1]);
    const sides = Number(m[2]);
    if (count < 1 || count > MAX_DICE) throw new DiceParseError(`Dice count out of range in "${raw}"`);
    if (sides < 2 || sides > MAX_SIDES) throw new DiceParseError(`Die size out of range in "${raw}"`);

    const explode = m[3] === '!';
    let rerollBelow: number | undefined;
    if (m[4] !== undefined) {
      const r = Number(m[4]);
      if (r < 1 || r >= sides) throw new DiceParseError(`reroll threshold out of range in "${raw}"`);
      rerollBelow = r;
    }

    let keep: DiceGroup['keep'];
    if (m[5]) {
      const n = Number(m[6]);
      if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`);
      keep = { op: m[5].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n };
    }
    terms.push({
      kind: 'dice', sign, count, sides,
      ...(explode ? { explode } : {}),
      ...(rerollBelow !== undefined ? { rerollBelow } : {}),
      ...(keep ? { keep } : {}),
    });
  }
  return terms;
}

function applyKeep(values: number[], keep: DiceGroup['keep']): boolean[] {
  const kept = values.map(() => true);
  if (!keep) return kept;
  // index list sorted by value
  const order = values.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
  let dropIdx: number[] = [];
  if (keep.op === 'kh') dropIdx = order.slice(0, values.length - keep.n).map((o) => o.i);
  else if (keep.op === 'kl') dropIdx = order.slice(keep.n).map((o) => o.i);
  else if (keep.op === 'dl') dropIdx = order.slice(0, keep.n).map((o) => o.i);
  else if (keep.op === 'dh') dropIdx = order.slice(values.length - keep.n).map((o) => o.i);
  for (const i of dropIdx) kept[i] = false;
  return kept;
}

/** Roll a parsed/string expression. Pass an Rng for deterministic results. */
export function rollDice(expression: string, rng: Rng = createRng()): RollResult {
  const terms = parseDice(expression);
  const termResults: TermResult[] = [];
  let total = 0;

  for (const term of terms) {
    if (term.kind === 'const') {
      const subtotal = term.sign * term.value;
      total += subtotal;
      termResults.push({ term, dice: [], subtotal });
      continue;
    }
    const values: number[] = [];
    for (let i = 0; i < term.count; i++) {
      let v = rng.int(1, term.sides);
      // reroll once if at/under the threshold
      if (term.rerollBelow !== undefined && v <= term.rerollBelow) v = rng.int(1, term.sides);
      values.push(v);
      // exploding: each max roll adds another die
      if (term.explode) {
        let last = v;
        let guard = 0;
        while (last === term.sides && guard < EXPLODE_CAP) {
          last = rng.int(1, term.sides);
          values.push(last);
          guard++;
        }
      }
    }
    const kept = applyKeep(values, term.keep);
    const dice: RolledDie[] = values.map((value, i) => ({
      sides: term.sides,
      value,
      kept: kept[i] ?? true,
    }));
    const sum = dice.reduce((acc, d) => acc + (d.kept ? d.value : 0), 0);
    const subtotal = term.sign * sum;
    total += subtotal;
    termResults.push({ term, dice, subtotal });
  }

  return {
    expression,
    total,
    terms: termResults,
    breakdown: formatBreakdown(termResults, total),
  };
}

function formatTerm(tr: TermResult): string {
  const { term } = tr;
  if (term.kind === 'const') return String(term.value);
  const keep = term.keep ? `${term.keep.op}${term.keep.n}` : '';
  const explode = term.explode ? '!' : '';
  const reroll = term.rerollBelow !== undefined ? `r${term.rerollBelow}` : '';
  const label = `${term.count}d${term.sides}${explode}${reroll}${keep}`;
  const dice = tr.dice
    .map((d) => (d.kept ? String(d.value) : `~~${d.value}~~`))
    .join(', ');
  return `${label} [${dice}]`;
}

function formatBreakdown(terms: TermResult[], total: number): string {
  let out = '';
  terms.forEach((tr, idx) => {
    const sign = tr.term.sign < 0 ? ' - ' : idx === 0 ? '' : ' + ';
    out += sign + formatTerm(tr);
  });
  return `${out} = ${total}`;
}
