import { PerspectiveCamera, Vector3 } from "three";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { Vec3 } from "../../src/core/vector";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import { createPlayerStartEntity } from "../../src/entities/entity-instances";
import type {
  FirstPersonPlayerShape,
  PlayerGroundProbeResult,
  ResolvedPlayerMotion
} from "../../src/runtime-three/player-collision";
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
import { ThirdPersonNavigationController } from "../../src/runtime-three/third-person-navigation-controller";

function createMockGamepad(options: {
  axes?: number[];
} = {}): Gamepad {
  return {
    connected: true,
    axes: options.axes ?? [0, 0, 0, 0],
    buttons: Array.from({ length: 16 }, () => ({
      pressed: false,
      touched: false,
      value: 0
    })),
    id: "mock-standard-gamepad",
    index: 0,
    mapping: "standard",
    timestamp: 0,
    vibrationActuator: null,
    hapticActuators: []
  } as unknown as Gamepad;
}

function createRuntimeControllerContext(
  playerStart = createPlayerStartEntity({
    id: "entity-player-start-main",
    inputBindings: {
      keyboard: {
        moveForward: "ArrowUp",
        moveBackward: "ArrowDown",
        moveLeft: "ArrowLeft",
        moveRight: "ArrowRight"
      }
    }
  })
) {
  const runtimeScene = buildRuntimeSceneFromDocument(
    {
      ...createEmptySceneDocument({ name: "Third Person Binding Scene" }),
      entities: {
        [playerStart.id]: playerStart
      }
    },
    {
      navigationMode: "thirdPerson"
    }
  );
  const domElement = document.createElement("canvas");
  const createVolumeState = () => ({
    inWater: false,
    inFog: false,
    waterSurfaceHeight: null
  });

  return {
    context: {
      camera: new PerspectiveCamera(70, 1, 0.05, 1000),
      domElement,
      getRuntimeScene: () => runtimeScene,
      resolveFirstPersonMotion: (
        feetPosition: Vec3,
        motion: Vec3,
        _shape: FirstPersonPlayerShape
      ): ResolvedPlayerMotion => ({
        feetPosition: {
          x: feetPosition.x + motion.x,
          y: feetPosition.y + motion.y,
          z: feetPosition.z + motion.z
        },
        grounded: false,
        collisionCount: 0,
        groundCollisionNormal: null,
        collidedAxes: {
          x: false,
          y: false,
          z: false
        }
      }),
      probePlayerGround: (): PlayerGroundProbeResult => ({
        grounded: false,
        distance: null,
        normal: null,
        slopeDegrees: null
      }),
      canOccupyPlayerShape: () => true,
      resolvePlayerVolumeState: () => createVolumeState(),
      resolveThirdPersonCameraCollision: (
        _pivot: Vec3,
        desiredCameraPosition: Vec3
      ) => ({
        ...desiredCameraPosition
      }),
      isCameraDrivenExternally: () => false,
      getCameraYawRadians: () => 0,
      isInputSuspended: () => false,
      setRuntimeMessage: vi.fn(),
      setPlayerControllerTelemetry: vi.fn()
    }
  };
}

describe("ThirdPersonNavigationController", () => {
  afterEach(() => {
    vi.restoreAllMocks();
    Object.defineProperty(navigator, "getGamepads", {
      configurable: true,
      value: undefined
    });
  });

  it("uses authored keyboard bindings instead of hardcoded WASD movement", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();

    controller.activate(context);

    const initialCameraZ = context.camera.position.z;

    window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
    controller.update(1);

    expect(context.camera.position.z).toBe(initialCameraZ);

    window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" }));
    window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
    controller.update(1);

    expect(context.camera.position.z).toBeGreaterThan(initialCameraZ);

    window.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
    controller.deactivate(context);
  });

  it("uses the gamepad right stick for third-person camera orbit", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const getGamepads = vi.fn<() => Gamepad[]>(() => [
      createMockGamepad({
        axes: [0, 0, 1, 0]
      })
    ]);

    Object.defineProperty(navigator, "getGamepads", {
      configurable: true,
      value: getGamepads
    });

    controller.activate(context);

    const initialCameraX = context.camera.position.x;
    const initialCameraZ = context.camera.position.z;

    controller.update(1);

    expect(
      context.camera.position.x !== initialCameraX ||
        context.camera.position.z !== initialCameraZ
    ).toBe(true);

    controller.deactivate(context);
  });

  it("smooths the third-person camera back out when collision clears", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    let collisionScale = 0.25;
    let latestPivot: Vec3 | null = null;
    let latestDesiredCameraPosition: Vec3 | null = null;
    const distanceBetween = (left: Vec3, right: Vec3) =>
      Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);

    context.resolveThirdPersonCameraCollision = vi.fn(
      (pivot: Vec3, desiredCameraPosition: Vec3) => {
        latestPivot = { ...pivot };
        latestDesiredCameraPosition = { ...desiredCameraPosition };

        return {
          x: pivot.x + (desiredCameraPosition.x - pivot.x) * collisionScale,
          y: pivot.y + (desiredCameraPosition.y - pivot.y) * collisionScale,
          z: pivot.z + (desiredCameraPosition.z - pivot.z) * collisionScale
        };
      }
    );

    controller.activate(context);

    const blockedDistance = distanceBetween(
      {
        x: context.camera.position.x,
        y: context.camera.position.y,
        z: context.camera.position.z
      },
      latestPivot ?? { x: 0, y: 0, z: 0 }
    );

    collisionScale = 1;
    controller.update(0.1);

    const recoveringDistance = distanceBetween(
      {
        x: context.camera.position.x,
        y: context.camera.position.y,
        z: context.camera.position.z
      },
      latestPivot ?? { x: 0, y: 0, z: 0 }
    );
    const desiredDistance = distanceBetween(
      latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 },
      latestPivot ?? { x: 0, y: 0, z: 0 }
    );

    expect(recoveringDistance).toBeGreaterThan(blockedDistance);
    expect(recoveringDistance).toBeLessThan(desiredDistance);

    controller.update(1);

    const finalDistance = distanceBetween(
      {
        x: context.camera.position.x,
        y: context.camera.position.y,
        z: context.camera.position.z
      },
      latestPivot ?? { x: 0, y: 0, z: 0 }
    );
    const finalDesiredDistance = distanceBetween(
      latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 },
      latestPivot ?? { x: 0, y: 0, z: 0 }
    );

    expect(finalDistance).toBeCloseTo(finalDesiredDistance);
    controller.deactivate(context);
  });

  it("allows the third-person camera to pitch low enough for floor collision to pull it inward", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const controllerInternals = controller as unknown as {
      pitchRadians: number;
    };
    const floorHeight = 0.2;
    let latestPivot: Vec3 | null = null;
    let latestDesiredCameraPosition: Vec3 | null = null;
    const distanceBetween = (left: Vec3, right: Vec3) =>
      Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);

    context.resolveThirdPersonCameraCollision = vi.fn(
      (pivot: Vec3, desiredCameraPosition: Vec3) => {
        latestPivot = { ...pivot };
        latestDesiredCameraPosition = { ...desiredCameraPosition };

        if (desiredCameraPosition.y >= floorHeight) {
          return { ...desiredCameraPosition };
        }

        const t =
          (pivot.y - floorHeight) /
          Math.max(pivot.y - desiredCameraPosition.y, 1e-6);

        return {
          x: pivot.x + (desiredCameraPosition.x - pivot.x) * t,
          y: floorHeight,
          z: pivot.z + (desiredCameraPosition.z - pivot.z) * t
        };
      }
    );

    controller.activate(context);
    controllerInternals.pitchRadians = -1.2;
    controller.update(0);

    expect(latestDesiredCameraPosition).not.toBeNull();
    const desiredCameraPosition =
      latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 };

    expect(desiredCameraPosition.y).toBeLessThan(floorHeight);
    expect(context.camera.position.y).toBeCloseTo(floorHeight);
    expect(
      distanceBetween(
        {
          x: context.camera.position.x,
          y: context.camera.position.y,
          z: context.camera.position.z
        },
        latestPivot ?? { x: 0, y: 0, z: 0 }
      )
    ).toBeLessThan(
      distanceBetween(
        desiredCameraPosition,
        latestPivot ?? { x: 0, y: 0, z: 0 }
      )
    );

    controller.deactivate(context);
  });

  it("uses lock-on look input as a temporary offset that returns to center", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    let axes = [0, 0, 1, 0];
    const getGamepads = vi.fn<() => Gamepad[]>(() => [
      createMockGamepad({
        axes
      })
    ]);
    const handleRuntimeTargetLookInput = vi.fn(() => ({
      activeTargetLocked: true,
      switchedTarget: false,
      switchInputHeld: false
    }));
    const targetContext = {
      ...context,
      handleRuntimeTargetLookInput
    };

    Object.defineProperty(navigator, "getGamepads", {
      configurable: true,
      value: getGamepads
    });

    controller.activate(targetContext);

    const initialCameraX = targetContext.camera.position.x;
    controller.update(0.1);
    const offsetCameraX = targetContext.camera.position.x;

    axes = [0, 0, 0, 0];
    controller.update(0.5);
    const returnedCameraX = targetContext.camera.position.x;

    expect(offsetCameraX).not.toBeCloseTo(initialCameraX);
    expect(Math.abs(returnedCameraX - initialCameraX)).toBeLessThan(
      Math.abs(offsetCameraX - initialCameraX)
    );
    expect(handleRuntimeTargetLookInput).toHaveBeenCalledWith({
      horizontal: expect.any(Number),
      vertical: 0
    });

    controller.deactivate(targetContext);
  });

  it("requests target clear without snapping back when lock-on manual look reaches its boundary", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const controllerInternals = controller as unknown as {
      cameraYawRadians: number;
      targetLookOffsetYawRadians: number;
    };
    const getGamepads = vi.fn<() => Gamepad[]>(() => [
      createMockGamepad({
        axes: [0, 0, 1, 0]
      })
    ]);
    const handleRuntimeTargetLookBoundaryReached = vi.fn(() => false);
    const targetContext = {
      ...context,
      handleRuntimeTargetLookInput: vi.fn(() => ({
        activeTargetLocked: true,
        switchedTarget: false,
        switchInputHeld: false
      })),
      handleRuntimeTargetLookBoundaryReached
    };

    Object.defineProperty(navigator, "getGamepads", {
      configurable: true,
      value: getGamepads
    });

    controller.activate(targetContext);
    controller.update(1);

    expect(handleRuntimeTargetLookBoundaryReached).toHaveBeenCalledTimes(1);
    expect(controllerInternals.cameraYawRadians).toBeLessThan(-0.7);
    expect(controllerInternals.targetLookOffsetYawRadians).toBe(0);

    controller.deactivate(targetContext);
  });

  it("uses third-person target assist to adjust vertical camera aim", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const targetContext = {
      ...context,
      resolveThirdPersonTargetAssist: () => ({
        targetPosition: {
          x: 0,
          y: 4,
          z: 5
        },
        strength: 1
      })
    };
    const cameraDirection = new Vector3();

    controller.activate(targetContext);
    targetContext.camera.getWorldDirection(cameraDirection);
    const initialForwardY = cameraDirection.y;

    controller.update(1);
    targetContext.camera.getWorldDirection(cameraDirection);

    expect(cameraDirection.y).toBeGreaterThan(initialForwardY);

    controller.deactivate(targetContext);
  });

  it("blends the previous high orbit pitch toward neutral when targeting starts", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    let targetAssistActive = false;
    const controllerInternals = controller as unknown as {
      pitchRadians: number;
    };
    const targetContext = {
      ...context,
      resolveThirdPersonTargetAssist: () =>
        targetAssistActive
          ? {
              targetPosition: {
                x: 0,
                y: 1,
                z: 5
              },
              strength: 1
            }
          : null
    };

    controller.activate(targetContext);
    const defaultCameraY = targetContext.camera.position.y;

    controllerInternals.pitchRadians = 1.2;
    controller.update(0);
    const highOrbitCameraY = targetContext.camera.position.y;

    targetAssistActive = true;
    controller.update(0.016);
    const firstTargetFrameCameraY = targetContext.camera.position.y;
    controller.update(1);

    expect(highOrbitCameraY).toBeGreaterThan(defaultCameraY + 2);
    expect(firstTargetFrameCameraY).toBeGreaterThan(defaultCameraY + 1);
    expect(firstTargetFrameCameraY).toBeLessThan(highOrbitCameraY);
    expect(controllerInternals.pitchRadians).toBeCloseTo(0.35, 2);

    controller.deactivate(targetContext);
  });

  it("pauses third-person target assist while the camera is actively moved with pointer drag", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const controllerInternals = controller as unknown as {
      cameraYawRadians: number;
      pitchRadians: number;
      targetAssistLookOffsetY: number;
      handlePointerDown(event: PointerEvent): void;
      handlePointerMove(event: PointerEvent): void;
      handlePointerUp(): void;
    };
    const targetContext = {
      ...context,
      resolveThirdPersonTargetAssist: () => ({
        targetPosition: {
          x: 0,
          y: 4,
          z: 5
        },
        strength: 1
      }),
      handleRuntimeTargetLookInput: vi.fn(() => ({
        activeTargetLocked: true,
        switchedTarget: false,
        switchInputHeld: false
      }))
    };

    controller.activate(targetContext);
    controllerInternals.cameraYawRadians = 1.1;
    controllerInternals.pitchRadians = 1.1;
    controllerInternals.targetAssistLookOffsetY = 0;

    controllerInternals.handlePointerDown({
      button: 0,
      clientX: 0,
      clientY: 0
    } as PointerEvent);
    controllerInternals.handlePointerMove({
      clientX: 30,
      clientY: 12
    } as PointerEvent);

    controller.update(0.016);

    expect(controllerInternals.cameraYawRadians).toBeCloseTo(1.1, 5);
    expect(controllerInternals.pitchRadians).toBeCloseTo(1.1, 5);
    expect(controllerInternals.targetAssistLookOffsetY).toBeCloseTo(0, 5);

    controllerInternals.handlePointerUp();
    controller.deactivate(targetContext);
  });

  it("fades vertical target assist when camera collision pushes the camera close", () => {
    const { context } = createRuntimeControllerContext();
    const controller = new ThirdPersonNavigationController();
    const targetContext = {
      ...context,
      resolveThirdPersonCameraCollision: (
        pivot: Vec3,
        desiredCameraPosition: Vec3
      ) => ({
        x: pivot.x + (desiredCameraPosition.x - pivot.x) * 0.12,
        y: pivot.y + (desiredCameraPosition.y - pivot.y) * 0.12,
        z: pivot.z + (desiredCameraPosition.z - pivot.z) * 0.12
      }),
      resolveThirdPersonTargetAssist: () => ({
        targetPosition: {
          x: 0,
          y: 5,
          z: 5
        },
        strength: 1
      })
    };
    const cameraDirection = new Vector3();

    controller.activate(targetContext);
    controller.update(1);
    targetContext.camera.getWorldDirection(cameraDirection);

    expect(cameraDirection.y).toBeLessThan(0.15);

    controller.deactivate(targetContext);
  });


  it("uses the authored movement template speed for third-person motion telemetry", () => {
    const playerStart = createPlayerStartEntity({
      id: "entity-player-start-custom-third-person-movement",
      inputBindings: {
        keyboard: {
          moveForward: "ArrowUp",
          moveBackward: "ArrowDown",
          moveLeft: "ArrowLeft",
          moveRight: "ArrowRight"
        }
      },
      movementTemplate: {
        moveSpeed: 2.25
      }
    });
    const { context } = createRuntimeControllerContext(playerStart);
    const controller = new ThirdPersonNavigationController();

    controller.activate(context);
    window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
    controller.update(1);

    const telemetry =
      context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];

    expect(
      Math.hypot(
        telemetry?.feetPosition.x ?? 0,
        telemetry?.feetPosition.z ?? 0
      )
    ).toBeCloseTo(2.25);
    expect(telemetry?.movement).toMatchObject({
      templateKind: "default",
      moveSpeed: 2.25,
      capabilities: {
        jump: true,
        sprint: true,
        crouch: true
      }
    });

    window.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
    controller.deactivate(context);
  });

  it("uses sprint input to raise gait and planar travel speed when grounded", () => {
    const playerStart = createPlayerStartEntity({
      id: "entity-player-start-third-person-sprint",
      inputBindings: {
        keyboard: {
          moveForward: "ArrowUp",
          moveBackward: "ArrowDown",
          moveLeft: "ArrowLeft",
          moveRight: "ArrowRight",
          sprint: "ShiftLeft"
        }
      }
    });
    const { context } = createRuntimeControllerContext(playerStart);
    context.probePlayerGround = vi.fn(() => ({
      grounded: true,
      distance: 0,
      normal: { x: 0, y: 1, z: 0 },
      slopeDegrees: 0
    }));
    context.resolveFirstPersonMotion = (
      feetPosition: Vec3,
      motion: Vec3,
      _shape: FirstPersonPlayerShape
    ) => ({
      feetPosition: {
        x: feetPosition.x + motion.x,
        y: feetPosition.y,
        z: feetPosition.z + motion.z
      },
      grounded: true,
      collisionCount: 1,
      groundCollisionNormal: { x: 0, y: 1, z: 0 },
      collidedAxes: {
        x: false,
        y: true,
        z: false
      }
    });
    const controller = new ThirdPersonNavigationController();

    controller.activate(context);
    window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
    window.dispatchEvent(
      new KeyboardEvent("keydown", { code: "ShiftLeft" })
    );
    controller.update(1);

    const telemetry =
      context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];

    expect(telemetry?.locomotionState.gait).toBe("sprint");
    expect(telemetry?.locomotionState.sprinting).toBe(true);
    expect(telemetry?.locomotionState.locomotionMode).toBe("grounded");
    expect(
      Math.hypot(
        telemetry?.feetPosition.x ?? 0,
        telemetry?.feetPosition.z ?? 0
      )
    ).toBeGreaterThan(7);

    window.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
    window.dispatchEvent(
      new KeyboardEvent("keyup", { code: "ShiftLeft" })
    );
    controller.deactivate(context);
  });
});
