import * as THREE from "three";
import RAPIER from "@dimforge/rapier3d-compat";
import { buildKhimkiWorld, ORIGIN } from "./world";
import type { GameCallbacks } from "./types";

type InputState = {
  keys: Set<string>;
  jumpQueued: boolean;
};

function makePlayerModel() {
  const root = new THREE.Group();
  const hoodie = new THREE.MeshStandardMaterial({ color: "#e84932", roughness: 0.72 });
  const dark = new THREE.MeshStandardMaterial({ color: "#171b21", roughness: 0.82 });
  const skin = new THREE.MeshStandardMaterial({ color: "#d7a37c", roughness: 0.86 });
  const shoes = new THREE.MeshStandardMaterial({ color: "#d7d9da", roughness: 0.62 });

  const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 0.52, 5, 10), hoodie);
  torso.position.y = 0.22;
  torso.scale.z = 0.72;
  root.add(torso);

  const head = new THREE.Mesh(new THREE.SphereGeometry(0.245, 16, 12), skin);
  head.position.y = 0.84;
  root.add(head);

  const hood = new THREE.Mesh(new THREE.TorusGeometry(0.25, 0.075, 7, 14, Math.PI * 1.45), hoodie);
  hood.position.set(0, 0.66, 0.04);
  hood.rotation.x = Math.PI / 2;
  hood.rotation.z = Math.PI * 0.77;
  root.add(hood);

  const makeLimb = (material: THREE.Material, x: number, y: number, isArm: boolean) => {
    const pivot = new THREE.Group();
    pivot.position.set(x, y, 0);
    const limb = new THREE.Mesh(new THREE.CapsuleGeometry(isArm ? 0.095 : 0.115, isArm ? 0.46 : 0.55, 4, 8), material);
    limb.position.y = isArm ? -0.29 : -0.34;
    pivot.add(limb);
    root.add(pivot);
    return pivot;
  };
  const leftArm = makeLimb(hoodie, -0.36, 0.45, true);
  const rightArm = makeLimb(hoodie, 0.36, 0.45, true);
  const leftLeg = makeLimb(dark, -0.16, -0.24, false);
  const rightLeg = makeLimb(dark, 0.16, -0.24, false);

  for (const x of [-0.16, 0.16]) {
    const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.13, 0.38), shoes);
    shoe.position.set(x, -0.91, -0.08);
    root.add(shoe);
  }
  root.traverse((child) => {
    if (child instanceof THREE.Mesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });
  root.userData.limbs = { leftArm, rightArm, leftLeg, rightLeg };
  return root;
}

export class KhimkiEngine {
  private readonly canvas: HTMLCanvasElement;
  private readonly callbacks: GameCallbacks;
  private readonly scene = new THREE.Scene();
  private readonly camera = new THREE.PerspectiveCamera(62, 1, 0.08, 900);
  private readonly clock = new THREE.Clock();
  private readonly input: InputState = { keys: new Set(), jumpQueued: false };
  private readonly playerModel = makePlayerModel();
  private readonly raycaster = new THREE.Raycaster();
  private renderer!: THREE.WebGLRenderer;
  private physics!: RAPIER.World;
  private playerBody!: RAPIER.RigidBody;
  private cameraOccluders: THREE.Object3D[] = [];
  private traffic: THREE.Group[] = [];
  private animationFrame = 0;
  private running = false;
  private started = false;
  private pointerLocked = false;
  private yaw = 0;
  private pitch = 0.16;
  private inverted = true;
  private walkPhase = 0;
  private fpsFrames = 0;
  private fpsElapsed = 0;
  private currentFps = 60;
  private hudElapsed = 0;
  private qualityElapsed = 0;
  private pixelRatio = 1;
  private readonly resizeObserver: ResizeObserver;

  constructor(canvas: HTMLCanvasElement, callbacks: GameCallbacks) {
    this.canvas = canvas;
    this.callbacks = callbacks;
    this.resizeObserver = new ResizeObserver(this.resize);
  }

  async init() {
    try {
      await RAPIER.init({});
      this.physics = new RAPIER.World({ x: 0, y: -18, z: 0 });
      this.setupRenderer();
      this.setupScene();
      const world = buildKhimkiWorld(this.scene, this.physics, RAPIER);
      this.cameraOccluders = world.cameraOccluders;
      this.traffic = world.traffic;
      this.createPlayer();
      this.bindEvents();
      this.resizeObserver.observe(this.canvas.parentElement ?? this.canvas);
      this.resize();
      this.running = true;
      this.clock.start();
      this.animate();
      this.callbacks.onReady();
    } catch (error) {
      const message = error instanceof Error ? error.message : "Не удалось запустить 3D-движок";
      this.callbacks.onError(message);
    }
  }

  start() {
    this.started = true;
    const result = this.canvas.requestPointerLock();
    if (result && typeof result.catch === "function") result.catch(() => undefined);
  }

  toggleInversion() {
    this.inverted = !this.inverted;
    return this.inverted;
  }

  private setupRenderer() {
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      antialias: true,
      alpha: false,
      powerPreference: "high-performance",
      stencil: false,
    });
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.08;
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.setClearColor(0x91aec2, 1);
    this.pixelRatio = Math.min(window.devicePixelRatio, 1.45);
    this.renderer.setPixelRatio(this.pixelRatio);
  }

  private setupScene() {
    this.scene.background = new THREE.Color("#91aec2");
    this.scene.fog = new THREE.FogExp2("#9db2bd", 0.00275);
    this.scene.add(this.playerModel);

    const hemisphere = new THREE.HemisphereLight(0xc4dded, 0x44513c, 2.45);
    this.scene.add(hemisphere);

    const sun = new THREE.DirectionalLight(0xffe7c7, 3.4);
    sun.position.set(-76, 135, 88);
    sun.castShadow = true;
    sun.shadow.mapSize.set(2048, 2048);
    sun.shadow.camera.left = -115;
    sun.shadow.camera.right = 115;
    sun.shadow.camera.top = 115;
    sun.shadow.camera.bottom = -115;
    sun.shadow.camera.near = 10;
    sun.shadow.camera.far = 310;
    sun.shadow.bias = -0.0003;
    this.scene.add(sun);

    const sunDisk = new THREE.Mesh(
      new THREE.SphereGeometry(5.5, 20, 12),
      new THREE.MeshBasicMaterial({ color: "#fff2cf", fog: false }),
    );
    sunDisk.position.set(-220, 270, -360);
    this.scene.add(sunDisk);
  }

  private createPlayer() {
    this.playerBody = this.physics.createRigidBody(
      RAPIER.RigidBodyDesc.dynamic()
        .setTranslation(0, 0.92, 48)
        .setCanSleep(false)
        .setCcdEnabled(true)
        .lockRotations()
        .setLinearDamping(6.4),
    );
    this.physics.createCollider(
      RAPIER.ColliderDesc.capsule(0.55, 0.34).setFriction(0.05).setRestitution(0),
      this.playerBody,
    );
    this.playerModel.position.set(0, 0.92, 48);
    this.camera.position.set(0, 4.6, 55);
    this.camera.lookAt(0, 1.4, 43);
  }

  private bindEvents() {
    window.addEventListener("keydown", this.onKeyDown);
    window.addEventListener("keyup", this.onKeyUp);
    window.addEventListener("blur", this.onBlur);
    document.addEventListener("pointerlockchange", this.onPointerLockChange);
    document.addEventListener("mousemove", this.onMouseMove);
    this.canvas.addEventListener("contextmenu", this.preventContextMenu);
  }

  private onKeyDown = (event: KeyboardEvent) => {
    this.input.keys.add(event.code);
    if (event.code === "Space" && !event.repeat) {
      this.input.jumpQueued = true;
      event.preventDefault();
    }
    if (event.code === "KeyI" && !event.repeat) this.toggleInversion();
  };

  private onKeyUp = (event: KeyboardEvent) => {
    this.input.keys.delete(event.code);
  };

  private onBlur = () => this.input.keys.clear();

  private onPointerLockChange = () => {
    this.pointerLocked = document.pointerLockElement === this.canvas;
    this.callbacks.onPointerLock(this.pointerLocked);
    if (!this.pointerLocked) this.input.keys.clear();
  };

  private onMouseMove = (event: MouseEvent) => {
    if (!this.pointerLocked) return;
    const direction = this.inverted ? 1 : -1;
    this.yaw += event.movementX * 0.00205 * direction;
    this.pitch = THREE.MathUtils.clamp(this.pitch + event.movementY * 0.00165 * direction, -0.24, 0.72);
  };

  private preventContextMenu = (event: MouseEvent) => event.preventDefault();

  private resize = () => {
    const width = Math.max(1, this.canvas.clientWidth);
    const height = Math.max(1, this.canvas.clientHeight);
    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    if (this.renderer) this.renderer.setSize(width, height, false);
  };

  private updatePlayer(delta: number) {
    const currentVelocity = this.playerBody.linvel();
    const forwardInput = Number(this.input.keys.has("KeyW") || this.input.keys.has("ArrowUp"))
      - Number(this.input.keys.has("KeyS") || this.input.keys.has("ArrowDown"));
    const sideInput = Number(this.input.keys.has("KeyD") || this.input.keys.has("ArrowRight"))
      - Number(this.input.keys.has("KeyA") || this.input.keys.has("ArrowLeft"));
    const isRunning = this.input.keys.has("ShiftLeft") || this.input.keys.has("ShiftRight");
    const speed = isRunning ? 8.2 : 4.6;
    const forward = new THREE.Vector3(-Math.sin(this.yaw), 0, -Math.cos(this.yaw));
    const right = new THREE.Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw));
    const movement = forward.multiplyScalar(forwardInput).add(right.multiplyScalar(sideInput));
    if (movement.lengthSq() > 1) movement.normalize();
    const canMove = this.pointerLocked && this.started;
    const targetX = canMove ? movement.x * speed : 0;
    const targetZ = canMove ? movement.z * speed : 0;
    const blend = 1 - Math.exp(-14 * delta);
    const velocityX = THREE.MathUtils.lerp(currentVelocity.x, targetX, blend);
    const velocityZ = THREE.MathUtils.lerp(currentVelocity.z, targetZ, blend);
    let velocityY = currentVelocity.y;
    const translation = this.playerBody.translation();
    const grounded = translation.y < 0.98 && Math.abs(currentVelocity.y) < 0.65;
    if (this.input.jumpQueued && grounded && canMove) velocityY = 6.45;
    this.input.jumpQueued = false;
    this.playerBody.setLinvel({ x: velocityX, y: velocityY, z: velocityZ }, true);

    if (translation.y < -8 || Math.abs(translation.x) > 295 || Math.abs(translation.z) > 295) {
      this.playerBody.setTranslation({ x: 0, y: 1.2, z: 48 }, true);
      this.playerBody.setLinvel({ x: 0, y: 0, z: 0 }, true);
    }

    const position = this.playerBody.translation();
    this.playerModel.position.set(position.x, position.y, position.z);
    const horizontalSpeed = Math.hypot(velocityX, velocityZ);
    if (horizontalSpeed > 0.25) {
      const targetRotation = Math.atan2(velocityX, velocityZ);
      let difference = targetRotation - this.playerModel.rotation.y;
      difference = Math.atan2(Math.sin(difference), Math.cos(difference));
      this.playerModel.rotation.y += difference * (1 - Math.exp(-14 * delta));
      this.walkPhase += delta * horizontalSpeed * 2.05;
    }
    const stride = Math.sin(this.walkPhase) * Math.min(0.72, horizontalSpeed * 0.1);
    const limbs = this.playerModel.userData.limbs as Record<string, THREE.Group>;
    limbs.leftArm.rotation.x = stride;
    limbs.rightArm.rotation.x = -stride;
    limbs.leftLeg.rotation.x = -stride;
    limbs.rightLeg.rotation.x = stride;
    if (horizontalSpeed < 0.25) {
      for (const limb of Object.values(limbs)) limb.rotation.x *= 0.84;
    }
  }

  private updateCamera(delta: number) {
    const position = this.playerModel.position;
    const forward = new THREE.Vector3(-Math.sin(this.yaw), 0, -Math.cos(this.yaw));
    const target = position.clone().add(new THREE.Vector3(0, 0.75, 0)).addScaledVector(forward, 1.15);
    const distance = 6.3;
    const desired = position.clone().addScaledVector(forward, -distance);
    desired.y += 3.55 + this.pitch * 4.25;

    const direction = desired.clone().sub(target);
    const maxDistance = direction.length();
    direction.normalize();
    this.raycaster.set(target, direction);
    this.raycaster.far = maxDistance;
    const hit = this.raycaster.intersectObjects(this.cameraOccluders, false)[0];
    if (hit && hit.distance < maxDistance) desired.copy(target).addScaledVector(direction, Math.max(0.8, hit.distance - 0.42));

    const positionBlend = 1 - Math.exp(-10.5 * delta);
    this.camera.position.lerp(desired, positionBlend);
    const lookTarget = target.clone().add(new THREE.Vector3(0, this.pitch * -0.35, 0));
    this.camera.lookAt(lookTarget);
  }

  private updateTraffic(elapsed: number) {
    for (const car of this.traffic) {
      const phase = car.userData.phase as number;
      const direction = car.userData.direction as number;
      const distance = ((elapsed * 9.4 + phase) % 360) - 180;
      car.position.z = distance * direction;
    }
  }

  private emitHud(delta: number) {
    this.fpsFrames += 1;
    this.fpsElapsed += delta;
    this.hudElapsed += delta;
    this.qualityElapsed += delta;
    if (this.fpsElapsed >= 0.5) {
      this.currentFps = Math.round(this.fpsFrames / this.fpsElapsed);
      this.fpsFrames = 0;
      this.fpsElapsed = 0;
    }
    if (this.qualityElapsed > 2.5) {
      if (this.currentFps < 43 && this.pixelRatio > 0.82) {
        this.pixelRatio = Math.max(0.82, this.pixelRatio - 0.12);
        this.renderer.setPixelRatio(this.pixelRatio);
        this.resize();
      } else if (this.currentFps > 57 && this.pixelRatio < Math.min(window.devicePixelRatio, 1.45)) {
        this.pixelRatio = Math.min(Math.min(window.devicePixelRatio, 1.45), this.pixelRatio + 0.08);
        this.renderer.setPixelRatio(this.pixelRatio);
        this.resize();
      }
      this.qualityElapsed = 0;
    }
    if (this.hudElapsed < 0.18) return;
    this.hudElapsed = 0;
    const position = this.playerBody.translation();
    const metersPerLon = 111320 * Math.cos((ORIGIN.latitude * Math.PI) / 180);
    const velocity = this.playerBody.linvel();
    this.callbacks.onHud({
      fps: this.currentFps,
      latitude: ORIGIN.latitude - position.z / 111320,
      longitude: ORIGIN.longitude + position.x / metersPerLon,
      speed: Math.hypot(velocity.x, velocity.z),
      renderer: "WebGL2 · WASM physics",
      quality: this.pixelRatio >= 1.28 ? "Высокое" : this.pixelRatio >= 1 ? "Сбалансированное" : "Производительность",
    });
  }

  private animate = () => {
    if (!this.running) return;
    const delta = Math.min(this.clock.getDelta(), 1 / 20);
    this.updatePlayer(delta);
    this.physics.timestep = delta;
    this.physics.step();
    this.updateCamera(delta);
    this.updateTraffic(this.clock.elapsedTime);
    this.emitHud(delta);
    this.renderer.render(this.scene, this.camera);
    this.animationFrame = requestAnimationFrame(this.animate);
  };

  dispose() {
    this.running = false;
    cancelAnimationFrame(this.animationFrame);
    this.resizeObserver.disconnect();
    window.removeEventListener("keydown", this.onKeyDown);
    window.removeEventListener("keyup", this.onKeyUp);
    window.removeEventListener("blur", this.onBlur);
    document.removeEventListener("pointerlockchange", this.onPointerLockChange);
    document.removeEventListener("mousemove", this.onMouseMove);
    this.canvas.removeEventListener("contextmenu", this.preventContextMenu);
    if (document.pointerLockElement === this.canvas) document.exitPointerLock();
    this.scene.traverse((object) => {
      if (!(object instanceof THREE.Mesh)) return;
      object.geometry.dispose();
      const materials = Array.isArray(object.material) ? object.material : [object.material];
      for (const material of materials) {
        for (const value of Object.values(material)) if (value instanceof THREE.Texture) value.dispose();
        material.dispose();
      }
    });
    this.renderer?.dispose();
  }
}
