import { PerspectiveCamera, Vector3 } from "three";
import type { CameraZoneDef } from "@psx/shared";
import { CollisionGroup, damp } from "@psx/shared";
import type { PhysicsWorld, SensorEvents } from "../engine/PhysicsWorld.js";

/**
 * Fixed camera zones — GDD §6.1.
 *
 * Each zone is a box sensor. While the player capsule is inside it, the camera
 * takes that zone's transform. Overlapping zones resolve by priority, then by
 * most-recently-entered, so authoring redundant overlap at doorways is safe.
 */

export const CAMERA_ZONE_PREFIX = "zone:";

interface ActiveZone {
  def: CameraZoneDef;
  enteredAtTick: number;
}

interface CameraPose {
  position: Vector3;
  target: Vector3;
  fov: number;
}

const DEFAULT_FOV = 55;
/** Seconds of damping used when a zone tracks the player. */
const TRACK_SMOOTHING = 0.0005;

export class CameraManager {
  readonly camera: PerspectiveCamera;

  private readonly zones = new Map<string, CameraZoneDef>();
  private readonly occupied = new Map<string, ActiveZone>();
  private activeZoneId: string | null = null;

  private readonly current: CameraPose;
  private readonly blendFrom: CameraPose;
  private blendRemaining = 0;
  private blendDuration = 0;

  private readonly lookDirection = new Vector3(0, 0, -1);
  private readonly scratchTarget = new Vector3();
  private tick = 0;

  /** Fired on every camera change so the player controller can lock its basis. */
  onCut: (() => void) | null = null;

  constructor(aspect: number) {
    this.camera = new PerspectiveCamera(DEFAULT_FOV, aspect, 0.1, 120);
    this.current = {
      position: new Vector3(0, 3, 5),
      target: new Vector3(0, 1, 0),
      fov: DEFAULT_FOV,
    };
    this.blendFrom = {
      position: this.current.position.clone(),
      target: this.current.target.clone(),
      fov: DEFAULT_FOV,
    };
  }

  /** Build sensors for a room's zones. Call `clear` first when swapping rooms. */
  loadZones(physics: PhysicsWorld, zoneDefs: CameraZoneDef[]): void {
    for (const def of zoneDefs) {
      this.zones.set(def.id, def);
      physics.createSensor({
        id: CAMERA_ZONE_PREFIX + def.id,
        position: def.trigger.center,
        halfExtents: def.trigger.halfExtents,
        group: CollisionGroup.CAMERA_ZONE,
      });
    }
  }

  clear(): void {
    this.zones.clear();
    this.occupied.clear();
    this.activeZoneId = null;
  }

  /** Feed the tick's sensor transitions. `visitorId` filters to the player. */
  handleSensorEvents(events: SensorEvents, visitorId: string): void {
    for (const event of events.begin) {
      if (event.visitorId !== visitorId) continue;
      const def = this.zoneFromSensorId(event.sensorId);
      if (def) this.occupied.set(def.id, { def, enteredAtTick: this.tick });
    }
    for (const event of events.end) {
      if (event.visitorId !== visitorId) continue;
      const def = this.zoneFromSensorId(event.sensorId);
      if (def) this.occupied.delete(def.id);
    }
  }

  private zoneFromSensorId(sensorId: string): CameraZoneDef | undefined {
    if (!sensorId.startsWith(CAMERA_ZONE_PREFIX)) return undefined;
    return this.zones.get(sensorId.slice(CAMERA_ZONE_PREFIX.length));
  }

  update(dt: number, playerPosition: Vector3): void {
    this.tick++;
    this.selectZone();

    const zone = this.activeZoneId ? this.zones.get(this.activeZoneId) : undefined;
    if (zone) this.applyZone(zone, dt, playerPosition);

    if (this.blendRemaining > 0) {
      this.blendRemaining = Math.max(0, this.blendRemaining - dt);
      const t = this.blendDuration > 0 ? 1 - this.blendRemaining / this.blendDuration : 1;
      // Smoothstep keeps blends from starting and stopping abruptly.
      const eased = t * t * (3 - 2 * t);
      this.camera.position.lerpVectors(this.blendFrom.position, this.current.position, eased);
      this.scratchTarget.lerpVectors(this.blendFrom.target, this.current.target, eased);
      this.camera.fov = this.blendFrom.fov + (this.current.fov - this.blendFrom.fov) * eased;
      this.camera.updateProjectionMatrix();
    } else {
      this.camera.position.copy(this.current.position);
      this.scratchTarget.copy(this.current.target);
      if (this.camera.fov !== this.current.fov) {
        this.camera.fov = this.current.fov;
        this.camera.updateProjectionMatrix();
      }
    }

    this.camera.lookAt(this.scratchTarget);
    this.camera.getWorldDirection(this.lookDirection);
  }

  /** Highest priority wins; ties break toward the zone entered most recently. */
  private selectZone(): void {
    let best: ActiveZone | null = null;
    for (const candidate of this.occupied.values()) {
      if (best === null) {
        best = candidate;
        continue;
      }
      const candidatePriority = candidate.def.priority ?? 0;
      const bestPriority = best.def.priority ?? 0;
      if (
        candidatePriority > bestPriority ||
        (candidatePriority === bestPriority && candidate.enteredAtTick > best.enteredAtTick)
      ) {
        best = candidate;
      }
    }

    const nextId = best?.def.id ?? null;
    if (nextId === this.activeZoneId) return;

    // Leaving every zone keeps the last camera rather than snapping to origin —
    // a gap in zone coverage should look like an authoring bug, not a crash.
    if (nextId === null) return;

    this.beginTransition(this.zones.get(nextId)!);
    this.activeZoneId = nextId;
  }

  private beginTransition(zone: CameraZoneDef): void {
    const blend = zone.blend ?? { mode: "cut" as const };
    if (blend.mode === "blend" && blend.duration > 0) {
      this.blendFrom.position.copy(this.camera.position);
      this.blendFrom.target.copy(this.scratchTarget);
      this.blendFrom.fov = this.camera.fov;
      this.blendDuration = blend.duration;
      this.blendRemaining = blend.duration;
    } else {
      this.blendRemaining = 0;
      this.blendDuration = 0;
    }
    this.onCut?.();
  }

  private applyZone(zone: CameraZoneDef, dt: number, playerPosition: Vector3): void {
    const { camera: settings } = zone;
    this.current.fov = settings.fov ?? DEFAULT_FOV;
    this.current.position.set(settings.position.x, settings.position.y, settings.position.z);

    if (!settings.trackPlayer) {
      this.current.target.set(settings.lookAt.x, settings.lookAt.y, settings.lookAt.z);
      return;
    }

    // Track the player, but never let the aim drift further than trackRadius
    // from the authored look-at — that anchor is what keeps the shot composed.
    this.scratchTarget.set(playerPosition.x, playerPosition.y + 0.9, playerPosition.z);
    const anchor = settings.lookAt;
    const radius = settings.trackRadius ?? 2.5;
    const offsetX = this.scratchTarget.x - anchor.x;
    const offsetY = this.scratchTarget.y - anchor.y;
    const offsetZ = this.scratchTarget.z - anchor.z;
    const distance = Math.hypot(offsetX, offsetY, offsetZ);
    const scale = distance > radius ? radius / distance : 1;

    this.current.target.set(
      damp(this.current.target.x, anchor.x + offsetX * scale, TRACK_SMOOTHING, dt),
      damp(this.current.target.y, anchor.y + offsetY * scale, TRACK_SMOOTHING, dt),
      damp(this.current.target.z, anchor.z + offsetZ * scale, TRACK_SMOOTHING, dt),
    );
  }

  /** World-space direction the camera faces — the basis for camera-relative movement. */
  getLookDirection(): Vector3 {
    return this.lookDirection;
  }

  get activeZone(): string | null {
    return this.activeZoneId;
  }

  setAspect(aspect: number): void {
    this.camera.aspect = aspect;
    this.camera.updateProjectionMatrix();
  }
}
