import { buildEventError, buildEventResolved } from '../protocol.js';
import type { EventRegistry } from '../registry.js';
import type {
  EventChoice,
  EventErrorEnvelope,
  EventPlayerContext,
  EventResolvedEnvelope,
  ResolvedRelationshipEffect,
} from '../types.js';
import { applyEventEffects } from './effects.js';
import { resolveText } from './selector.js';

interface PendingEventInstance {
  instanceId: string;
  playerId: string;
  eventId: string;
  status: 'pending' | 'answered' | 'resolved' | 'cancelled';
}

interface EventResolutionStore {
  getPendingEventInstances(playerId: string): Promise<PendingEventInstance[]>;
  answerEventInstance(instanceId: string, choiceId: string): Promise<boolean>;
  resolveEventInstance(
    instanceId: string,
    resolution: {
      resolutionText: string;
      effects?: Record<string, unknown>;
    }
  ): Promise<boolean>;
}

export interface EventResponseInput {
  eventId: string;
  choiceId: string;
}

type EventResponseResult = EventResolvedEnvelope | EventErrorEnvelope;

export class EventResponder {
  constructor(
    private readonly registry: EventRegistry,
    private readonly store: EventResolutionStore
  ) {}

  async respond(player: EventPlayerContext, input: EventResponseInput): Promise<EventResponseResult> {
    const pendingInstances = await this.store.getPendingEventInstances(player.userId);
    const pendingInstance = pendingInstances.find((instance) => instance.eventId === input.eventId);

    if (!pendingInstance) {
      return buildEventError({
        code: 'EVENT_NOT_PENDING',
        message: `No pending event instance found for ${input.eventId}`,
        eventId: input.eventId,
      });
    }

    const definition = this.registry.get(input.eventId);
    if (!definition) {
      return buildEventError({
        code: 'UNKNOWN_EVENT',
        message: `Event definition not found for ${input.eventId}`,
        eventId: input.eventId,
        instanceId: pendingInstance.instanceId,
      });
    }

    const choice = definition.choices.find((item) => item.choiceId === input.choiceId);
    if (!choice) {
      return buildEventError({
        code: 'INVALID_CHOICE',
        message: `Choice ${input.choiceId} is invalid for event ${input.eventId}`,
        eventId: input.eventId,
        instanceId: pendingInstance.instanceId,
      });
    }

    // Affordability gate for money SPENDS only. Mirrors the shop
    // ("Insufficient funds") and diamond gating: an interactive choice with a
    // net negative money delta the player can't cover is rejected wholesale —
    // nothing is answered, applied, or resolved. Net-positive money rewards,
    // affordable spends, and non-money (energy/diamond/free) choices are NOT
    // blocked. Computed exactly like applyChoiceEffects derives money below.
    //
    // Soft-lock guard: only gate a choice when an AFFORDABLE alternative exists
    // in the same event. If EVERY choice is an unaffordable money spend (e.g.
    // the "family reunion" dilemma whose only options both cost money and the
    // player is broke/negative), leave them all enabled so the player can
    // always proceed — going into debt is the sanctioned fallback rather than a
    // dead-end paused question.

    // Per-choice net money delta, mirroring the inline derivation below.
    const moneyDeltaOf = (c: EventChoice): number =>
      c.effects?.resources?.money !== undefined
        ? c.effects.resources.money
        : c.moneyCost
          ? -c.moneyCost
          : 0;
    // Coerce current money the same way effects.ts (asNumber) does: a finite
    // number passes through, anything else falls back to 0.
    const currentMoney =
      typeof player.c.money === 'number' && Number.isFinite(player.c.money)
        ? player.c.money
        : Number(player.c.money) || 0;
    const isAffordable = (c: EventChoice): boolean => {
      const delta = moneyDeltaOf(c);
      return !(delta < 0 && currentMoney + delta < 0);
    };
    if (!isAffordable(choice) && definition.choices.some(isAffordable)) {
      return buildEventError({
        code: 'INSUFFICIENT_FUNDS',
        message: 'Not enough money for this choice',
        eventId: input.eventId,
        instanceId: pendingInstance.instanceId,
      });
    }

    const answered = await this.store.answerEventInstance(pendingInstance.instanceId, input.choiceId);
    if (!answered) {
      return buildEventError({
        code: 'ANSWER_REJECTED',
        message: `Failed to answer event instance ${pendingInstance.instanceId}`,
        eventId: input.eventId,
        instanceId: pendingInstance.instanceId,
      });
    }

    const resolvedRelationships = applyChoiceEffects(player, choice);
    markAskedQuestion(player, input.eventId);
    applyChoiceFlags(player, choice);

    const resolutionText = resolveText(
      choice.resolutionTextFn,
      choice.resolutionText ?? choice.text,
      player
    );
    const resolved = await this.store.resolveEventInstance(pendingInstance.instanceId, {
      resolutionText,
      effects: choice.effects as Record<string, unknown> | undefined,
    });

    if (!resolved) {
      return buildEventError({
        code: 'RESOLUTION_FAILED',
        message: `Failed to resolve event instance ${pendingInstance.instanceId}`,
        eventId: input.eventId,
        instanceId: pendingInstance.instanceId,
      });
    }

    return buildEventResolved({
      eventId: input.eventId,
      instanceId: pendingInstance.instanceId,
      resolutionText,
      effects: choice.effects,
      ...(resolvedRelationships.length > 0 ? { resolvedRelationships } : {}),
      metadata: {
        choiceId: input.choiceId,
      },
    });
  }
}

/**
 * Apply a choice's effects to the player and return the affinity changes that
 * landed on named NPCs (for the confirmation screen).
 */
function applyChoiceEffects(
  player: EventPlayerContext,
  choice: EventChoice
): ResolvedRelationshipEffect[] {
  const baseEffects = choice.effects ?? {};
  const resourceEffects = {
    energy: choice.energyCost ? -choice.energyCost : 0,
    money: choice.moneyCost ? -choice.moneyCost : 0,
    diamonds: choice.diamondCost ? -choice.diamondCost : 0,
    ...baseEffects.resources,
  };

  return applyEventEffects(
    player as { c: Record<string, unknown>; r?: Array<Record<string, unknown>> },
    {
      ...baseEffects,
      resources: resourceEffects,
    }
  );
}

/**
 * Add an event id to player.askedQuestions so the selector dedupes it.
 * Mirrors the passive-event marker in EventEngine. Required for interactive
 * events or the same event would refire every tick forever.
 */
function markAskedQuestion(player: EventPlayerContext, eventId: string): void {
  const maybeAsked = (player as { askedQuestions?: unknown }).askedQuestions;
  if (maybeAsked instanceof Set) {
    maybeAsked.add(eventId);
    return;
  }
  if (Array.isArray(maybeAsked)) {
    if (!maybeAsked.includes(eventId)) {
      maybeAsked.push(eventId);
    }
    return;
  }
  // If the player context has no askedQuestions yet, create a Set in place.
  (player as { askedQuestions?: Set<string> }).askedQuestions = new Set([eventId]);
}

/**
 * Persist a choice's `setFlags` onto the player's `flags` set so downstream
 * events can branch on which choice was made. Mirrors `markAskedQuestion`:
 * tolerates a Set (in-memory) or array (post-JSON) container and creates a Set
 * in place when absent.
 */
function applyChoiceFlags(player: EventPlayerContext, choice: EventChoice): void {
  if (!choice.setFlags || choice.setFlags.length === 0) {
    return;
  }
  const maybeFlags = (player as { flags?: unknown }).flags;
  if (maybeFlags instanceof Set) {
    for (const flag of choice.setFlags) maybeFlags.add(flag);
    return;
  }
  if (Array.isArray(maybeFlags)) {
    for (const flag of choice.setFlags) {
      if (!maybeFlags.includes(flag)) maybeFlags.push(flag);
    }
    return;
  }
  (player as { flags?: Set<string> }).flags = new Set(choice.setFlags);
}
