import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { createOfficeWalker } from "../interiors/officeWalker.ts";
import { Plan } from "../interiors/plan.ts";
import type { Level, Office, Room, Wall } from "../interiors/types.ts";

const FLOOR: Room = {
  id: "floor",
  name: "Floor",
  floor: "floor" as never,
  outline: [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 8 }, { x: 0, z: 8 }],
};

function makePlan(walls: Wall[] = [], elevation = 2.5): Plan {
  const level: Level = {
    id: "ground",
    name: "Ground",
    elevation,
    wallHeight: 3,
    wallThickness: 0.1,
    floorplan: { rooms: [FLOOR], walls },
  };
  const office: Office = { id: "actor-test", name: "Actor Test", levels: [level], viewpoints: [] };
  return new Plan(office, { warn: false });
}

describe("office walker actor adapter", () => {
  it("is inert by default and becomes a floor-aware humanoid when activated", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 2, z: 2 },
      speed: 1,
      fixedStep: 0.1,
    });
    assert.equal(actor.state().actor, "humanoid");
    assert.equal(actor.root.position.y, 2.5);
    actor.setAction({ x: 2, z: 0 });
    actor.tick(0.2);
    assert.deepEqual(actor.state().position, { x: 2, z: 2 });
    actor.setActive(true);
    assert.deepEqual(actor.action(), { x: 0, z: 0 }, "activation never replays stale input");
    actor.setAction({ x: 2, z: 0 });
    actor.tick(0.2);
    assert.ok(actor.state().position.x > 2.19);
    assert.equal(actor.view.position.y, 2.5);
    assert.ok(Math.abs(actor.root.rotation.y + Math.PI / 2) < 1e-12);
    actor.dispose();
  });

  it("builds an anonymous dog and publishes a defensive chase-camera contract", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 4, z: 4 },
      actor: { kind: "anonymous-dog", coatColor: 0x222222 },
      active: true,
    });
    assert.equal(actor.root.userData.actorType, "anonymous-dog");
    const pose = actor.followPose();
    assert.ok(pose.position.z > 4, "camera is behind a -Z-facing actor");
    assert.ok(pose.target.z < 4, "camera aims ahead of the actor");
    pose.position.x = 999;
    assert.notEqual(actor.followPose().position.x, 999);
    actor.dispose();
  });

  it("presents deterministic dog start, office turn, trot and stop semantics", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 4, z: 4 },
      actor: { kind: "anonymous-dog" },
      speed: 1.6,
      fixedStep: 0.1,
      active: true,
    });
    const dog = actor.root.getObjectByName("dog") as THREE.Group;
    assert.equal(dog.userData.pose, "idle");

    actor.setAction({ x: 0, z: -1 });
    actor.tick(0.1);
    assert.equal(dog.userData.pose, "walk", "eased first step does not pop directly into a trot");
    assert.equal(dog.userData.acceleration, 1);
    actor.tick(0.1);
    assert.equal(dog.userData.pose, "trot");

    actor.setAction({ x: 1, z: 0 });
    actor.tick(0.1);
    assert.ok(Math.abs(Number(dog.userData.turnLean)) > 0.9);
    const body = dog.getObjectByName("dog.body") as THREE.Group;
    assert.notEqual(body.rotation.z, 0);

    actor.setAction({ x: 0, z: 0 });
    actor.tick(0.1);
    assert.equal(dog.userData.pose, "idle");
    assert.equal(dog.userData.acceleration, -1);
    actor.dispose();
  });

  it("uses resolved door gaps while retaining wall collision and reset state", () => {
    const plan = makePlan([{
      id: "divider",
      from: { x: 5, z: 0 },
      to: { x: 5, z: 8 },
      openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
    }]);
    const actor = createOfficeWalker(plan, {
      levelId: "ground",
      position: { x: 4, z: 4 },
      speed: 2,
      fixedStep: 0.1,
      active: true,
    });
    actor.setAction({ x: 1, z: 0 });
    for (let index = 0; index < 10; index += 1) actor.tick(0.1);
    assert.ok(actor.state().position.x > 5.5);
    const reset = actor.reset({ levelId: "ground", position: { x: 2, z: 2 } });
    assert.deepEqual(reset.position, { x: 2, z: 2 });
    assert.deepEqual(reset.action, { x: 0, z: 0 });
    assert.equal(reset.distance, 0);
    actor.dispose();
  });

  it("removes and disposes its actor root idempotently", () => {
    const actor = createOfficeWalker(makePlan(), { levelId: "ground", position: { x: 2, z: 2 } });
    const parent = new THREE.Group();
    parent.add(actor.root);
    actor.dispose();
    actor.dispose();
    assert.equal(actor.root.parent, null);
  });

  it("refreshes appearance under a stable root without changing walker or camera state", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 2, z: 2 },
      speed: 1,
      fixedStep: 0.1,
      active: true,
      camera: { distance: 4, height: 2.8, targetHeight: 1.1, lookAhead: 0.9 },
    });
    const root = actor.root;
    actor.setAction({ x: 1, z: 0 });
    actor.tick(0.3);
    const before = actor.state();
    const cameraBefore = actor.followPose();
    const oldRig = root.children[0];
    const after = actor.setAppearance({ kind: "anonymous-dog", coatColor: 0x222222 });
    assert.equal(actor.root, root);
    assert.equal(oldRig?.parent, null);
    assert.equal(after.actor, "anonymous-dog");
    assert.deepEqual(after.position, before.position);
    assert.deepEqual(after.facing, before.facing);
    assert.equal(after.distance, before.distance);
    assert.equal(after.active, before.active);
    assert.deepEqual(after.action, before.action);
    assert.deepEqual(actor.followPose().position.toArray(), cameraBefore.position.toArray());
    assert.deepEqual(actor.followPose().target.toArray(), cameraBefore.target.toArray());
    assert.equal(root.userData.actorType, "anonymous-dog");
    actor.dispose();
  });

  it("keeps humanoid face texture caller-owned across a skin refresh and clear", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 2, z: 2 },
      actor: { kind: "humanoid", outfitColor: 0x112233 },
    });
    const texture = new THREE.Texture();
    let textureDisposals = 0;
    texture.addEventListener("dispose", () => textureDisposals++);
    assert.equal(actor.attachFaceTexture(texture), true);
    const firstFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
    const firstMaterial = firstFace.material as THREE.MeshBasicMaterial;
    assert.equal(firstMaterial.map, texture);
    actor.setAppearance({ kind: "humanoid", outfitColor: 0x334455, bodyShape: "broad" });
    const secondFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
    assert.notEqual(secondFace, firstFace);
    assert.equal(firstMaterial.map, null);
    assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, texture);
    actor.clearFaceTexture();
    assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
    actor.attachFaceTexture(texture);
    actor.dispose();
    assert.equal(textureDisposals, 0);
    assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
  });

  it("does not accept a face texture while the office actor is anonymous", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 2, z: 2 },
      actor: { kind: "anonymous-dog" },
    });
    assert.equal(actor.attachFaceTexture(new THREE.Texture()), false);
    actor.dispose();
  });
});

// ---- The climb -------------------------------------------------------------

/**
 * Two storeys and one dog-leg stair between them, sized so that the numbers in
 * the assertions are readable: 4 m of rise, two 3 m flights and a flat landing.
 *
 * The upper footprint deliberately sits at the *edge* of the upper floor, which
 * is what it does in `mateo-court`: the way up arrives through a gap in a
 * balustrade, and covering that gap is the thing that stops a walker strolling
 * out of it.
 */
function stairPlan(): Plan {
  const room = (id: string, outline: Room["outline"]): Room =>
    ({ id, name: id, floor: "floor" as never, outline });
  const lower: Level = {
    id: "level-1",
    name: "Ground",
    elevation: 0,
    wallHeight: 3,
    wallThickness: 0.1,
    floorplan: { rooms: [room("l1", FLOOR.outline)], walls: [] },
  };
  const upper: Level = {
    id: "level-2",
    name: "Upper",
    elevation: 4,
    wallHeight: 3,
    wallThickness: 0.1,
    floorplan: { rooms: [room("l2", FLOOR.outline)], walls: [] },
  };
  const office: Office = {
    id: "stair-test",
    name: "Stair Test",
    levels: [lower, upper],
    viewpoints: [],
    transitions: [{
      id: "stair",
      kind: "stair",
      lower: {
        levelId: "level-1",
        footprint: [{ x: 1, z: 1 }, { x: 3, z: 1 }, { x: 3, z: 3 }, { x: 1, z: 3 }],
        landing: { x: 2, z: 2 },
      },
      upper: {
        levelId: "level-2",
        footprint: [{ x: 7, z: 1 }, { x: 9, z: 1 }, { x: 9, z: 3 }, { x: 7, z: 3 }],
        landing: { x: 8, z: 2 },
      },
      legs: [
        { to: { x: 5, z: 2 }, rise: 1 },
        { to: { x: 5, z: 5 }, rise: 0 },
        { to: { x: 8, z: 2 }, rise: 1 },
      ],
    }],
  };
  return new Plan(office, { warn: false });
}

function climber(plan: Plan) {
  const actor = createOfficeWalker(plan, {
    levelId: "level-1",
    position: { x: 6, z: 2 },
    speed: 2,
    fixedStep: 0.05,
    active: true,
  });
  return actor;
}

/** Walk west into the foot of the stair and return once the crossing has begun. */
function walkIntoTheStair(actor: ReturnType<typeof climber>): void {
  actor.setAction({ x: -1, z: 0 });
  for (let step = 0; step < 60 && actor.state().crossing === null; step += 1) actor.tick(0.05);
}

describe("office walker crossings", () => {
  it("climbs a stair, changes storey once, and keeps the odometer running", () => {
    const actor = climber(stairPlan());
    const start = actor.state();
    assert.equal(start.levelId, "level-1");
    assert.equal(actor.root.position.y, 0);

    walkIntoTheStair(actor);
    const walked = actor.state().distance;
    assert.equal(actor.state().crossing, "stair");
    assert.ok(walked > 2.5, `${walked}`);

    // Half way up: still on the lower storey by state, visibly between floors.
    const seen: number[] = [];
    for (let step = 0; step < 4; step += 1) {
      actor.tick(0.1);
      seen.push(actor.root.position.y);
    }
    assert.ok(seen[seen.length - 1]! > 0, `${seen.join()}`);
    assert.ok(seen[seen.length - 1]! < 4, `${seen.join()}`);
    // The camera rides the actor up rather than cutting to a floor height.
    assert.ok(Math.abs(actor.followPose().position.y - (actor.root.position.y + 2.25)) < 1e-9);

    for (let step = 0; step < 200 && actor.state().crossing !== null; step += 1) actor.tick(0.05);
    const top = actor.state();
    assert.equal(top.crossing, null);
    assert.equal(top.levelId, "level-2");
    assert.deepEqual(top.position, { x: 8, z: 2 });
    assert.equal(actor.root.position.y, 4);
    // `reset()` was not the transition path: the odometer survived the climb.
    assert.equal(top.distance, walked);

    // Every height on the way up was between the two floors, in order.
    for (let index = 1; index < seen.length; index += 1) {
      assert.ok(seen[index]! >= seen[index - 1]!, `${seen.join()}`);
    }
    actor.dispose();
  });

  it("does not immediately fall back down the stair it just came up", () => {
    const actor = climber(stairPlan());
    walkIntoTheStair(actor);
    for (let step = 0; step < 200 && actor.state().crossing !== null; step += 1) actor.tick(0.05);
    assert.equal(actor.state().levelId, "level-2");

    // Still inside the upper footprint, still holding a direction. The latch is
    // what stops this being an infinite loop between two floors.
    actor.setAction({ x: 1, z: 0 });
    for (let step = 0; step < 10; step += 1) actor.tick(0.05);
    assert.equal(actor.state().crossing, null);
    assert.equal(actor.state().levelId, "level-2");
    actor.dispose();
  });

  it("does not start a crossing from standing still, or while inactive", () => {
    const actor = createOfficeWalker(stairPlan(), {
      levelId: "level-1",
      position: { x: 2, z: 2 },
      speed: 2,
      fixedStep: 0.05,
      active: true,
    });
    // Standing on the foot of the flight, holding nothing.
    for (let step = 0; step < 20; step += 1) actor.tick(0.05);
    assert.equal(actor.state().crossing, null);
    assert.equal(actor.state().levelId, "level-1");

    actor.setActive(false);
    actor.setAction({ x: 1, z: 0 });
    for (let step = 0; step < 20; step += 1) actor.tick(0.05);
    assert.equal(actor.state().crossing, null);
    assert.equal(actor.state().levelId, "level-1");
    actor.dispose();
  });

  it("lands on one storey when a crossing is interrupted", () => {
    const inactive = climber(stairPlan());
    walkIntoTheStair(inactive);
    inactive.tick(0.1);
    assert.equal(inactive.state().crossing, "stair");
    inactive.setActive(false);
    assert.equal(inactive.state().crossing, null);
    assert.equal(inactive.state().levelId, "level-2");
    assert.equal(inactive.root.position.y, 4);
    inactive.dispose();

    // `reset` is a teleport to a known place, so it abandons the climb rather
    // than completing it — but it still lands on a storey, never between two.
    const restarted = climber(stairPlan());
    walkIntoTheStair(restarted);
    restarted.tick(0.1);
    restarted.reset();
    assert.equal(restarted.state().crossing, null);
    assert.equal(restarted.state().levelId, "level-1");
    assert.deepEqual(restarted.state().position, { x: 6, z: 2 });
    assert.equal(restarted.root.position.y, 0);
    restarted.dispose();
  });

  it("walks normally in a pack that authors no transitions", () => {
    const actor = createOfficeWalker(makePlan(), {
      levelId: "ground",
      position: { x: 2, z: 2 },
      speed: 1,
      fixedStep: 0.1,
      active: true,
    });
    actor.setAction({ x: 1, z: 0 });
    for (let step = 0; step < 10; step += 1) actor.tick(0.1);
    assert.equal(actor.state().crossing, null);
    assert.ok(actor.state().position.x > 2.9);
    actor.dispose();
  });
});
