/**
 * Renderer-independent walking over a resolved office plan.
 *
 * Input is a direction on the office floor plane, not keys or stick events.
 * The controller advances on a fixed clock, sweeps the walker's circular
 * footprint against the exact collision segments produced by `Plan`, and
 * projects blocked motion along a wall so diagonal input slides instead of
 * stopping. Doors need no special case: the wall resolver has already left a
 * gap in `LevelPlan.collision` for every passable opening.
 *
 * ### The level is state, and the state is still two-dimensional
 *
 * `levelId` used to be spawn *configuration* — `snapshot()` returned
 * `spawn.levelId` and nothing in `tick()` could change it, so a snapshot taken
 * on another storey was refused outright and every authored upper floor in every
 * pack was unreachable on foot. It is now a mutable part of the finite state,
 * changed by exactly one method, `enterLevel`.
 *
 * What deliberately did **not** change is the shape of `WalkerState`: it is two
 * numbers and a level id, with no `y` anywhere. Height is fully determined by
 * the level and belongs to whoever is drawing — `officeWalker` interpolates
 * `floorY` across a crossing and `Plan` owns the number. Adding a vertical here
 * would ripple into every consumer of `floorY` for a value none of them would be
 * allowed to disagree about, and it would cost this file the property that makes
 * it worth having: it is numeric, three-free, testable without a WebGL context
 * and snapshotable by Arena.
 *
 * `enterLevel` is not `reset`. `reset` adopts a new *spawn*, zeroes the odometer
 * and restores the authored facing — which is a teleport, and using it to climb
 * a staircase would silently reset `distance` at the top of every flight.
 */

import type { Bounds, LevelPlan, Segment } from "./plan.ts";
import type { Point2 } from "./types.ts";

const EPSILON = 1e-8;
const BISECTION_STEPS = 24;
const SLIDE_PASSES = 3;

export const DEFAULT_WALKER_RADIUS = 0.3;
export const DEFAULT_WALKER_SPEED = 1.6;
export const DEFAULT_FIXED_STEP = 1 / 60;
export const DEFAULT_MAX_CATCH_UP_STEPS = 8;

/** A world-space direction on the office floor plane. */
export interface WalkerAction {
  x: number;
  z: number;
}

export interface WalkerSpawn {
  levelId: string;
  position: Point2;
}

export interface WalkerOptions extends WalkerSpawn {
  /** Initial unit direction; defaults to north / local -Z. */
  facing?: Point2;
  /** Circular footprint radius, in metres. */
  radius?: number;
  /** Metres per second at full input. */
  speed?: number;
  /** Simulation seconds per movement step. */
  fixedStep?: number;
  /** Prevents a resumed/backgrounded tab from running an unbounded backlog. */
  maxCatchUpSteps?: number;
}

export interface WalkerState {
  levelId: string;
  position: Point2;
  /** Last non-zero normalized action; useful as a renderer-facing heading. */
  facing: Point2;
  /** Total successfully travelled distance, in metres, since the last reset. */
  distance: number;
}

/** The small part of `Plan` movement depends on. A test or server can implement it too. */
export interface WalkerPlan {
  level(id: string): Pick<LevelPlan, "bounds" | "collision"> | null;
  blocked(levelId: string, from: Point2, to: Point2, radius?: number): boolean;
}

export interface WalkerController {
  /** A defensive snapshot: callers cannot corrupt the simulation's finite state. */
  state(): WalkerState;
  /** Add real time; zero or more fixed simulation steps may run. */
  tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
  /** Return to the original spawn, or atomically adopt another valid spawn. */
  reset(spawn?: WalkerSpawn): WalkerState;
  /** Restore a trusted JSON snapshot without changing the configured spawn. */
  restore(snapshot: WalkerState): WalkerState;
  /**
   * Cross to another storey at a position that is valid on it, keeping the
   * odometer and the configured spawn. Throws if it is not.
   *
   * The one mutator that is not a teleport. See the header on why the climb
   * itself is presentation and lives in `officeWalker`. `facing` is optional
   * because a lift does not turn you round and a flight of stairs does: pass the
   * direction of the last leg and the actor arrives looking the way it came off
   * the treads, rather than snapping back to whatever it held at the bottom.
   */
  enterLevel(levelId: string, position: Point2, facing?: Point2): WalkerState;
}

/**
 * Clamp arbitrary planar input to the unit disc. Non-finite input means idle;
 * letting one bad gamepad sample become NaN would otherwise poison every frame.
 */
export function normalizeWalkerAction(action: WalkerAction): WalkerAction {
  if (!finitePoint(action)) return { x: 0, z: 0 };
  const length = Math.hypot(action.x, action.z);
  if (length <= 1) return { x: action.x, z: action.z };
  return { x: action.x / length, z: action.z / length };
}

export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerController {
  const radius = positive(options.radius ?? DEFAULT_WALKER_RADIUS, "radius");
  const speed = positive(options.speed ?? DEFAULT_WALKER_SPEED, "speed");
  const fixedStep = positive(options.fixedStep ?? DEFAULT_FIXED_STEP, "fixedStep");
  const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS);

  let spawn = checkedSpawn(plan, options, radius);
  let levelId = spawn.levelId;
  let position = copy(spawn.position);
  const initialFacing = normalizedFacing(options.facing);
  let facing: Point2 = copy(initialFacing);
  let distance = 0;
  let accumulator = 0;

  function snapshot(): WalkerState {
    return {
      levelId,
      position: copy(position),
      facing: copy(facing),
      distance,
    };
  }

  function reset(next = spawn): WalkerState {
    spawn = checkedSpawn(plan, next, radius);
    levelId = spawn.levelId;
    position = copy(spawn.position);
    facing = copy(initialFacing);
    distance = 0;
    accumulator = 0;
    return snapshot();
  }

  function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState {
    // The internals are private, but this also makes the recovery policy clear
    // if a future refactor exposes a mutable transport/state object.
    if (!finitePoint(position) || !validPosition(plan, levelId, position, radius)) reset();
    if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();

    const action = normalizeWalkerAction(rawAction);
    if (Math.hypot(action.x, action.z) > EPSILON) facing = copy(action);

    const maxBacklog = fixedStep * maxCatchUpSteps;
    accumulator = Math.min(maxBacklog, accumulator + elapsedSeconds);
    let steps = 0;
    while (accumulator + EPSILON >= fixedStep && steps < maxCatchUpSteps) {
      accumulator -= fixedStep;
      if (accumulator < 0) accumulator = 0;
      steps += 1;
      const amount = speed * fixedStep;
      const before = position;
      position = moveWithSliding(plan, levelId, position, {
        x: action.x * amount,
        z: action.z * amount,
      }, radius);
      distance += Math.hypot(position.x - before.x, position.z - before.z);
    }
    return snapshot();
  }

  /**
   * The rule a snapshot has to pass, and the reason it is written this way.
   *
   * It used to be "the level equals the spawn level", which made a snapshot from
   * an upper storey illegal even when it described a place a walker could
   * plainly stand. The rule is now the one it should always have been — **a
   * level this plan resolves, at a position that is valid on it** — which is
   * strictly the check the next line already performed, with the spawn
   * comparison removed. Everything else a bad snapshot can be is still refused:
   * a non-finite coordinate, a negative odometer, a position outside the level's
   * bounds or inside a wall.
   *
   * This is an Arena contract rather than an internal: `src/arena/officeNav.ts`
   * restores an episode through it, and what a snapshot may legally contain is
   * defined here and nowhere else.
   */
  function restore(next: WalkerState): WalkerState {
    if (
      typeof next.levelId !== "string" || !finitePoint(next.position) || !finitePoint(next.facing) ||
      !Number.isFinite(next.distance) || next.distance < 0 ||
      !validPosition(plan, next.levelId, next.position, radius)
    ) throw new RangeError("walker snapshot is incompatible or invalid");
    levelId = next.levelId;
    position = copy(next.position);
    facing = normalizedFacing(next.facing);
    distance = next.distance;
    accumulator = 0;
    return snapshot();
  }

  /**
   * Step onto another storey, keeping everything else.
   *
   * The whole of the cross-level mechanism, and deliberately the whole of it:
   * the odometer keeps running, the facing is kept, the spawn is untouched, and
   * the caller has already decided both that a transition exists and where on
   * the far level it lands. `Plan.transitionAt` is the thing that decides;
   * `officeWalker` is the thing that animates the climb. This only moves the
   * two numbers that make it true.
   *
   * It throws rather than dropping, because unlike an authored pack this is
   * called by engine code with a position the resolver has already validated —
   * a failure here is a bug upstream, not a typo in somebody's floorplan.
   */
  function enterLevel(nextLevelId: string, nextPosition: Point2, nextFacing?: Point2): WalkerState {
    if (!nextLevelId || !finitePoint(nextPosition) || !validPosition(plan, nextLevelId, nextPosition, radius)) {
      throw new RangeError(`walker cannot enter level "${nextLevelId}" at that position`);
    }
    levelId = nextLevelId;
    position = copy(nextPosition);
    if (nextFacing !== undefined) facing = normalizedFacing(nextFacing);
    accumulator = 0;
    return snapshot();
  }

  return { state: snapshot, tick, reset, restore, enterLevel };
}

function normalizedFacing(value: Point2 | undefined): Point2 {
  if (!value || !finitePoint(value)) return { x: 0, z: -1 };
  const length = Math.hypot(value.x, value.z);
  return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 };
}

function moveWithSliding(
  plan: WalkerPlan,
  levelId: string,
  start: Point2,
  displacement: Point2,
  radius: number,
): Point2 {
  const level = plan.level(levelId);
  if (!level) return copy(start);

  // A configured high speed still cannot tunnel: no sweep is longer than half
  // a radius. `Plan.blocked` is swept too; the subdivision primarily makes a
  // corner followed by a slide behave consistently.
  const length = Math.hypot(displacement.x, displacement.z);
  const slices = Math.max(1, Math.ceil(length / Math.max(radius * 0.5, 0.01)));
  const slice = { x: displacement.x / slices, z: displacement.z / slices };
  let at = copy(start);
  for (let index = 0; index < slices; index += 1) {
    at = moveSlice(plan, levelId, level.bounds, level.collision, at, slice, radius);
  }
  return at;
}

function moveSlice(
  plan: WalkerPlan,
  levelId: string,
  bounds: Bounds,
  segments: readonly Segment[],
  start: Point2,
  initial: Point2,
  radius: number,
): Point2 {
  let at = copy(start);
  let remaining = copy(initial);

  for (let pass = 0; pass < SLIDE_PASSES; pass += 1) {
    if (Math.hypot(remaining.x, remaining.z) <= EPSILON) break;
    const target = bounded(add(at, remaining), bounds, radius);
    const attempted = { x: target.x - at.x, z: target.z - at.z };
    if (Math.hypot(attempted.x, attempted.z) <= EPSILON) break;
    if (!plan.blocked(levelId, at, target, radius)) {
      at = target;
      break;
    }

    const fraction = clearFraction(plan, levelId, at, attempted, radius);
    if (fraction > 0) at = add(at, scale(attempted, fraction));
    const left = scale(attempted, 1 - fraction);
    const wall = nearestBlockingSegment(at, add(at, left), segments, radius);
    if (!wall) break;

    const wx = wall.to.x - wall.from.x;
    const wz = wall.to.z - wall.from.z;
    const wallLength = Math.hypot(wx, wz);
    if (wallLength <= EPSILON) break;
    const tx = wx / wallLength;
    const tz = wz / wallLength;
    const along = left.x * tx + left.z * tz;
    remaining = { x: tx * along, z: tz * along };
  }
  return at;
}

/** Largest prefix of a blocked displacement whose whole swept capsule is clear. */
function clearFraction(
  plan: WalkerPlan,
  levelId: string,
  start: Point2,
  displacement: Point2,
  radius: number,
): number {
  let low = 0;
  let high = 1;
  for (let index = 0; index < BISECTION_STEPS; index += 1) {
    const middle = (low + high) / 2;
    if (plan.blocked(levelId, start, add(start, scale(displacement, middle)), radius)) high = middle;
    else low = middle;
  }
  // Stay microscopically on the clear side so the projected slide does not
  // begin inside the wall because of a last-bit rounding difference.
  return Math.max(0, low - 1e-7);
}

function nearestBlockingSegment(
  from: Point2,
  to: Point2,
  segments: readonly Segment[],
  radius: number,
): Segment | null {
  let nearest: Segment | null = null;
  let best = Infinity;
  for (const segment of segments) {
    const distance = segmentDistance(from, to, segment.from, segment.to);
    const clearance = radius + segment.thickness / 2;
    if (distance >= clearance + 1e-6 || distance >= best) continue;
    best = distance;
    nearest = segment;
  }
  return nearest;
}

function checkedSpawn(plan: WalkerPlan, spawn: WalkerSpawn, radius: number): WalkerSpawn {
  if (!spawn.levelId || !finitePoint(spawn.position)) {
    throw new RangeError("walker spawn must name a level and contain finite coordinates");
  }
  if (!validPosition(plan, spawn.levelId, spawn.position, radius)) {
    throw new RangeError("walker spawn must be inside the level bounds and clear of walls");
  }
  return { levelId: spawn.levelId, position: copy(spawn.position) };
}

function validPosition(plan: WalkerPlan, levelId: string, point: Point2, radius: number): boolean {
  const level = plan.level(levelId);
  return level !== null && inside(point, level.bounds, radius) && !plan.blocked(levelId, point, point, radius);
}

function inside(point: Point2, bounds: Bounds, radius: number): boolean {
  return (
    point.x >= bounds.minX + radius && point.x <= bounds.maxX - radius &&
    point.z >= bounds.minZ + radius && point.z <= bounds.maxZ - radius
  );
}

function bounded(point: Point2, bounds: Bounds, radius: number): Point2 {
  return {
    x: Math.min(bounds.maxX - radius, Math.max(bounds.minX + radius, point.x)),
    z: Math.min(bounds.maxZ - radius, Math.max(bounds.minZ + radius, point.z)),
  };
}

function positive(value: number, name: string): number {
  if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
  return value;
}

function integer(value: number): number {
  if (!Number.isInteger(value) || value < 1) throw new RangeError("maxCatchUpSteps must be a positive integer");
  return value;
}

function finitePoint(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 add(a: Point2, b: Point2): Point2 {
  return { x: a.x + b.x, z: a.z + b.z };
}

function scale(point: Point2, amount: number): Point2 {
  return { x: point.x * amount, z: point.z * amount };
}

function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number {
  if (segmentsCross(a1, a2, b1, b2)) return 0;
  return Math.min(
    pointSegmentDistance(a1, b1, b2),
    pointSegmentDistance(a2, b1, b2),
    pointSegmentDistance(b1, a1, a2),
    pointSegmentDistance(b2, a1, a2),
  );
}

function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
  const ab1 = cross(a1, a2, b1);
  const ab2 = cross(a1, a2, b2);
  const ba1 = cross(b1, b2, a1);
  const ba2 = cross(b1, b2, a2);
  // Proper crossing only. Collinear, disjoint segments must fall through to
  // endpoint distance; treating every collinear pair as a crossing would make
  // a walker sliding parallel to a distant wall collide with it.
  return ab1 * ab2 < 0 && ba1 * ba2 < 0;
}

function cross(a: Point2, b: Point2, point: Point2): number {
  return (b.x - a.x) * (point.z - a.z) - (b.z - a.z) * (point.x - a.x);
}

function pointSegmentDistance(point: Point2, a: Point2, b: Point2): number {
  const dx = b.x - a.x;
  const dz = b.z - a.z;
  const lengthSquared = dx * dx + dz * dz;
  if (lengthSquared <= EPSILON) return Math.hypot(point.x - a.x, point.z - a.z);
  const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared));
  return Math.hypot(point.x - (a.x + t * dx), point.z - (a.z + t * dz));
}
