import { createWalker, normalizeWalkerAction, type WalkerController } from "./walker.ts";
import type { Plan } from "./plan.ts";
import {
  type ResolvedRobotOperations,
  type RobotActivityMode,
  type RobotJobKind,
  type RobotSpawnDefinition,
} from "./robotOperations.ts";
import { createRobotRouter, type RobotRouter } from "./robotRoutes.ts";
import type { Point2 } from "./types.ts";

export const ROBOT_ACTIVITY_SCHEMA_VERSION = 1;
export const ROBOT_ACTIVITY_FIXED_STEP_SECONDS = 0.1;

const ROBOT_SPEED_MPS = 1.05;
const ROBOT_RADIUS_M = 0.28;
const ARRIVAL_RADIUS_M = 0.3;
const WAYPOINT_RADIUS_M = 0.035;
const BLOCKED_TICKS = 10;
const RECOVERY_TICKS = 6;
const MAX_RECOVERIES = 4;
const MOVE_BATTERY_PER_M = 0.00055;
const IDLE_BATTERY_PER_TICK = 0.000004;
const CHARGE_PER_TICK = 0.018;
const CHARGE_TARGET = 0.96;
const LOW_BATTERY = 0.16;

export interface RobotActivityAction {
  x: number;
  z: number;
  interact: boolean;
}

export type RobotPayload = "parcel" | null;

export interface RobotActivityState {
  id: string;
  label: string;
  levelId: string;
  position: Point2;
  facing: Point2;
  mode: RobotActivityMode;
  phase: string;
  activeJobId: string | null;
  activeJobKind: RobotJobKind | null;
  activeStationIds: string[];
  jobSequence: number;
  scheduleCursor: number;
  stationIndex: number;
  route: Point2[];
  routeIndex: number;
  battery: number;
  payload: RobotPayload;
  progress: number;
  objectiveTicks: number;
  blockedTicks: number;
  recoveryTicks: number;
  recoveryCount: number;
  idleTicks: number;
  completedJobs: number;
  travelledM: number;
  terminalReason: "battery-depleted" | "blocked-unrecoverable" | null;
  /** This is always true so snapshots cannot be mistaken for live operations. */
  simulated: true;
}

export interface RobotActivitySnapshot {
  schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION;
  operationsId: string;
  operationsVersion: number;
  seed: number;
  tick: number;
  accumulatorSeconds: number;
  robots: RobotActivityState[];
}

export interface RobotActivityTrace {
  schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION;
  initial: RobotActivitySnapshot;
  actions: Array<Record<string, RobotActivityAction>>;
  final: RobotActivitySnapshot;
}

export interface RobotActivityOptions {
  seed?: number;
  /** Omit to instantiate every robot authored by the operations definition. */
  robotIds?: readonly string[];
  /** These robots require explicit movement and interaction actions. */
  controlledRobotIds?: readonly string[];
  /** Arena/eval hook: begin these robots on an explicit authored job. */
  initialJobIds?: Readonly<Record<string, string>>;
}

export interface RobotActivityController {
  readonly fixedStepSeconds: number;
  readonly disclosure: string;
  states(): readonly RobotActivityState[];
  step(actions?: Readonly<Record<string, RobotActivityAction>>): RobotActivitySnapshot;
  tick(elapsedSeconds: number, actions?: Readonly<Record<string, RobotActivityAction>>): RobotActivitySnapshot;
  snapshot(): RobotActivitySnapshot;
  restore(snapshot: RobotActivitySnapshot): RobotActivitySnapshot;
  trace(): RobotActivityTrace;
  replay(trace: RobotActivityTrace): RobotActivitySnapshot;
}

interface RuntimeRobot {
  definition: RobotSpawnDefinition;
  controlled: boolean;
  walker: WalkerController;
  state: RobotActivityState;
}

/** Shared renderer-independent authority for browser robots and Arena episodes. */
export function createRobotActivity(
  plan: Plan,
  operations: ResolvedRobotOperations,
  options: RobotActivityOptions = {},
): RobotActivityController {
  const seed = normalizeSeed(options.seed ?? 0x54455241);
  const selectedIds = options.robotIds ? new Set(options.robotIds) : null;
  const controlledIds = new Set(options.controlledRobotIds ?? []);
  const router = createRobotRouter(plan);
  const runtimes: RuntimeRobot[] = [];
  for (const definition of operations.robots.values()) {
    if (selectedIds && !selectedIds.has(definition.id)) continue;
    const station = operations.stations.get(definition.spawnStationId)!;
    const walker = createWalker(plan, {
      levelId: definition.levelId,
      position: station.position,
      facing: station.facing,
      radius: ROBOT_RADIUS_M,
      speed: ROBOT_SPEED_MPS,
      fixedStep: ROBOT_ACTIVITY_FIXED_STEP_SECONDS,
      maxCatchUpSteps: 1,
    });
    const offset = hash(seed, definition.id, "schedule") % definition.schedule.length;
    const idleTicks = 2 + hash(seed, definition.id, "stagger") % 12;
    const walkerState = walker.state();
    runtimes.push({
      definition,
      controlled: controlledIds.has(definition.id),
      walker,
      state: {
        id: definition.id,
        label: definition.label,
        levelId: definition.levelId,
        position: copy(walkerState.position),
        facing: copy(walkerState.facing),
        mode: "idle",
        phase: "scheduled-idle",
        activeJobId: null,
        activeJobKind: null,
        activeStationIds: [],
        jobSequence: 0,
        scheduleCursor: offset,
        stationIndex: 0,
        route: [],
        routeIndex: 0,
        battery: definition.initialBattery,
        payload: null,
        progress: 0,
        objectiveTicks: 0,
        blockedTicks: 0,
        recoveryTicks: 0,
        recoveryCount: 0,
        idleTicks,
        completedJobs: 0,
        travelledM: 0,
        terminalReason: null,
        simulated: true,
      },
    });
    const initialJobId = options.initialJobIds?.[definition.id];
    if (initialJobId) {
      const initialJob = operations.jobs.get(initialJobId);
      if (!initialJob || !definition.schedule.includes(initialJobId)) {
        throw new Error(`robot ${definition.id} cannot start unknown or unscheduled job ${initialJobId}`);
      }
      assign(runtimes[runtimes.length - 1]!.state, initialJob.id, initialJob.kind, initialJob.stationIds);
    }
  }
  if (selectedIds) {
    for (const id of selectedIds) {
      if (!runtimes.some((runtime) => runtime.definition.id === id)) {
        throw new Error(`unknown robot ${id}`);
      }
    }
  }
  for (const id of controlledIds) {
    if (!runtimes.some((runtime) => runtime.definition.id === id)) {
      throw new Error(`controlled robot ${id} was not instantiated`);
    }
  }
  runtimes.sort((a, b) => a.definition.id.localeCompare(b.definition.id));

  let tickIndex = 0;
  let accumulatorSeconds = 0;
  let initialSnapshot: RobotActivitySnapshot;
  let traceActions: Array<Record<string, RobotActivityAction>> = [];

  function step(rawActions: Readonly<Record<string, RobotActivityAction>> = {}): RobotActivitySnapshot {
    const actions = normalizeActions(runtimes, rawActions);
    for (const runtime of runtimes) advanceRobot(runtime, actions[runtime.definition.id], router, operations, seed);
    tickIndex += 1;
    traceActions.push(structuredClone(actions));
    return snapshot();
  }

  function tick(
    elapsedSeconds: number,
    actions: Readonly<Record<string, RobotActivityAction>> = {},
  ): RobotActivitySnapshot {
    if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
    accumulatorSeconds = Math.min(
      ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 8,
      accumulatorSeconds + elapsedSeconds,
    );
    let steps = 0;
    while (accumulatorSeconds + 1e-9 >= ROBOT_ACTIVITY_FIXED_STEP_SECONDS && steps < 8) {
      accumulatorSeconds -= ROBOT_ACTIVITY_FIXED_STEP_SECONDS;
      if (accumulatorSeconds < 0) accumulatorSeconds = 0;
      step(actions);
      steps += 1;
    }
    return snapshot();
  }

  function snapshot(): RobotActivitySnapshot {
    return {
      schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION,
      operationsId: operations.definition.id,
      operationsVersion: operations.definition.version,
      seed,
      tick: tickIndex,
      accumulatorSeconds,
      robots: runtimes.map((runtime) => cloneState(runtime.state)),
    };
  }

  function restore(next: RobotActivitySnapshot): RobotActivitySnapshot {
    assertSnapshot(next, operations, seed, runtimes);
    tickIndex = next.tick;
    accumulatorSeconds = next.accumulatorSeconds;
    for (const runtime of runtimes) {
      const state = next.robots.find((candidate) => candidate.id === runtime.definition.id)!;
      runtime.walker.restore({
        levelId: state.levelId,
        position: state.position,
        facing: state.facing,
        distance: state.travelledM,
      });
      runtime.state = cloneState(state);
    }
    initialSnapshot = snapshot();
    traceActions = [];
    return snapshot();
  }

  function trace(): RobotActivityTrace {
    return {
      schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION,
      initial: structuredClone(initialSnapshot),
      actions: structuredClone(traceActions),
      final: snapshot(),
    };
  }

  function replay(traceEnvelope: RobotActivityTrace): RobotActivitySnapshot {
    if (traceEnvelope.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION || !Array.isArray(traceEnvelope.actions)) {
      throw new Error("robot activity trace is invalid");
    }
    restore(structuredClone(traceEnvelope.initial));
    for (const actions of traceEnvelope.actions) step(actions);
    const actual = snapshot();
    if (canonical(actual) !== canonical(traceEnvelope.final)) {
      throw new Error("robot activity trace diverged");
    }
    return actual;
  }

  initialSnapshot = snapshot();
  return {
    fixedStepSeconds: ROBOT_ACTIVITY_FIXED_STEP_SECONDS,
    disclosure: operations.definition.disclosure,
    states: () => snapshot().robots,
    step,
    tick,
    snapshot,
    restore,
    trace,
    replay,
  };
}

function advanceRobot(
  runtime: RuntimeRobot,
  action: RobotActivityAction | undefined,
  router: RobotRouter,
  operations: ResolvedRobotOperations,
  seed: number,
): void {
  const state = runtime.state;
  if (state.terminalReason) return;
  state.battery = clamp01(state.battery - IDLE_BATTERY_PER_TICK);
  if (state.battery <= 0) {
    state.battery = 0;
    state.mode = "idle";
    state.phase = "terminal";
    state.terminalReason = "battery-depleted";
    return;
  }

  if (state.mode === "blocked-recovery") {
    recover(runtime, seed);
    return;
  }

  if (!state.activeJobId) {
    if (state.idleTicks > 0) {
      state.idleTicks -= 1;
      return;
    }
    const charge = nearestCharge(operations, state.levelId);
    if (state.battery <= LOW_BATTERY && charge) {
      assign(state, `$battery:${charge.id}`, "charge", [charge.id]);
    } else {
      const jobId = runtime.definition.schedule[state.scheduleCursor]!;
      const job = operations.jobs.get(jobId)!;
      assign(state, job.id, job.kind, job.stationIds);
      state.scheduleCursor = (state.scheduleCursor + 1) % runtime.definition.schedule.length;
    }
  }

  const stationId = state.activeStationIds[state.stationIndex];
  const station = stationId ? operations.stations.get(stationId) : undefined;
  if (!station || station.levelId !== state.levelId) {
    state.mode = "idle";
    state.phase = "terminal";
    state.terminalReason = "blocked-unrecoverable";
    return;
  }

  const distanceToStation = distance(state.position, station.position);
  if (distanceToStation <= ARRIVAL_RADIUS_M) {
    state.route = [];
    state.routeIndex = 0;
    state.blockedTicks = 0;
    const walker = runtime.walker.state();
    runtime.walker.restore({ ...walker, facing: station.facing });
    syncWalker(runtime);
    const canWork = !runtime.controlled || action?.interact === true;
    if (state.activeJobKind === "charge" && canWork) {
      state.phase = "charging";
      state.objectiveTicks += 1;
      state.battery = clamp01(state.battery + CHARGE_PER_TICK);
      updateProgress(state);
      if (state.battery >= CHARGE_TARGET) completeJob(state, runtime.definition, seed);
      return;
    }
    if (!canWork) {
      state.phase = "awaiting-interaction";
      return;
    }
    state.phase = activityPhase(state.activeJobKind, state.stationIndex);
    state.objectiveTicks += 1;
    updateProgress(state);
    const job = operations.jobs.get(state.activeJobId!);
    const dwellTicks = job?.dwellTicks ?? 8;
    if (state.objectiveTicks >= dwellTicks) completeObjective(state, runtime.definition, seed);
    return;
  }

  state.objectiveTicks = 0;
  state.phase = navigationPhase(state.activeJobKind, state.stationIndex);
  if (state.route.length === 0 || state.routeIndex >= state.route.length) {
    const route = router.route(state.levelId, state.position, station.position);
    if (!route) {
      enterRecovery(state);
      return;
    }
    state.route = route.waypoints.map(copy);
    state.routeIndex = 0;
  }
  while (
    state.routeIndex < state.route.length - 1 &&
    distance(state.position, state.route[state.routeIndex]!) <= WAYPOINT_RADIUS_M
  ) state.routeIndex += 1;
  const waypoint = state.route[state.routeIndex] ?? station.position;
  const autonomous = movementToward(state.position, waypoint);
  const movement = runtime.controlled
    ? normalizeWalkerAction(action ?? { x: 0, z: 0 })
    : autonomous;
  const demand = Math.hypot(movement.x, movement.z);
  const before = runtime.walker.state();
  const after = runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, movement);
  const moved = distance(before.position, after.position);
  state.battery = clamp01(state.battery - moved * MOVE_BATTERY_PER_M);
  syncWalker(runtime);
  state.blockedTicks = demand > 0.2 && moved < ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 0.12
    ? state.blockedTicks + 1
    : 0;
  if (state.blockedTicks >= BLOCKED_TICKS) enterRecovery(state);
  updateProgress(state);
}

function recover(runtime: RuntimeRobot, seed: number): void {
  const state = runtime.state;
  state.phase = "backoff-and-replan";
  const angle = (hash(seed, state.id, state.recoveryCount) / 0xffff_ffff) * Math.PI * 2;
  const action = { x: Math.sin(angle), z: Math.cos(angle) };
  runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, action);
  syncWalker(runtime);
  state.recoveryTicks -= 1;
  if (state.recoveryTicks > 0) return;
  if (state.recoveryCount >= MAX_RECOVERIES) {
    state.mode = "idle";
    state.phase = "terminal";
    state.terminalReason = "blocked-unrecoverable";
    return;
  }
  state.mode = state.activeJobKind ?? "idle";
  state.phase = "replanning";
  state.route = [];
  state.routeIndex = 0;
  state.blockedTicks = 0;
}

function enterRecovery(state: RobotActivityState): void {
  state.mode = "blocked-recovery";
  state.phase = "backoff-and-replan";
  state.recoveryTicks = RECOVERY_TICKS;
  state.recoveryCount += 1;
  state.route = [];
  state.routeIndex = 0;
  state.blockedTicks = 0;
}

function assign(
  state: RobotActivityState,
  id: string,
  kind: RobotJobKind,
  stationIds: readonly string[],
): void {
  state.activeJobId = id;
  state.activeJobKind = kind;
  state.activeStationIds = [...stationIds];
  state.stationIndex = 0;
  state.mode = kind;
  state.phase = navigationPhase(kind, 0);
  state.route = [];
  state.routeIndex = 0;
  state.objectiveTicks = 0;
  state.recoveryCount = 0;
  state.jobSequence += 1;
  updateProgress(state);
}

function completeObjective(
  state: RobotActivityState,
  definition: RobotSpawnDefinition,
  seed: number,
): void {
  if (state.activeJobKind === "deliver" && state.stationIndex === 0) state.payload = "parcel";
  if (state.activeJobKind === "deliver" && state.stationIndex === 1) state.payload = null;
  state.objectiveTicks = 0;
  state.stationIndex += 1;
  state.route = [];
  state.routeIndex = 0;
  if (state.stationIndex >= state.activeStationIds.length) completeJob(state, definition, seed);
  else {
    state.phase = navigationPhase(state.activeJobKind, state.stationIndex);
    updateProgress(state);
  }
}

function completeJob(state: RobotActivityState, definition: RobotSpawnDefinition, seed: number): void {
  state.activeJobId = null;
  state.activeJobKind = null;
  state.activeStationIds = [];
  state.stationIndex = 0;
  state.mode = "idle";
  state.phase = "scheduled-idle";
  state.progress = 1;
  state.objectiveTicks = 0;
  state.completedJobs += 1;
  state.idleTicks = 3 + hash(seed, definition.id, state.jobSequence, "idle") % 8;
}

function updateProgress(state: RobotActivityState): void {
  const count = Math.max(1, state.activeStationIds.length);
  const local = state.activeJobKind === "charge"
    ? Math.min(1, state.battery / CHARGE_TARGET)
    : Math.min(0.95, state.objectiveTicks / 20);
  state.progress = Math.min(1, (state.stationIndex + local) / count);
}

function navigationPhase(kind: RobotJobKind | null, stationIndex: number): string {
  if (kind === "deliver") return stationIndex === 0 ? "to-pickup" : "to-dropoff";
  if (kind === "inspect") return "to-inspection";
  if (kind === "charge") return "to-charge";
  return "patrolling";
}

function activityPhase(kind: RobotJobKind | null, stationIndex: number): string {
  if (kind === "deliver") return stationIndex === 0 ? "loading-parcel" : "delivering-parcel";
  if (kind === "inspect") return "inspecting";
  if (kind === "charge") return "charging";
  return "patrol-check";
}

function nearestCharge(operations: ResolvedRobotOperations, levelId: string) {
  return [...operations.stations.values()]
    .filter((station) => station.levelId === levelId && station.role === "charge")
    .sort((a, b) => a.id.localeCompare(b.id))[0];
}

function syncWalker(runtime: RuntimeRobot): void {
  const walker = runtime.walker.state();
  runtime.state.position = copy(walker.position);
  runtime.state.facing = copy(walker.facing);
  runtime.state.travelledM = walker.distance;
}

function normalizeActions(
  runtimes: readonly RuntimeRobot[],
  actions: Readonly<Record<string, RobotActivityAction>>,
): Record<string, RobotActivityAction> {
  const normalized: Record<string, RobotActivityAction> = {};
  for (const runtime of runtimes) {
    if (!runtime.controlled) continue;
    const raw = actions[runtime.definition.id];
    const movement = normalizeWalkerAction(raw ?? { x: 0, z: 0 });
    normalized[runtime.definition.id] = {
      x: movement.x,
      z: movement.z,
      interact: raw?.interact === true,
    };
  }
  return normalized;
}

function assertSnapshot(
  snapshot: RobotActivitySnapshot,
  operations: ResolvedRobotOperations,
  seed: number,
  runtimes: readonly RuntimeRobot[],
): void {
  if (
    snapshot.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION ||
    snapshot.operationsId !== operations.definition.id ||
    snapshot.operationsVersion !== operations.definition.version ||
    snapshot.seed !== seed || !Number.isSafeInteger(snapshot.tick) || snapshot.tick < 0 ||
    !(snapshot.accumulatorSeconds >= 0 && snapshot.accumulatorSeconds < ROBOT_ACTIVITY_FIXED_STEP_SECONDS + 1e-9) ||
    !Array.isArray(snapshot.robots) || snapshot.robots.length !== runtimes.length
  ) throw new Error("robot activity snapshot is incompatible or invalid");
  const expectedIds = runtimes.map((runtime) => runtime.definition.id).sort();
  const actualIds = snapshot.robots.map((robot) => robot.id).sort();
  if (canonical(expectedIds) !== canonical(actualIds)) throw new Error("robot activity snapshot robot set differs");
  for (const robot of snapshot.robots) {
    if (
      !finite(robot.position) || !finite(robot.facing) || robot.levelId.length === 0 ||
      !Number.isFinite(robot.battery) || robot.battery < 0 || robot.battery > 1 ||
      !Number.isFinite(robot.progress) || robot.progress < 0 || robot.progress > 1 ||
      !Number.isFinite(robot.travelledM) || robot.travelledM < 0 || robot.simulated !== true ||
      !Number.isSafeInteger(robot.scheduleCursor) || !Number.isSafeInteger(robot.stationIndex) ||
      !Number.isSafeInteger(robot.jobSequence) || !Number.isSafeInteger(robot.completedJobs) ||
      !Array.isArray(robot.route) || robot.route.some((point) => !finite(point))
    ) throw new Error(`robot activity snapshot state for ${robot.id} is invalid`);
  }
}

function cloneState(state: RobotActivityState): RobotActivityState {
  return {
    ...state,
    position: copy(state.position),
    facing: copy(state.facing),
    activeStationIds: [...state.activeStationIds],
    route: state.route.map(copy),
  };
}

function direction(from: Point2, to: Point2): Point2 {
  const dx = to.x - from.x;
  const dz = to.z - from.z;
  const length = Math.hypot(dx, dz);
  return length > 1e-9 ? { x: dx / length, z: dz / length } : { x: 0, z: 0 };
}

function movementToward(from: Point2, to: Point2): Point2 {
  const heading = direction(from, to);
  const magnitude = Math.min(
    1,
    distance(from, to) / (ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS),
  );
  return { x: heading.x * magnitude, z: heading.z * magnitude };
}

function distance(a: Point2, b: Point2): number {
  return Math.hypot(a.x - b.x, a.z - b.z);
}

function finite(point: Point2): boolean {
  return Number.isFinite(point.x) && Number.isFinite(point.z);
}

function copy(point: Point2): Point2 {
  return { x: point.x, z: point.z };
}

function clamp01(value: number): number {
  return Math.min(1, Math.max(0, value));
}

function normalizeSeed(value: number): number {
  return Number.isFinite(value) ? Math.trunc(value) >>> 0 : 0;
}

function hash(seed: number, ...parts: Array<string | number>): number {
  let result = (0x811c9dc5 ^ seed) >>> 0;
  const text = parts.join("\u0000");
  for (let index = 0; index < text.length; index += 1) {
    result ^= text.charCodeAt(index);
    result = Math.imul(result, 0x01000193) >>> 0;
  }
  return result;
}

function canonical(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(
      ([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`,
    ).join(",")}}`;
  }
  return JSON.stringify(value);
}
