import type { DynamicTextFn, EventDefinition, EventPlayerContext } from '../types.js';

/**
 * Resolve event text, preferring an optional player-derived producer (`fn`)
 * over the static fallback string. The `fn` form drives per-firing text
 * variation for repeatable events; callers pass the player context so the
 * variant can be date-seeded (stable within a firing, varied across
 * firings/years). When no `fn` is supplied the static string is returned
 * unchanged, preserving every existing static event.
 */
export function resolveText(
  fn: DynamicTextFn | undefined,
  fallback: string,
  player: EventPlayerContext
): string {
  return fn ? fn(player) : fallback;
}

function withinAgeRange(definition: EventDefinition, ageYears: number | undefined): boolean {
  if (ageYears === undefined) {
    return definition.minAge === undefined && definition.maxAge === undefined;
  }

  if (definition.minAge !== undefined && ageYears < definition.minAge) {
    return false;
  }

  if (definition.maxAge !== undefined && ageYears > definition.maxAge) {
    return false;
  }

  return true;
}

function hasBeenAsked(definition: EventDefinition, player: EventPlayerContext): boolean {
  const askedQuestions = (player as { askedQuestions?: unknown }).askedQuestions;
  if (askedQuestions instanceof Set && askedQuestions.has(definition.id)) {
    return true;
  }
  if (Array.isArray(askedQuestions) && askedQuestions.includes(definition.id)) {
    return true;
  }
  return false;
}

/**
 * Resolve the in-game day this event last fired on, or undefined if never.
 * Reads the `eventLastFired` map off the player context (mirrors the way
 * `askedQuestions` is read directly off the context object).
 */
function lastFiredDay(definition: EventDefinition, player: EventPlayerContext): number | undefined {
  const record = (player as { eventLastFired?: unknown }).eventLastFired;
  if (record && typeof record === 'object') {
    const value = (record as Record<string, unknown>)[definition.id];
    if (typeof value === 'number' && Number.isFinite(value)) {
      return value;
    }
  }
  return undefined;
}

/**
 * True if the player carries the given persistent flag. Flags are set by a
 * choice's `setFlags` on resolution (see respond.ts) and read here so a
 * downstream event's `isEligible` can branch on which choice was made. Reads a
 * Set (in-memory) or array (post-JSON) `flags` container off the context,
 * mirroring how `askedQuestions` is read.
 */
export function playerHasFlag(player: EventPlayerContext, flag: string): boolean {
  const flags = (player as { flags?: unknown }).flags;
  if (flags instanceof Set) {
    return flags.has(flag);
  }
  if (Array.isArray(flags)) {
    return flags.includes(flag);
  }
  return false;
}

/**
 * Normalize an event's selection weight. Omitted -> 1. Non-positive or
 * non-finite values are clamped up to a small positive epsilon so every
 * eligible event retains a chance of being picked.
 */
export function eventWeight(definition: EventDefinition): number {
  const raw = definition.weight;
  if (typeof raw !== 'number' || !Number.isFinite(raw)) {
    return 1;
  }
  return raw > 0 ? raw : Number.EPSILON;
}

export function isEventEligible(
  definition: EventDefinition,
  player: EventPlayerContext,
  currentDay?: number
): boolean {
  // Once-ever gate: applies ONLY to non-repeatable events. Repeatable events
  // remain eligible after firing (subject to cooldown below).
  if (!definition.repeatable && hasBeenAsked(definition, player)) {
    return false;
  }

  // Cooldown gate: skip the event if it last fired fewer than `cooldownDays`
  // in-game days ago. Only checked when a cooldown is configured and we know
  // both the current day and a prior firing day.
  if (definition.cooldownDays !== undefined && definition.cooldownDays > 0) {
    const fired = lastFiredDay(definition, player);
    if (fired !== undefined && currentDay !== undefined) {
      if (currentDay - fired < definition.cooldownDays) {
        return false;
      }
    }
  }

  if (!withinAgeRange(definition, player.c?.ageYears)) {
    return false;
  }

  if (definition.isEligible && !definition.isEligible(player)) {
    return false;
  }

  return true;
}

/**
 * Select the next event to fire from the candidate definitions.
 *
 * Selection is a WEIGHTED-RANDOM pick among ALL currently-eligible events
 * (probability proportional to each event's `weight`, default 1), rather than
 * the old "first eligible" behavior. This avoids the "burst then dead air"
 * pattern where a fixed prefix of the catalog always fired first.
 *
 * Determinism: the RNG is injectable via the optional `rng` parameter
 * (defaults to `Math.random`), so tests can pass a seeded generator for
 * repeatable results.
 *
 * Backward compatibility: with a single eligible event, the result is always
 * that event regardless of RNG, matching the prior first-eligible behavior.
 *
 * @param definitions candidate event definitions (registry order)
 * @param player      player context (eligibility + cooldown source)
 * @param currentDay  current in-game day, used for `cooldownDays` checks
 * @param rng         optional RNG returning [0, 1); defaults to Math.random
 */
export function selectNextEligibleEvent(
  definitions: EventDefinition[],
  player: EventPlayerContext,
  currentDay?: number,
  rng: () => number = Math.random
): EventDefinition | null {
  const eligible = definitions.filter((definition) =>
    isEventEligible(definition, player, currentDay)
  );

  if (eligible.length === 0) {
    return null;
  }

  if (eligible.length === 1) {
    return eligible[0];
  }

  const totalWeight = eligible.reduce((sum, definition) => sum + eventWeight(definition), 0);
  if (totalWeight <= 0) {
    return eligible[0];
  }

  // Clamp roll into [0, totalWeight) to guard against rng() returning >= 1.
  let roll = rng() * totalWeight;
  if (!Number.isFinite(roll) || roll < 0) {
    roll = 0;
  }
  if (roll >= totalWeight) {
    roll = totalWeight - Number.EPSILON;
  }

  for (const definition of eligible) {
    roll -= eventWeight(definition);
    if (roll < 0) {
      return definition;
    }
  }

  // Floating-point fallback: return the last eligible event.
  return eligible[eligible.length - 1];
}
