/**
 * The office-facing half of walk mode: a numeric `WalkerController` wearing an
 * original actor rig and publishing a third-person camera pose.
 *
 * There is deliberately no identity, webcam capture, keyboard or network code here.
 * Callers choose humanoid or anonymous dog, translate their own input device to
 * a normalized planar action, and decide when walk mode is active. A caller may
 * attach an already-created face texture; ownership stays with that caller.
 *
 * ### The climb lives here, and it is presentation
 *
 * `WalkerState` has no `y` and is never getting one: height is fully determined
 * by the storey you are on, `Plan` owns that number, and adding a vertical to
 * the controller would ripple into every consumer of `floorY` for a value none
 * of them may disagree about. So a crossing between two storeys is *this* file's
 * job. It interpolates the actor's world height and the follow camera's along
 * `ResolvedTransition.path` — the same list of points the drawn treads are built
 * from — and hands the controller its new storey exactly once, at the midpoint,
 * through `enterLevel`.
 *
 * Three rules the crossing keeps, and each of them is a defect avoided:
 *
 * - **Both levels resolve before it starts.** `sync` and `followPose` used to
 *   throw outright on a level they could not find, which for a cross-level
 *   handoff means killing the RAF loop mid-frame. They now fall back to the last
 *   height they knew, and a crossing refuses to begin at all unless both ends
 *   are already resolved — the failure is checked where it can be reported
 *   rather than where it would be fatal.
 * - **It always lands on one storey.** Going inactive, resetting or disposing
 *   part-way through completes the handover immediately rather than abandoning
 *   the actor between two floors.
 * - **You have to leave before you can come back.** Arriving puts the walker
 *   inside the far footprint, which is a way back down; without a latch a
 *   staircase would be an infinite loop and the actor would oscillate.
 */

import * as THREE from "three";
import {
  buildDog,
  dogPoseStateForSpeed,
  disposeDog,
  poseDog,
  type DogRig,
} from "../assets/actors/dog.ts";
import {
  buildHumanoid,
  disposeHumanoid,
  poseHumanoid,
  type HumanoidRig,
} from "../assets/actors/humanoid.ts";
import type { Pose } from "../engine/scenekit.ts";
import type { Plan, ResolvedTransition, TransitionSide } from "./plan.ts";
import {
  createWalker,
  normalizeWalkerAction,
  type WalkerAction,
  type WalkerController,
  type WalkerOptions,
  type WalkerSpawn,
  type WalkerState,
} from "./walker.ts";

const HUMANOID_CAMERA = { distance: 3.2, height: 2.25, targetHeight: 1.25, lookAhead: 0.7 };
const DOG_CAMERA = { distance: 2.6, height: 1.45, targetHeight: 0.48, lookAhead: 0.55 };
const STRIDE_METRES = 0.72;
const GAIT_EASE_SECONDS = 0.16;

/**
 * How fast the actor moves along a transition path, as a multiple of its own
 * walking speed.
 *
 * Faster than walking, because the alternative is worse. `mateo-court`'s stair
 * is eleven and a half metres of path — two flights, a half landing and the
 * approach — and traversing that at 1.6 m/s is seven seconds during which
 * nothing the viewer does has any effect. A climb is an event, not a cutscene.
 */
const CLIMB_SPEED_FACTOR = 1.45;

/**
 * Hard bounds on a crossing, in seconds.
 *
 * The floor stops a one-metre step between a mezzanine and its landing being an
 * instantaneous jump; the ceiling stops a long flight taking the controls away
 * for longer than anybody will sit still for.
 */
const MIN_CLIMB_SECONDS = 0.9;
const MAX_CLIMB_SECONDS = 3.6;

/** Below this, the actor is standing still and a crossing has no reason to start. */
const CLIMB_INPUT_EPSILON = 1e-3;

export type OfficeActorKind = "humanoid" | "anonymous-dog";

export interface OfficeActorAppearance {
  kind: OfficeActorKind;
  skinTone?: THREE.ColorRepresentation;
  outfitColor?: THREE.ColorRepresentation;
  accentColor?: THREE.ColorRepresentation;
  hairColor?: THREE.ColorRepresentation;
  bodyShape?: "slim" | "average" | "broad";
  coatColor?: THREE.ColorRepresentation;
  markingsColor?: THREE.ColorRepresentation;
  collarColor?: THREE.ColorRepresentation;
}

export interface FollowCameraOptions {
  /** Metres behind the actor. */
  distance?: number;
  /** Camera height above this level's floor, in metres. */
  height?: number;
  /** Look target height above this level's floor, in metres. */
  targetHeight?: number;
  /** Metres ahead of the actor to aim. */
  lookAhead?: number;
}

export interface OfficeWalkerOptions extends WalkerOptions {
  actor?: OfficeActorAppearance;
  camera?: FollowCameraOptions;
  /** False by default: constructing an actor must not change dollhouse controls. */
  active?: boolean;
}

export interface OfficeWalkerState extends WalkerState {
  active: boolean;
  actor: OfficeActorKind;
  action: WalkerAction;
  /**
   * The transition the actor is part-way along, or `null`.
   *
   * `levelId` is still authoritative and still flips exactly once, halfway
   * through — a consumer that only wants to know which storey to draw needs
   * nothing from this field. It is here so that a caller which drives input can
   * see that input is currently going nowhere, and so a test can assert the
   * crossing rather than infer it from a height.
   */
  crossing: string | null;
}

export interface OfficeWalker {
  /** Floor-centred, scene-ready actor root. */
  root: THREE.Group;
  /** Stable live position for occupancy-responsive office lighting. */
  view: { position: THREE.Vector3 };
  state(): OfficeWalkerState;
  active(): boolean;
  setActive(active: boolean): void;
  action(): WalkerAction;
  setAction(action: WalkerAction): WalkerAction;
  tick(elapsedSeconds: number): OfficeWalkerState;
  reset(spawn?: WalkerSpawn): OfficeWalkerState;
  /** Rebuild only the procedural skin, retaining this root and all walker/camera state. */
  setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState;
  /** Attach a caller-owned texture. False for an anonymous dog. */
  attachFaceTexture(texture: THREE.Texture): boolean;
  /** Detach the current face without disposing the caller's texture. */
  clearFaceTexture(): void;
  /** A defensive scene-space chase-camera pose for the current actor state. */
  followPose(): Pose;
  dispose(): void;
}

/** One crossing in flight. Presentation only: nothing here is walker state. */
interface Crossing {
  transition: ResolvedTransition;
  toLevelId: string;
  toPosition: { x: number; z: number };
  /** The direction of the last leg: which way the actor comes off the treads. */
  toFacing: { x: number; z: number };
  /** Foot-to-head or head-to-foot, in office-world metres, current pose first. */
  path: readonly { x: number; y: number; z: number }[];
  /** Cumulative 3-D length at each point. `spans[0]` is 0. */
  spans: readonly number[];
  length: number;
  seconds: number;
  elapsed: number;
  handedOver: boolean;
}

type Actor =
  | { kind: "humanoid"; rig: HumanoidRig }
  | { kind: "anonymous-dog"; rig: DogRig };

export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
  let actor = buildActor(options.actor ?? { kind: "humanoid" });
  const controller: WalkerController = createWalker(plan, options);
  const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
  const camera = {
    distance: positive(options.camera?.distance ?? baseCamera.distance, "camera.distance"),
    height: positive(options.camera?.height ?? baseCamera.height, "camera.height"),
    targetHeight: nonNegative(options.camera?.targetHeight ?? baseCamera.targetHeight, "camera.targetHeight"),
    lookAhead: nonNegative(options.camera?.lookAhead ?? baseCamera.lookAhead, "camera.lookAhead"),
  };

  let enabled = options.active ?? false;
  let desired: WalkerAction = { x: 0, z: 0 };
  let gait = 0;
  let presentationSeconds = 0;
  let previousSpeed = 0;
  const previousFacing = new THREE.Vector2(0, -1);
  let faceTexture: THREE.Texture | null = null;
  let disposed = false;
  let lastFloorY = plan.level(options.levelId)?.floorY ?? 0;
  let warnedLostLevel = false;
  let crossing: Crossing | null = null;
  /**
   * The transition whose footprint the actor is standing in and has not yet
   * left.
   *
   * Arriving from a crossing puts you inside the *far* footprint, which is a way
   * straight back. Without this latch a staircase is an infinite loop: up, down,
   * up, for as long as the key is held.
   */
  let latched: string | null = null;
  const root = new THREE.Group();
  root.name = "office-walker-actor";
  root.userData.kind = "playable-actor";
  root.userData.forwardAxis = "-Z";
  root.userData.actorType = actor.rig.root.userData.actorType;
  root.add(actor.rig.root);
  const view = { position: new THREE.Vector3() };

  /**
   * This level's floor height, or the last one we knew.
   *
   * It used to throw. A `throw` from inside `tick` kills the RAF loop for the
   * whole scene, and the case it fires on — a level that stops resolving — is
   * precisely the one a cross-level handoff could produce. Everywhere else in
   * the interiors stack the discipline is to report and carry on, so this
   * carries on at the last known height and says so once. A crossing is refused
   * outright if either end is unresolved, which is where that failure is
   * actually catchable.
   */
  function floorYOf(levelId: string): number {
    const level = plan.level(levelId);
    if (level) {
      lastFloorY = level.floorY;
      return level.floorY;
    }
    if (!warnedLostLevel) {
      warnedLostLevel = true;
      console.warn(`office walker lost level "${levelId}"; holding the last known floor`);
    }
    return lastFloorY;
  }

  function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0, override?: {
    x: number;
    y: number;
    z: number;
  }): void {
    const floorY = floorYOf(state.levelId);
    if (override) root.position.set(override.x, override.y, override.z);
    else root.position.set(state.position.x, floorY, state.position.z);
    view.position.copy(root.position);
    root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);

    const speed = elapsedSeconds > 0 ? Math.max(0, travelled / elapsedSeconds) : 0;
    const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
    gait += (targetGait - gait) * Math.min(1, elapsedSeconds / GAIT_EASE_SECONDS);
    presentationSeconds += elapsedSeconds;
    const phase = speed > 1e-5
      ? (state.distance / STRIDE_METRES) * Math.PI * 2
      : presentationSeconds * 2.2;
    const facingDot = THREE.MathUtils.clamp(
      previousFacing.x * state.facing.x + previousFacing.y * state.facing.z,
      -1,
      1,
    );
    const facingCross = previousFacing.x * state.facing.z - previousFacing.y * state.facing.x;
    const turn = elapsedSeconds > 0
      ? THREE.MathUtils.clamp(Math.atan2(facingCross, facingDot) / elapsedSeconds / 4, -1, 1)
      : 0;
    const acceleration = elapsedSeconds > 0
      ? THREE.MathUtils.clamp((speed - previousSpeed) / elapsedSeconds / 5, -1, 1)
      : 0;
    if (actor.kind === "humanoid") {
      poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
    } else {
      const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2));
      poseDog(actor.rig, {
        state: dogState,
        phase,
        amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + gait * 0.62, 0, 1),
        turn,
        acceleration,
        attentionYaw: -turn * 0.08,
      });
    }
    previousSpeed = speed;
    previousFacing.set(state.facing.x, state.facing.z);
  }

  /**
   * Start a crossing, or decline to.
   *
   * Declining is silent and is the ordinary answer: standing still on a stair
   * is standing on a stair. It refuses outright — rather than beginning and
   * failing — when either level is unresolved or the far landing is not a place
   * the controller would accept, because a handover that throws does it inside
   * the frame loop where nothing can recover.
   */
  function beginCrossing(side: TransitionSide): boolean {
    if (plan.level(side.from.levelId) === null || plan.level(side.to.levelId) === null) return false;

    const authored = side.transition.path;
    const ordered = side.ascending ? authored : [...authored].reverse();
    // The actor triggers at the edge of the footprint, which is usually a metre
    // or so from the landing the path starts at. Starting the path where the
    // actor actually is turns that gap into the first stride of the climb
    // rather than a jump.
    const here = { x: root.position.x, y: root.position.y, z: root.position.z };
    const head = ordered[0]!;
    const path = Math.hypot(here.x - head.x, here.z - head.z) > 0.05
      ? [here, ...ordered]
      : [...ordered];

    const spans: number[] = [0];
    let length = 0;
    for (let index = 1; index < path.length; index += 1) {
      const a = path[index - 1]!;
      const b = path[index]!;
      length += Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
      spans.push(length);
    }
    if (!(length > 1e-4)) return false;

    const speed = (options.speed ?? 1.6) * CLIMB_SPEED_FACTOR;
    const penultimate = path[path.length - 2]!;
    const arrival = path[path.length - 1]!;
    const lastX = arrival.x - penultimate.x;
    const lastZ = arrival.z - penultimate.z;
    const lastRun = Math.hypot(lastX, lastZ);
    crossing = {
      transition: side.transition,
      toLevelId: side.to.levelId,
      toPosition: { x: side.to.landing.x, z: side.to.landing.z },
      // A vertical last leg — a lift — has no direction of its own, and the
      // pack's authored `facing` is the answer there. Failing both, the actor
      // keeps what it had.
      toFacing: lastRun > 1e-6
        ? { x: lastX / lastRun, z: lastZ / lastRun }
        : { x: Math.sin(side.to.facing), z: -Math.cos(side.to.facing) },
      path,
      spans,
      length,
      seconds: Math.min(MAX_CLIMB_SECONDS, Math.max(MIN_CLIMB_SECONDS, length / speed)),
      elapsed: 0,
      handedOver: false,
    };
    latched = side.transition.id;
    return true;
  }

  /** The pose at a fraction of the way along a crossing, and the way it faces. */
  function poseAlong(active: Crossing, t: number): {
    position: { x: number; y: number; z: number };
    facing: { x: number; z: number };
  } {
    const target = Math.min(active.length, Math.max(0, t * active.length));
    let index = 1;
    while (index < active.spans.length - 1 && active.spans[index]! < target) index += 1;
    const a = active.path[index - 1]!;
    const b = active.path[index]!;
    const span = active.spans[index]! - active.spans[index - 1]!;
    const local = span > 1e-9 ? (target - active.spans[index - 1]!) / span : 1;
    const position = {
      x: a.x + (b.x - a.x) * local,
      y: a.y + (b.y - a.y) * local,
      z: a.z + (b.z - a.z) * local,
    };
    const dx = b.x - a.x;
    const dz = b.z - a.z;
    const flat = Math.hypot(dx, dz);
    // A lift's legs are vertical and have no heading of their own, so the actor
    // keeps the one it arrived with rather than snapping to an arbitrary axis.
    const facing = flat > 1e-6 ? { x: dx / flat, z: dz / flat } : null;
    return { position, facing: facing ?? { x: 0, z: -1 } };
  }

  /**
   * The one place `levelId` changes, and it happens exactly once per crossing.
   *
   * At the midpoint rather than at the end so that the storey the rest of the
   * application is told about — the minimap, the presence pose, the occupancy
   * lighting — changes while the actor is visibly between floors, which is the
   * only moment at which either answer is defensible.
   */
  function handOver(active: Crossing): void {
    if (active.handedOver) return;
    active.handedOver = true;
    try {
      controller.enterLevel(active.toLevelId, active.toPosition, active.toFacing);
    } catch {
      // `Plan` validated this landing when it resolved the transition, so this
      // is unreachable short of a plan swapped underneath a live crossing. Not
      // throwing is the point: the alternative is a dead frame loop.
      crossing = null;
    }
  }

  /** Finish a crossing now, wherever it had got to. Used by every interruption. */
  function completeCrossing(): void {
    const active = crossing;
    if (!active) return;
    handOver(active);
    crossing = null;
    sync(controller.state());
  }

  function snapshot(): OfficeWalkerState {
    const state = controller.state();
    return {
      ...state,
      position: { ...state.position },
      facing: { ...state.facing },
      active: enabled,
      actor: actor.kind,
      action: { ...desired },
      crossing: crossing?.transition.id ?? null,
    };
  }

  function replaceActor(appearance: OfficeActorAppearance): void {
    const previous = actor;
    actor = buildActor(appearance);
    root.add(actor.rig.root);
    if (actor.kind === "humanoid" && faceTexture) applyOfficeFaceTexture(actor.rig, faceTexture);
    if (previous.kind === "humanoid") applyOfficeFaceTexture(previous.rig, null);
    if (actor.kind !== "humanoid") faceTexture = null;
    previous.rig.root.removeFromParent();
    if (previous.kind === "humanoid") disposeHumanoid(previous.rig);
    else disposeDog(previous.rig);
    root.userData.actorType = actor.rig.root.userData.actorType;
    sync(controller.state());
  }

  sync(controller.state());

  return {
    root,
    view,
    state: snapshot,
    active: () => enabled,
    setActive(active) {
      if (active !== enabled) desired = { x: 0, z: 0 };
      enabled = active;
      // Never leave the actor between two floors. Going inactive part-way up a
      // flight lands it at the top, which is the only place the rest of the
      // application can describe.
      if (!active) completeCrossing();
    },
    action: () => ({ ...desired }),
    setAction(action) {
      desired = normalizeWalkerAction(action);
      return { ...desired };
    },
    tick(elapsedSeconds) {
      if (disposed) return snapshot();
      const dt = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;

      // A crossing owns the actor while it runs. The controller is deliberately
      // not ticked: input during a climb goes nowhere, and letting it accumulate
      // would land the walker somewhere it did not visibly walk to.
      const active = crossing;
      if (active) {
        active.elapsed += dt;
        const t = Math.min(1, active.seconds > 0 ? active.elapsed / active.seconds : 1);
        if (t >= 0.5) handOver(active);
        if (t >= 1) {
          completeCrossing();
          return snapshot();
        }
        const along = poseAlong(active, t);
        const state = controller.state();
        // The climb's own heading, not the one the controller is holding: the
        // actor has to face up the flight it is on.
        sync({ ...state, facing: along.facing }, dt, dt * (active.length / Math.max(active.seconds, 1e-6)), along.position);
        return snapshot();
      }

      const before = controller.state();
      const next = enabled
        ? controller.tick(elapsedSeconds, desired)
        : controller.tick(elapsedSeconds, { x: 0, z: 0 });

      // Standing on a way up *is* the input. There is no key to press, which is
      // why the footprints in a pack are the shape they are: the bottom of a
      // flight rather than the whole stair, and the gap in a balustrade rather
      // than the whole walkway.
      const side = plan.transitionAt(next.levelId, next.position);
      if (side === null) latched = null;
      else if (
        enabled && latched !== side.transition.id &&
        Math.hypot(desired.x, desired.z) > CLIMB_INPUT_EPSILON &&
        beginCrossing(side)
      ) {
        return snapshot();
      }

      sync(next, dt, next.distance - before.distance);
      return snapshot();
    },
    reset(spawn) {
      // Abandoned rather than completed: `reset` is a teleport to a known place,
      // so finishing the climb first would move the walker somewhere else and
      // then move it again.
      crossing = null;
      latched = null;
      const state = controller.reset(spawn);
      desired = { x: 0, z: 0 };
      gait = 0;
      presentationSeconds = 0;
      previousSpeed = 0;
      previousFacing.set(state.facing.x, state.facing.z);
      sync(state);
      return snapshot();
    },
    setAppearance(appearance) {
      if (disposed) return snapshot();
      replaceActor(appearance);
      return snapshot();
    },
    attachFaceTexture(texture) {
      if (disposed || actor.kind !== "humanoid") return false;
      if (!texture || texture.isTexture !== true) {
        throw new RangeError("office walker: face texture must be a Three.js Texture");
      }
      faceTexture = texture;
      applyOfficeFaceTexture(actor.rig, texture);
      return true;
    },
    clearFaceTexture() {
      faceTexture = null;
      if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
    },
    followPose() {
      const state = controller.state();
      // The actor's *drawn* position, so the camera rises with it across a
      // crossing instead of cutting to the destination floor at the midpoint.
      // On every other frame `root.position` is exactly the state's position at
      // this level's floor, so this is the same answer it always gave.
      const base = root.position;
      const facing = crossing ? headingOf(root.rotation.y) : state.facing;
      return {
        position: new THREE.Vector3(
          base.x - facing.x * camera.distance,
          base.y + camera.height,
          base.z - facing.z * camera.distance,
        ),
        target: new THREE.Vector3(
          base.x + facing.x * camera.lookAhead,
          base.y + camera.targetHeight,
          base.z + facing.z * camera.lookAhead,
        ),
      };
    },
    dispose() {
      if (disposed) return;
      completeCrossing();
      disposed = true;
      root.removeFromParent();
      if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
      faceTexture = null;
      if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
      else disposeDog(actor.rig);
    },
  };
}

/** The inverse of the yaw `sync` writes: a rotation back to a planar heading. */
function headingOf(yaw: number): { x: number; z: number } {
  return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
}

function buildActor(appearance: OfficeActorAppearance): Actor {
  if (appearance.kind === "anonymous-dog") {
    return {
      kind: "anonymous-dog",
      rig: buildDog({
        ...(appearance.coatColor !== undefined ? { coatColor: appearance.coatColor } : {}),
        ...(appearance.markingsColor !== undefined ? { markingsColor: appearance.markingsColor } : {}),
        ...(appearance.collarColor !== undefined ? { collarColor: appearance.collarColor } : {}),
      }),
    };
  }
  return {
    kind: "humanoid",
    rig: buildHumanoid({
      ...(appearance.skinTone !== undefined ? { skinTone: appearance.skinTone } : {}),
      ...(appearance.outfitColor !== undefined ? { outfitColor: appearance.outfitColor } : {}),
      ...(appearance.accentColor !== undefined ? { accentColor: appearance.accentColor } : {}),
      ...(appearance.hairColor !== undefined ? { hairColor: appearance.hairColor } : {}),
      ...(appearance.bodyShape !== undefined ? { bodyShape: appearance.bodyShape } : {}),
    }),
  };
}

const DEFAULT_OFFICE_FACE_COLOR = 0x18242b;

function applyOfficeFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
  const material = rig.face.material;
  if (!(material instanceof THREE.MeshBasicMaterial)) {
    throw new Error("office walker: humanoid face must use one MeshBasicMaterial");
  }
  material.map = texture;
  material.color.set(texture ? 0xffffff : DEFAULT_OFFICE_FACE_COLOR);
  material.needsUpdate = true;
}

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 nonNegative(value: number, name: string): number {
  if (value < 0 || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and non-negative`);
  return value;
}
