import { randomUUID } from 'node:crypto';

import { buildEventPrompt, buildEventResolved } from '../protocol.js';
import type { EventRegistry } from '../registry.js';
import type {
  EventPlayerContext,
  EventPromptEnvelope,
  EventResolvedEnvelope,
} from '../types.js';
import { applyEventEffects } from './effects.js';
import { resolveText, selectNextEligibleEvent } from './selector.js';

interface PendingEventInstance {
  instanceId: string;
  status: string;
}

interface CreatedEventInstance {
  instanceId: string;
  eventId: string;
  prompt: string;
  choices: Array<{
    choiceId: string;
    text: string;
    energyCost?: number;
    moneyCost?: number;
    diamondCost?: number;
  }>;
}

interface EventInstanceStore {
  getPendingEventInstances(playerId: string): Promise<PendingEventInstance[]>;
  createEventInstance(input: {
    instanceId: string;
    playerId: string;
    eventId: string;
    prompt: string;
    choices: Array<{
      choiceId: string;
      text: string;
      energyCost?: number;
      moneyCost?: number;
      diamondCost?: number;
    }>;
    context?: Record<string, unknown>;
  }): Promise<CreatedEventInstance>;
  resolveEventInstance?: (
    instanceId: string,
    resolution: {
      resolutionText: string;
      effects?: Record<string, unknown>;
    }
  ) => Promise<boolean>;
}

export class EventEngine {
  constructor(
    private readonly registry: EventRegistry,
    private readonly store: EventInstanceStore
  ) {}

  async promptNext(
    player: EventPlayerContext
  ): Promise<EventPromptEnvelope | EventResolvedEnvelope | null> {
    const pending = await this.store.getPendingEventInstances(player.userId);
    if (pending.length > 0) {
      return null;
    }

    // ── Per-in-game-day emit rate gate ──────────────────────────────────────
    // promptNext is called every in-game hour (PlayerSession.processHourTick),
    // and any age-gate that unlocks several once-ever events at once would
    // otherwise drain them in a tight same-day BURST (measured peak: 17 prompts
    // in one in-game day). Gating to at most ONE v2 prompt per in-game day
    // turns that burst into a steady drip: the surplus eligible events simply
    // stay eligible for subsequent days. This is the single chokepoint for ALL
    // v2 prompt emission (online PlayerSession today; any future offline caller
    // routes through here too), so the cadence can't diverge between paths.
    const today = resolveAbsoluteDay(player);
    if (today !== undefined) {
      const lastEmit = readLastEmitDay(player);
      if (lastEmit !== undefined && today - lastEmit < EVENT_EMIT_MIN_DAYS) {
        return null;
      }
    }

    const currentDay = resolveCurrentDay(player);
    const definition = selectNextEligibleEvent(this.registry.list(), player, currentDay);
    if (!definition) {
      return null;
    }

    // An event is firing this day — stamp the gate so no further v2 prompt
    // fires until EVENT_EMIT_MIN_DAYS in-game days have elapsed.
    if (today !== undefined) {
      writeLastEmitDay(player, today);
    }

    // Record the firing day for cooldown tracking. This happens once, at the
    // moment the event fires (instance creation), for both passive and
    // interactive events. The once-ever gate for non-repeatable events is
    // handled separately via askedQuestions (passive below; interactive in
    // the responder when the player answers).
    recordEventFired(player, definition.id, currentDay);

    const instance = await this.store.createEventInstance({
      instanceId: `evt_${randomUUID()}`,
      playerId: player.userId,
      eventId: definition.id,
      prompt: resolveText(definition.promptFn, definition.prompt, player),
      choices: definition.choices.map((choice) => ({
        choiceId: choice.choiceId,
        text: choice.text,
        energyCost: choice.energyCost,
        moneyCost: effectiveMoneyCost(choice),
        diamondCost: choice.diamondCost,
      })),
      context: {
        category: definition.category,
      },
    });

    if (definition.kind === 'passive') {
      const passiveChoice = definition.choices[0];
      if (!passiveChoice) {
        return null;
      }

      applyPassiveEffects(player, passiveChoice);

      const passiveResolutionText = resolveText(
        passiveChoice.resolutionTextFn,
        passiveChoice.resolutionText ?? passiveChoice.text,
        player
      );

      if (this.store.resolveEventInstance) {
        await this.store.resolveEventInstance(instance.instanceId, {
          resolutionText: passiveResolutionText,
          effects: passiveChoice.effects as Record<string, unknown> | undefined,
        });
      }

      markAskedQuestion(player, definition.id);

      return buildEventResolved({
        eventId: instance.eventId,
        instanceId: instance.instanceId,
        resolutionText: passiveResolutionText,
        effects: passiveChoice.effects,
        metadata: {
          category: definition.category,
          choiceId: passiveChoice.choiceId,
          kind: 'passive',
        },
      });
    }

    return buildEventPrompt({
      eventId: instance.eventId,
      instanceId: instance.instanceId,
      prompt: instance.prompt,
      choices: instance.choices,
      metadata: {
        category: definition.category,
      },
    });
  }
}

/**
 * Minimum number of in-game days between successive v2 prompt emissions for a
 * single player. 1 = "at most one prompt per in-game day", which is what tames
 * the age-gate burst while still allowing a daily drip. Raising this thins the
 * cadence further; the cadence target is ~1-4 prompts/in-game-week steady state,
 * so 1 (up to 7/week ceiling, gated lower by content availability) is correct.
 */
const EVENT_EMIT_MIN_DAYS = 1;

/**
 * Resolve a MONOTONIC absolute in-game day for the emit-rate gate. Identical to
 * the cooldown day resolution (prefers the character's strictly-increasing
 * `ageDays` over the wrapping `dayOfYear`), so the gate and cooldowns share one
 * day basis.
 */
function resolveAbsoluteDay(player: EventPlayerContext): number | undefined {
  return resolveCurrentDay(player);
}

/** Read the absolute day the last v2 prompt fired on, or undefined if never. */
function readLastEmitDay(player: EventPlayerContext): number | undefined {
  const value = (player as { lastEventEmitDay?: unknown }).lastEventEmitDay;
  if (typeof value === 'number' && Number.isFinite(value)) {
    return value;
  }
  return undefined;
}

/** Stamp the absolute day a v2 prompt fired on, for the emit-rate gate. */
function writeLastEmitDay(player: EventPlayerContext, day: number): void {
  (player as { lastEventEmitDay?: number }).lastEventEmitDay = day;
}

/**
 * The money a choice will SPEND, surfaced to the client prompt so the cost pill
 * and the affordability gate can see it. Mirrors how applyChoiceEffects and the
 * responder derive money: prefer the canonical `effects.resources.money` delta,
 * else the legacy top-level `moneyCost`. Only a NET SPEND (negative delta) is a
 * cost; a money REWARD (positive delta) is not surfaced as a cost. Returns
 * undefined when there is no money cost so the envelope shape is unchanged for
 * free/reward choices. (Without this, effects-based costs — e.g. unexpectedBill,
 * whose cost lives in effects.resources.money — would show as "0" on the client
 * and could not be greyed out when unaffordable.)
 */
function effectiveMoneyCost(choice: {
  moneyCost?: number;
  effects?: { resources?: { money?: number } };
}): number | undefined {
  const delta = choice.effects?.resources?.money;
  if (delta !== undefined) {
    return delta < 0 ? -delta : undefined;
  }
  return choice.moneyCost;
}

function applyPassiveEffects(
  player: EventPlayerContext,
  choice: {
    energyCost?: number;
    moneyCost?: number;
    diamondCost?: number;
    effects?: {
      resources?: {
        energy?: number;
        money?: number;
        diamonds?: number;
      };
      stats?: Record<string, number>;
      relationships?: Array<{
        personId: string;
        affinityDelta: number;
      }>;
    };
  }
): void {
  const resourceEffects = {
    energy: choice.energyCost ? -choice.energyCost : 0,
    money: choice.moneyCost ? -choice.moneyCost : 0,
    diamonds: choice.diamondCost ? -choice.diamondCost : 0,
    ...choice.effects?.resources,
  };

  applyEventEffects(player as { c: Record<string, unknown> }, {
    ...choice.effects,
    resources: resourceEffects,
  });
}

function markAskedQuestion(player: EventPlayerContext, eventId: string): void {
  const maybeAsked = (player as { askedQuestions?: unknown }).askedQuestions;
  if (maybeAsked instanceof Set) {
    maybeAsked.add(eventId);
  }
}

/**
 * Resolve the player's current in-game day for cooldown math. Prefers an
 * explicit `currentDay`/`absoluteDay` on the context, falling back to the
 * player's `dayOfYear`. Returns undefined when no day signal is available
 * (cooldown checks then no-op, preserving prior behavior).
 */
function resolveCurrentDay(player: EventPlayerContext): number | undefined {
  const ctx = player as {
    currentDay?: unknown;
    absoluteDay?: unknown;
    dayOfYear?: unknown;
  };
  // Prefer a MONOTONIC absolute day (character ageDays) for cooldown math. The
  // legacy fallbacks (currentDay/absoluteDay/dayOfYear) are kept for contexts
  // that lack a character, but dayOfYear WRAPS at 365 — using it for cooldowns
  // makes `currentDay - lastFired` go sharply negative at every year boundary,
  // which spuriously suppresses repeatable events for most of each year (the
  // root cause of ambient/recurring events almost never re-firing). ageDays
  // never wraps, so cooldownDays elapses correctly across year boundaries.
  const ageDays = (player.c as { ageDays?: unknown } | undefined)?.ageDays;
  if (typeof ageDays === 'number' && Number.isFinite(ageDays)) {
    return Math.floor(ageDays);
  }
  for (const candidate of [ctx.currentDay, ctx.absoluteDay, ctx.dayOfYear]) {
    if (typeof candidate === 'number' && Number.isFinite(candidate)) {
      return candidate;
    }
  }
  return undefined;
}

/**
 * Record the in-game day an event fired on, in the player's `eventLastFired`
 * map. Stored as a plain object (keyed by event id) so it round-trips through
 * JSON save/load natively, unlike the Set-backed `askedQuestions`. Created in
 * place on the context if absent.
 */
function recordEventFired(
  player: EventPlayerContext,
  eventId: string,
  currentDay: number | undefined
): void {
  if (currentDay === undefined) {
    return;
  }
  const target = player as { eventLastFired?: Record<string, number> };
  if (!target.eventLastFired || typeof target.eventLastFired !== 'object') {
    target.eventLastFired = {};
  }
  target.eventLastFired[eventId] = currentDay;
}
