/**
 * SeededRandom — deterministic PRNG for games and procedural generation.
 *
 * Algorithm: xoshiro128** (Blackman & Vigna, 2018)
 *   Period:  2^128 − 1
 *   Quality: passes PractRand, BigCrush; excellent for game use
 *   State:   4 × uint32 — compact, fast, fully serialisable
 *
 * Seeding: SplitMix32 is used to expand any integer or string seed into
 * the four-word initial state, avoiding the all-zero trap.
 *
 * Usage:
 *   const rng = new SeededRandom('world-seed');
 *   const roomRng = rng.clone();          // independent sub-stream
 *   const x = roomRng.randInt(0, 100);
 *   const saved = rng.getState();         // snapshot
 *   rng.setState(saved);                  // restore
 */

import { capitalize, stringHash } from "./utils";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** Four-word xoshiro128** state. Serialisable to / from JSON. */
export type RNGState = [number, number, number, number];

export interface NameParts {
  onsets?: string[];
  vowels?: string[];
  codas?: string[];
  prefixes?: string[];
  suffixes?: string[];
}

export interface NameOptions {
  parts?: NameParts;

  minSyllables?: number;
  maxSyllables?: number;

  prefixChance?: number;
  onsetChance?: number;
  codaChance?: number;
  suffixChance?: number;

  capitalize?: boolean;
  minLength?: number;
  maxLength?: number;

  maxAttempts?: number;

  bannedPatterns?: RegExp[];
};

// ---------------------------------------------------------------------------
// Helpers (module-private)
// ---------------------------------------------------------------------------

/** Left-rotate a uint32. */
function rotl(x: number, k: number): number {
  return ((x << k) | (x >>> (32 - k))) >>> 0;
}

/**
 * SplitMix32 — used only during seeding to expand a single uint32 into the
 * four independent state words required by xoshiro128**.
 */
function splitMix32Step(h: number): [number, number] {
  h = (h + 0x9e3779b9) >>> 0;
  let v = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
  v = Math.imul(v ^ (v >>> 13), 0xc2b2ae35) >>> 0;
  return [(v ^ (v >>> 16)) >>> 0, h];
}

/** Expand a single integer (or hashed string) into a valid xoshiro128 state. */
function buildState(seed: number | string): RNGState {
  let h = typeof seed === 'string' ? stringHash(seed) : seed >>> 0;
  let s0: number, s1: number, s2: number, s3: number;
  [s0, h] = splitMix32Step(h);
  [s1, h] = splitMix32Step(h);
  [s2, h] = splitMix32Step(h);
  [s3] = splitMix32Step(h);
  // Guard: xoshiro128** must never start in the all-zero state.
  if ((s0 | s1 | s2 | s3) === 0) s0 = 1;
  return [s0, s1, s2, s3];
}

function cleanupName(raw: string): string {
  let s = raw.toLowerCase();

  // Keep letters, apostrophes and hyphens only.
  s = s.replace(/[^a-z'-]/g, "");

  // Collapse repeated separators.
  s = s.replace(/(['-])\1+/g, "$1");

  // Collapse extreme repeats.
  s = s.replace(/([bcdfghjklmnpqrstvwxyz])\1{2,}/g, "$1$1");
  s = s.replace(/([aeiouy])\1{2,}/g, "$1$1");

  // Remove separators at edges.
  s = s.replace(/^['-]+|['-]+$/g, "");

  // Remove awkward separator chains.
  s = s.replace(/['-]{2,}/g, "-");

  return s;
}

function looksBad(name: string, bannedPatterns: RegExp[]): boolean {
  if (name.length < 2) return true;

  const tests = [
    /[bcdfghjklmnpqrstvwxyz]{5,}/i,
    /[aeiouy]{5,}/i,
    /(.)\1\1/i,
    /^['-]|['-]$/i,
    /['-]{2,}/i,
    ...bannedPatterns,
  ];

  return tests.some((re) => re.test(name));
}

// ---------------------------------------------------------------------------
// Main class
// ---------------------------------------------------------------------------

export class SeededRandom {
  private s: RNGState;

  /**
   * @param seed  A number, string, or a previously saved RNGState array.
   *              Strings are hashed deterministically (same string → same sequence).
   */
  constructor(seed: number | string | RNGState = Date.now()) {
    this.s = Array.isArray(seed)
      ? ([...seed] as RNGState)
      : buildState(seed);
  }

  // -------------------------------------------------------------------------
  // Core generator
  // -------------------------------------------------------------------------

  /** xoshiro128** — advances state, returns a raw uint32. */
  private next(): number {
    const [s0, s1, s2, s3] = this.s;

    const result = Math.imul(rotl(Math.imul(s1, 5) >>> 0, 7), 9) >>> 0;

    const t = (s1 << 9) >>> 0;
    this.s[2] = (s2 ^ s0) >>> 0;
    this.s[3] = (s3 ^ s1) >>> 0;
    this.s[1] = (s1 ^ this.s[2]) >>> 0;
    this.s[0] = (s0 ^ this.s[3]) >>> 0;
    this.s[2] = (this.s[2] ^ t) >>> 0;
    this.s[3] = rotl(this.s[3], 11);

    return result;
  }

  // -------------------------------------------------------------------------
  // Public API
  // -------------------------------------------------------------------------

  /**
   * Uniform float in [0, 1).
   * Uses 32 bits of randomness → 2^32 distinct values spaced by ~2.3 × 10^−10.
   */
  nextFloat(): number {
    return this.next() / 0x1_0000_0000;
  }

  /**
   * Uniform integer in [min, max).
   * Uses rejection sampling to eliminate modulo bias.
   *
   * @param min  Inclusive lower bound (default 0).
   * @param max  Exclusive upper bound (required).
   */
  nextInt(min: number, max: number): number;
  nextInt(max: number): number;
  nextInt(minOrMax: number, max?: number): number {
    const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max];

    if (!Number.isInteger(min) || !Number.isInteger(hi)) {
      throw new RangeError(`randInt: bounds must be integers, got (${min}, ${hi})`);
    }
    if (min >= hi) {
      return hi;
    }

    const range = hi - min;
    // Rejection threshold that makes every output equally likely.
    // threshold = (2^32 mod range), computed without overflow as:
    const threshold = (0x1_0000_0000 - range) % range >>> 0;

    let v: number;
    do { v = this.next(); } while (v < threshold);
    return min + (v % range);
  }

  /** Uniform boolean — exactly 50 % probability. */
  nextBool(): boolean {
    return (this.next() & 1) === 1;
  }

  nextName(options: NameOptions = {}): string {
    const parts: Required<NameParts> = {
      onsets: options.parts?.onsets?.length ? options.parts.onsets : DEFAULT_PARTS.onsets,
      vowels: options.parts?.vowels?.length ? options.parts.vowels : DEFAULT_PARTS.vowels,
      codas: options.parts?.codas?.length ? options.parts.codas : DEFAULT_PARTS.codas,
      prefixes: options.parts?.prefixes?.length ? options.parts.prefixes : DEFAULT_PARTS.prefixes,
      suffixes: options.parts?.suffixes?.length ? options.parts.suffixes : DEFAULT_PARTS.suffixes
    };

    const minSyllables = options.minSyllables ?? 2;
    const maxSyllables = options.maxSyllables ?? 3;

    const prefixChance = options.prefixChance ?? 0.18;
    const onsetChance = options.onsetChance ?? 0.92;
    const codaChance = options.codaChance ?? 0.58;
    const suffixChance = options.suffixChance ?? 0.12;

    const capitalizeOutput = options.capitalize ?? true;
    const minLength = options.minLength ?? 3;
    const maxLength = options.maxLength ?? 12;

    const maxAttempts = options.maxAttempts ?? 32;
    const bannedPatterns = options.bannedPatterns ?? DEFAULT_BANNED;

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      let name = "";

      if (parts.prefixes.length > 0 && this.nextFloat() < prefixChance) {
        name += this.choice(parts.prefixes);
      }

      const syllables = this.nextInt(minSyllables, maxSyllables);

      for (let i = 0; i < syllables; i++) {
        if (parts.onsets.length > 0 && this.nextFloat() < onsetChance) {
          name += this.choice(parts.onsets);
        }

        name += this.choice(parts.vowels);

        if (parts.codas.length > 0 && this.nextFloat() < codaChance) {
          name += this.choice(parts.codas);
        }
      }

      if (parts.suffixes.length > 0 && this.nextFloat() < suffixChance) {
        name += this.choice(parts.suffixes);
      }

      name = cleanupName(name);

      if (
        name.length >= minLength &&
        name.length <= maxLength &&
        !looksBad(name, bannedPatterns)
      ) {
        return capitalizeOutput ? capitalize(name) : name;
      }
    }

    // Fallback: force something usable even if the filters were too strict.
    let fallback = cleanupName(
      this.choice(parts.onsets) + this.choice(parts.vowels) + this.choice(parts.codas)
    );

    if (!fallback) fallback = "name";
    fallback = fallback.slice(0, Math.max(minLength, Math.min(maxLength, fallback.length)));

    return capitalizeOutput ? capitalize(fallback) : fallback;
  }

  /**
   * Uniform random element from any iterable.
   * Arrays are read in O(1); other iterables are materialised first.
   *
   * @param iterable  Source collection.
   * @param k         When provided, returns k distinct elements sampled
   *                  **without replacement** using a partial Fisher-Yates
   *                  sweep — O(k) time, O(n) space for the working copy.
   *
   * @throws RangeError if the collection is empty, k < 1, or k > collection size.
   */
  choice<T>(iterable: Iterable<T>): T;
  choice<T>(iterable: Iterable<T>, k: number): T[];
  choice<T>(iterable: Iterable<T>, k?: number): T | T[] {
    const arr = [...iterable];
    if (arr.length === 0) return [];

    if (k === undefined) {
      return arr[this.nextInt(0, arr.length)];
    }

    if (k === 0) {
      return [];
    }

    if (!Number.isInteger(k) || k < 0) {
      throw new RangeError(`choice: k must be a positive integer, got ${k}`);
    }
    if (k > arr.length) {
      throw new RangeError(`choice: k (${k}) exceeds collection size (${arr.length})`);
    }

    // Partial Fisher-Yates — only iterate the first k swaps.
    // The selected items accumulate at the front of the working copy.
    for (let i = 0; i < k; i++) {
      const j = this.nextInt(i, arr.length);
      const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
    }
    return arr.slice(0, k);
  }

  /**
   * Weighted random choice — weights need not be normalised.
   *
   * Runs in O(n) per draw. For hot paths with a large, static item list
   * consider building an alias table (Vose's method) instead.
   *
   * @param items    Source array.
   * @param weights  Parallel weight array; must be non-negative with total > 0.
   * @param k        When provided, returns k elements sampled independently
   *                 **with replacement** — each draw is an independent weighted
   *                 roll, so the same item can appear multiple times.
   *
   * @throws RangeError if arrays have different lengths, contain negative
   *         weights, the total weight is zero, or k < 1.
   */
  weightedChoice<T>(items: readonly T[], weights: readonly number[]): T;
  weightedChoice<T>(items: readonly T[], weights: readonly number[], k: number): T[];
  weightedChoice<T>(items: readonly T[], weights: readonly number[], k?: number): T | T[] {
    if (items.length === 0) {
      throw new RangeError('weightedChoice: items is empty');
    }
    if (items.length !== weights.length) {
      throw new RangeError(
        `weightedChoice: items.length (${items.length}) !== weights.length (${weights.length})`,
      );
    }
    if (k === 0) {
      return [];
    }
    if (k !== undefined && (!Number.isInteger(k) || k < 1)) {
      throw new RangeError(`weightedChoice: k must be a positive integer, got ${k}`);
    }

    let total = 0;
    for (let i = 0; i < weights.length; i++) {
      if (weights[i] < 0) throw new RangeError(`weightedChoice: negative weight at index ${i}`);
      total += weights[i];
    }
    if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0');

    const drawOne = (): T => {
      let r = this.nextFloat() * total;
      for (let i = 0; i < items.length; i++) {
        r -= weights[i];
        if (r < 0) return items[i];
      }
      // Float rounding safety net — return last item with non-zero weight.
      for (let i = items.length - 1; i >= 0; i--) {
        if (weights[i] > 0) return items[i];
      }
      return items[items.length - 1];
    };

    if (k === undefined) return drawOne();

    const result: T[] = new Array(k);
    for (let i = 0; i < k; i++) result[i] = drawOne();
    return result;
  }

  /**
   * Fisher-Yates shuffle — mutates the array in place.
   * Returns the same array for chaining.
   */
  shuffle<T>(arr: T[]): T[] {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = this.nextInt(0, i + 1);
      const tmp = arr[i];
      arr[i] = arr[j];
      arr[j] = tmp;
    }
    return arr;
  }

  /**
   * Returns a new shuffled copy of the input.
   * The original is not modified.
   */
  toShuffled<T>(arr: readonly T[]): T[] {
    return this.shuffle([...arr]);
  }

  // -------------------------------------------------------------------------
  // State management
  // -------------------------------------------------------------------------

  /**
   * Snapshot the current generator state.
   * The returned value is a plain array — safe to JSON.stringify / store.
   */
  getState(): RNGState {
    return [...this.s] as RNGState;
  }

  /**
   * Restore the generator to a previously snapshotted state.
   * After this call, the sequence is identical to what it was at the snapshot.
   */
  setState(state: RNGState): void {
    if (!Array.isArray(state) || state.length !== 4) {
      throw new TypeError('setState: expected an array of 4 uint32s');
    }
    this.s = [...state] as RNGState;
  }

  /**
   * Create an independent child RNG for a sub-resource (room, NPC, particle
   * system, …).
   *
   * The child starts from the *current* state. The parent then advances
   * so parent and child immediately diverge — neither will reproduce
   * the other's sequence.
   *
   * Typical pattern:
   *   const worldRng   = new SeededRandom('seed-42');
   *   const dungeonRng = worldRng.fork();   // independent dungeon stream
   *   const npcRng     = worldRng.fork();   // independent NPC stream
   */
  fork(): SeededRandom {
    const child = new SeededRandom(this.getState());
    this.jump(); // diverge parent so the two streams never overlap
    return child;
  }

  /**
   * Advance the state by 2^64 steps (equivalent to 2^64 calls to next()).
   * Use this to generate 2^64 non-overlapping subsequences for parallel work.
   */
  jump(): void {
    const JUMP = [0x8764000b, 0xf542d2d3, 0x6fa035c3, 0x77f2db5b];
    let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
    for (const j of JUMP) {
      for (let b = 0; b < 32; b++) {
        if ((j >>> b) & 1) {
          s0 ^= this.s[0];
          s1 ^= this.s[1];
          s2 ^= this.s[2];
          s3 ^= this.s[3];
        }
        this.next();
      }
    }
    this.s[0] = s0 >>> 0;
    this.s[1] = s1 >>> 0;
    this.s[2] = s2 >>> 0;
    this.s[3] = s3 >>> 0;
  }

  /**
   * Advance the state by 2^96 steps (equivalent to 2^96 calls to next()).
   * Generates 2^32 starting points; each jump() from those gives 2^32
   * non-overlapping subsequences — useful for distributed computation.
   */
  longJump(): void {
    const LONG_JUMP = [0xb523952e, 0x0b6f099f, 0xccf5a0ef, 0x1c580662];
    let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
    for (const j of LONG_JUMP) {
      for (let b = 0; b < 32; b++) {
        if ((j >>> b) & 1) {
          s0 ^= this.s[0];
          s1 ^= this.s[1];
          s2 ^= this.s[2];
          s3 ^= this.s[3];
        }
        this.next();
      }
    }
    this.s[0] = s0 >>> 0;
    this.s[1] = s1 >>> 0;
    this.s[2] = s2 >>> 0;
    this.s[3] = s3 >>> 0;
  }

  // -------------------------------------------------------------------------
  // Serialisation
  // -------------------------------------------------------------------------

  toJSON(): { state: RNGState } {
    return { state: this.getState() };
  }

  static fromJSON(json: { state: RNGState }): SeededRandom {
    return new SeededRandom(json.state);
  }
}

// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------

const DEFAULT_PARTS: Required<NameParts> = {
  onsets: [
    "", "", "",
    "b", "br", "c", "cr", "d", "dr", "f", "f", "g", "gr",
    "k", "kr", "l", "m", "n", "p", "pr", "r", "s", "sh",
    "st", "str", "t", "th", "tr", "v", "w", "z"
  ],
  vowels: [
    "a", "a", "a", "a", "e", "e", "e", "i", "i", "o", "o", "o", "o", "u", "u",
    "ae", "ai", "ea", "ie", "oa", "ou", "ui"
  ],
  codas: [
    "", "", "", "", "n", "r", "s", "th", "k", "l", "m", "nd",
    "rd", "st", "sh", "x", "z"
  ],
  prefixes: [
    "Al", "Bel", "Cor", "Dar", "El", "Fae", "Gal", "Har",
    "Is", "Kel", "Mar", "Nor", "Or", "Val", "Wil"
  ],
  suffixes: [
    "a", "an", "ar", "as", "en", "er", "eth", "ia", "in",
    "ir", "or", "os", "um", "us", "wyn", "iel"
  ]
};

const DEFAULT_BANNED: RegExp[] = [
  /aaa/i,
  /eee/i,
  /iii/i,
  /ooo/i,
  /uuu/i,
  /[bcdfghjklmnpqrstvwxyz]{5,}/i,
  /[aeiouy]{5,}/i,
  /q(?!u)/i,
  /xq|qx|zx|qz/i,
  /vv/i
];

export const MALE_NAME_OPTIONS: NameOptions = {
  minSyllables: 2,
  maxSyllables: 3,

  prefixChance: 0.12,
  suffixChance: 0.08,

  maxLength: 11,

  parts: {
    onsets: [
      "", "",
      "b", "br", "d", "dr", "f", "g", "gr",
      "k", "kr", "l", "m", "n", "p", "pr",
      "r", "s", "st", "t", "th", "tr", "v", "w"
    ],

    vowels: [
      "a", "a", "e", "e", "i", "o", "o", "u",
      "ae", "ai", "oa"
    ],

    codas: [
      "", "", "",
      "n", "r", "s", "k", "l", "m",
      "nd", "rd", "st", "th"
    ],

    prefixes: [
      "Al", "Bar", "Cor", "Dar", "Ed",
      "Gar", "Hal", "Kor", "Mor", "Tor", "Var"
    ],

    suffixes: [
      "an", "ar", "en", "or", "ric",
      "us", "ik", "vald", "mir"
    ]
  }
};

export const FEMALE_NAME_OPTIONS: NameOptions = {
  minSyllables: 2,
  maxSyllables: 4,

  prefixChance: 0.18,
  suffixChance: 0.22,

  maxLength: 12,

  parts: {
    onsets: [
      "", "", "",
      "l", "l", "m", "n", "r", "s",
      "sh", "th", "v", "f", "el", "al",
      "cel", "ly", "my"
    ],

    vowels: [
      "a", "a", "e", "e", "i", "i", "o",
      "u", "ae", "ia", "ea", "ie", "ou"
    ],

    codas: [
      "", "", "", "",
      "l", "n", "ra", "na", "s", "th"
    ],

    prefixes: [
      "Ael", "Ela", "Fae", "Lia",
      "Mira", "Ny", "Syl", "Vela"
    ],

    suffixes: [
      "a", "ia", "elle", "eth", "ina",
      "ira", "is", "wyn", "ara", "iel"
    ]
  }
};

export const ANIMAL_NAME_OPTIONS: NameOptions = {
  minSyllables: 1,
  maxSyllables: 2,

  prefixChance: 0.05,
  suffixChance: 0.02,

  minLength: 3,
  maxLength: 9,

  parts: {
    onsets: [
      "", "",
      "b", "br", "ch", "cl", "cr",
      "f", "fl", "gr", "h", "k",
      "kr", "m", "p", "pr", "r",
      "sc", "sk", "sn", "sp", "st",
      "t", "tr", "w", "z"
    ],

    vowels: [
      "a", "e", "i", "o", "u",
      "oo", "ee", "ai"
    ],

    codas: [
      "", "", "",
      "f", "g", "k", "l", "m",
      "n", "p", "r", "rk", "sh",
      "sk", "t", "x", "z"
    ],

    prefixes: [
      "Black", "Grey", "Red", "Swift",
      "Wild", "Ash", "Stone"
    ],

    suffixes: [
      "claw", "fang", "fur",
      "tail", "paw", "snout"
    ]
  },

  bannedPatterns: [
    ...DEFAULT_BANNED,
    /human/i
  ]
};
