import { matchesCondition } from "./conditions";
import { appendLog, applyEffects, describeStateDelta } from "./effects";
import type {
  ActionDefinition,
  AvailableAction,
  EventDefinition,
  GameState,
  PhaseDefinition,
  PreparedWorldDefinition,
  RuntimeContext,
  StartDefinition,
  StepResult,
  TimingModifier,
  TimelineEntry,
  ValueDefinition,
  WorldDefinition,
} from "./types";
import {
  clamp,
  clone,
  mergeEffects,
  pickWeighted,
  uniqueStrings,
} from "./utils";
import { prepareWorldDefinition } from "./world";

function resolvePhaseIndex(
  world: PreparedWorldDefinition,
  phaseId?: string,
): number {
  const index = world.phases.findIndex((phase) => phase.id === phaseId);
  return index >= 0 ? index : 0;
}

function buildInitialValues(
  definitions: ValueDefinition[],
  baseValues?: Record<string, number>,
  overrides?: Record<string, number>,
  defaultMin?: number,
): Record<string, number> {
  const values: Record<string, number> = {};
  const definitionLookup = new Map(
    definitions.map((definition) => [definition.id, definition]),
  );

  for (const definition of definitions) {
    values[definition.id] = clamp(
      definition.initial ?? 0,
      definition.min ?? defaultMin,
      definition.max,
    );
  }

  for (const source of [baseValues, overrides]) {
    for (const [key, value] of Object.entries(source ?? {})) {
      const definition = definitionLookup.get(key);
      values[key] = clamp(
        value,
        definition?.min ?? defaultMin,
        definition?.max,
      );
    }
  }

  return values;
}

function createInitialState(
  world: PreparedWorldDefinition,
  overrides: StartDefinition = {},
): GameState {
  const phaseIndex = resolvePhaseIndex(
    world,
    overrides.phaseId ?? world.start.phaseId,
  );
  const phaseId = world.phases[phaseIndex]?.id ?? "phase-0";

  return {
    worldId: world.id,
    originAge: overrides.age ?? world.start.age ?? 18,
    age: overrides.age ?? world.start.age ?? 18,
    day: overrides.day ?? world.start.day ?? 1,
    turn: 0,
    phaseIndex,
    phaseId,
    stats: buildInitialValues(world.stats, world.start.stats, overrides.stats),
    resources: buildInitialValues(
      world.resources,
      world.start.resources,
      overrides.resources,
      0,
    ),
    inventory: buildInitialValues(
      world.inventory,
      world.start.inventory,
      overrides.inventory,
      0,
    ),
    flags: {
      worldComplete: false,
      ...(world.start.flags ?? {}),
      ...(overrides.flags ?? {}),
    },
    tags: uniqueStrings([
      ...(world.start.tags ?? []),
      ...(overrides.tags ?? []),
    ]),
    seenEvents: {},
    log: [],
  };
}

function advanceTime(
  world: PreparedWorldDefinition,
  state: GameState,
  steps: number,
): GameState {
  const next = clone(state);
  const phaseCount = world.phases.length;
  const daysPerAge = world.calendar?.daysPerAge ?? 120;

  for (let index = 0; index < steps; index += 1) {
    next.turn += 1;
    next.phaseIndex = (next.phaseIndex + 1) % phaseCount;

    if (next.phaseIndex === 0) {
      next.day += 1;
      next.age = next.originAge + Math.floor((next.day - 1) / daysPerAge);
    }
  }

  next.phaseId = world.phases[next.phaseIndex]?.id ?? next.phaseId;

  if (world.calendar?.maxDays && next.day > world.calendar.maxDays) {
    next.flags.worldComplete = true;
  }

  return next;
}

export class WorldRuntime {
  readonly world: PreparedWorldDefinition;

  readonly phases: PhaseDefinition[];

  private readonly actions: Map<string, ActionDefinition>;

  private readonly random: () => number;

  private readonly maxLogEntries: number;

  private state: GameState;

  constructor(
    world: WorldDefinition,
    options: {
      random?: () => number;
      maxLogEntries?: number;
      start?: StartDefinition;
    } = {},
  ) {
    this.world = prepareWorldDefinition(world);
    this.phases = clone(this.world.phases);
    this.actions = new Map(
      this.world.actions.map((action) => [action.id, action]),
    );
    this.random = options.random ?? Math.random;
    this.maxLogEntries = options.maxLogEntries ?? 40;
    this.state = createInitialState(this.world, options.start);
  }

  snapshot(): GameState {
    return clone(this.state);
  }

  reset(overrides: StartDefinition = {}): GameState {
    this.state = createInitialState(this.world, overrides);
    return this.snapshot();
  }

  getCurrentPhase(): PhaseDefinition {
    return this.world.phases[this.state.phaseIndex] ?? this.world.phases[0]!;
  }

  getAvailableActions(): AvailableAction[] {
    if (this.state.flags.worldComplete) {
      return [];
    }

    return this.world.actions
      .map((action) => {
        const timingModifier = this.resolveTimingModifier(action, this.state, {
          phaseId: this.state.phaseId,
        });
        const isAvailable = matchesCondition(this.state, action.availability, {
          phaseId: this.state.phaseId,
        });

        return {
          ...clone(action),
          isAvailable,
          isBestTiming: timingModifier?.tone === "best",
          timingModifier,
        };
      })
      .filter((action) => action.isAvailable);
  }

  step(actionId: string): StepResult {
    if (this.state.flags.worldComplete) {
      throw new Error(
        "The current world demo has ended. Reset before stepping again.",
      );
    }

    const action = this.actions.get(actionId);
    if (!action) {
      throw new Error(`Unknown action: ${actionId}`);
    }

    const beforeAction = this.snapshot();
    const currentPhase = this.getCurrentPhase();
    const context: RuntimeContext = {
      actionId,
      phaseId: beforeAction.phaseId,
    };

    if (!matchesCondition(beforeAction, action.availability, context)) {
      throw new Error(`Action is not available: ${actionId}`);
    }

    const timingModifier = this.resolveTimingModifier(
      action,
      beforeAction,
      context,
    );
    const actionEffects = mergeEffects(action.effects, timingModifier?.effects);
    const afterAction = applyEffects(this.world, beforeAction, actionEffects);
    const actionEntry = this.createLogEntry(
      "action",
      action,
      currentPhase,
      beforeAction,
      afterAction,
      timingModifier,
    );

    let afterEvent = afterAction;
    let eventEntry: TimelineEntry | undefined;
    const event = this.pickEvent(afterAction, context);

    if (event) {
      const eventBefore = afterAction;
      const eventAfter = applyEffects(this.world, eventBefore, event.effects);
      eventAfter.seenEvents[event.id] =
        (eventAfter.seenEvents[event.id] ?? 0) + 1;
      afterEvent = eventAfter;
      eventEntry = this.createLogEntry(
        "event",
        event,
        currentPhase,
        eventBefore,
        eventAfter,
      );
    }

    const advanced = advanceTime(
      this.world,
      afterEvent,
      action.advancePhases ?? 1,
    );
    this.state = appendLog(
      advanced,
      eventEntry ? [actionEntry, eventEntry] : [actionEntry],
      this.maxLogEntries,
    );

    return {
      state: this.snapshot(),
      action: actionEntry,
      event: eventEntry,
      availableActions: this.getAvailableActions(),
    };
  }

  private resolveTimingModifier(
    action: ActionDefinition,
    state: GameState,
    context: RuntimeContext,
  ): TimingModifier | undefined {
    const modifier = action.timing?.[context.phaseId ?? state.phaseId];

    if (!modifier) {
      return undefined;
    }

    if (!matchesCondition(state, modifier.when, context)) {
      return undefined;
    }

    return modifier;
  }

  private pickEvent(
    state: GameState,
    context: RuntimeContext,
  ): EventDefinition | undefined {
    const pool = this.world.events.filter((event) => {
      if (event.once && (state.seenEvents[event.id] ?? 0) > 0) {
        return false;
      }

      return matchesCondition(state, event.when, context);
    });

    return pickWeighted(pool, (event) => event.weight ?? 1, this.random);
  }

  private createLogEntry(
    kind: "action" | "event",
    subject: ActionDefinition | EventDefinition,
    phase: PhaseDefinition,
    before: GameState,
    after: GameState,
    timingModifier?: TimingModifier,
  ): TimelineEntry {
    return {
      kind,
      id: subject.id,
      title: subject.name,
      description: subject.description,
      age: before.age,
      day: before.day,
      phaseId: before.phaseId,
      phaseLabel: phase.label,
      note: timingModifier?.label,
      tone: timingModifier?.tone,
      changes: describeStateDelta(this.world, before, after),
    };
  }
}
