import * as THREE from "three";
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
import { TGALoader } from "three/addons/loaders/TGALoader.js";
/* msgpack for server messages */
import { encode, decode } from "https://esm.sh/@msgpack/msgpack@3.1.2?bundle";

(() => {
  if (window.__threePS1Campfire) return;
  window.__threePS1Campfire = true;

  const MODEL_URL = "https://world.seppjm.com/3js/models/scene.fbx";
  const NORMALIZE_SCALE = false;
  const USE_FOG = true;

  const PS1_SCALE = 3;
  const COLOR_LEVELS = 32;
  const USE_DITHER = true;
  const VERTEX_SNAP_PIXELS = 0.75;

  const FIRE_POS = new THREE.Vector3(
    2885.090654499771,
    5.937671631541306,
    -2843.489246932181
  );
  const CAM_POS = new THREE.Vector3(
    3280.98691276581,
    386.84586301208896,
    -2012.4527013816644
  );
  const CAM_ROT_DEG = { yaw: 395.51276597880525, pitch: 2.7272791048226543 };
  const CAM_FOV = 90;

  const FIRE_SPRITE_SIZE = 150.0;
  const FIRE_HEIGHT_OFFSET = 0.25;
  const FIRE_MAIN_INTENSITY = 2.2;
  const FIRE_SUB_INTENSITY = 1.0;
  const FIRE_MAIN_DISTANCE = 12000;
  const FIRE_SUB_DISTANCE = 6000;
  const FIRE_DECAY = 1.2;

  /* --- WS spectator (minimal) --- */
  const WS_URL = "wss://ws.world.seppjm.com/ws"; 
  const REMOTE_SPRITE_HEIGHT = 120;
  const REMOTE_SPRITE_ASPECT = 0.55;
  const players = new Map(); // id -> {group, target, rotY}
  let myID = null;
  let ws = null;

  function makeProceduralBillboard() {
    const W = 64, H = 128;
    const c = document.createElement("canvas");
    c.width = W; c.height = H;
    const g = c.getContext("2d");
    g.fillStyle = "rgba(0,0,0,0)";
    g.fillRect(0,0,W,H);
    g.fillStyle = "#d6e0ff";
    g.fillRect(18,20,28,78);
    g.beginPath(); g.arc(32,30,18,0,Math.PI*2); g.fill();
    g.fillRect(18,98,10,24); g.fillRect(36,98,10,24);
    g.strokeStyle = "#131a2f"; g.lineWidth = 3;
    g.strokeRect(18,20,28,78);
    g.beginPath(); g.arc(32,30,18,0,Math.PI*2); g.stroke();
    const tex = new THREE.CanvasTexture(c);
    tex.generateMipmaps = false;
    tex.minFilter = THREE.NearestFilter;
    tex.magFilter = THREE.NearestFilter;
    return tex;
  }
  function makeBillboardSprite() {
    const tex = makeProceduralBillboard();
    const spr = new THREE.Sprite(new THREE.SpriteMaterial({
      map: tex, transparent: true, depthWrite: true, depthTest: true
    }));
    spr.center.set(0.5, 0.0); // bottom at ground-ish
    spr.position.y = 0;
    spr.scale.set(REMOTE_SPRITE_HEIGHT * REMOTE_SPRITE_ASPECT, REMOTE_SPRITE_HEIGHT, 1);
    return spr;
  }
  function spawnOrUpdate(p) {
    if (myID && p.id === myID) return;
    let entry = players.get(p.id);
    if (!entry) {
      const group = new THREE.Group();
      if (p.pos?.length === 3) group.position.set(p.pos[0], p.pos[1], p.pos[2]);
      group.rotation.y = p.rotY || 0;
      group.add(makeBillboardSprite());
      scene.add(group);
      entry = { group, target: new THREE.Vector3().copy(group.position), rotY: group.rotation.y };
      players.set(p.id, entry);
    } else {
      if (p.pos?.length === 3) entry.target.set(p.pos[0], p.pos[1], p.pos[2]);
      entry.rotY = p.rotY || 0;
    }
  }
  function removePlayer(id) {
    const e = players.get(id);
    if (!e) return;
    scene.remove(e.group);
    players.delete(id);
  }
  function connectSpectator() {
    try {
      ws = new WebSocket(WS_URL);
      ws.binaryType = "arraybuffer";
      ws.addEventListener("open", () => {
        const name = `Viewer${Math.floor(Math.random()*9000)+1000}`;
        ws.send(encode({ type: "join", name, role: "spectator" }));
      });
      ws.addEventListener("message", (ev) => {
        const msg = decode(ev.data);
        switch (msg.type) {
          case "welcome":
            myID = msg.id || null;
            (msg.players || []).forEach(spawnOrUpdate);
            break;
          case "player_join":
            if (msg.player) spawnOrUpdate(msg.player);
            break;
          case "player_leave":
            if (msg.id) removePlayer(msg.id);
            break;
          case "state":
            if (Array.isArray(msg.updates)) msg.updates.forEach(spawnOrUpdate);
            else if (msg.id && msg.pos) spawnOrUpdate(msg);
            break;
        }
      });
    } catch {}
  }
  /* --- end WS spectator --- */

  const footer = document.querySelector("footer");
  const canvas = document.createElement("canvas");
  canvas.id = "three-bg";
  Object.assign(canvas.style, {
    position: "fixed",
    inset: "0",
    width: "100vw",
    height: "100vh",
    display: "block",
    zIndex: "0",
    pointerEvents: "none",
    clipPath: "inset(0 0 var(--bg-clip-bottom, 0px) 0)",
    WebkitClipPath: "inset(0 0 var(--bg-clip-bottom, 0px) 0)",
    opacity: "0",
    transition: "opacity 1400ms ease",
    imageRendering: "pixelated",
  });
  document.body.prepend(canvas);

  function updateFooterClip() {
    const fadeLen = 140;
    const rect = footer?.getBoundingClientRect();
    if (!rect) {
      canvas.style.setProperty("--bg-clip-bottom", "0px");
      canvas.style.maskImage = "none";
      canvas.style.WebkitMaskImage = "none";
      return;
    }
    const overlap = Math.max(0, innerHeight - rect.top);
    const clip = Math.max(0, overlap - fadeLen);
    canvas.style.setProperty("--bg-clip-bottom", `${clip}px`);
    if (overlap > 0) {
      const grad = `linear-gradient(to bottom, black 0%, black calc(100% - ${fadeLen}px), transparent 100%)`;
      canvas.style.maskImage = grad;
      canvas.style.WebkitMaskImage = grad;
    } else {
      canvas.style.maskImage = "none";
      canvas.style.WebkitMaskImage = "none";
    }
  }
  addEventListener("scroll", updateFooterClip, { passive: true });
  addEventListener("resize", updateFooterClip);
  new ResizeObserver(updateFooterClip).observe(document.body);
  if (footer) new ResizeObserver(updateFooterClip).observe(footer);
  updateFooterClip();

  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: false,
    alpha: true,
    powerPreference: "high-performance",
  });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.NoToneMapping;
  renderer.shadowMap.enabled = false;
  renderer.setPixelRatio(1);
  renderer.setClearColor(0x000000, 0);

  let lowW = 0,
    lowH = 0,
    rt = null;

  const postScene = new THREE.Scene();
  const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const postMat = new THREE.ShaderMaterial({
    uniforms: {
      tDiffuse: { value: null },
      uLowRes: { value: new THREE.Vector2(1, 1) },
      uLevels: { value: COLOR_LEVELS },
      uDither: { value: USE_DITHER ? 1 : 0 },
      uTime: { value: 0.0 },
    },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = (position.xy + 1.0) * 0.5;
        gl_Position = vec4(position.xy, 0.0, 1.0);
      }
    `,
    fragmentShader: `
      precision mediump float;
      uniform sampler2D tDiffuse;
      uniform vec2  uLowRes;
      uniform int   uLevels;
      uniform int   uDither;
      uniform float uTime;
      varying vec2  vUv;

      float hash(vec2 p) {
        p = fract(p * vec2(123.34, 345.45));
        p += dot(p, p + 34.345);
        return fract(p.x * p.y);
      }
      vec3 quantize(vec3 c, vec2 pix) {
        float L = float(uLevels);
        float t = (uDither == 1) ? (hash(pix + uTime) - 0.5) : 0.0;
        vec3 q = floor(clamp(c + t / L, 0.0, 1.0) * (L - 1.0) + 0.5) / (L - 1.0);
        return q;
      }
      void main() {
        vec2 p  = vUv * uLowRes;
        vec2 uv = (floor(p) + 0.5) / uLowRes;
        vec3 col = texture2D(tDiffuse, uv).rgb;
        col = quantize(col, p);
        gl_FragColor = vec4(col, 1.0);
      }
    `,
    depthTest: false,
    depthWrite: false,
  });
  postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), postMat));

  function resizeRenderer() {
    const w = innerWidth,
      h = innerHeight;
    lowW = Math.max(1, Math.floor(w / PS1_SCALE));
    lowH = Math.max(1, Math.floor(h / PS1_SCALE));
    renderer.setSize(w, h, false);
    if (rt) rt.dispose();
    rt = new THREE.WebGLRenderTarget(lowW, lowH, {
      minFilter: THREE.NearestFilter,
      magFilter: THREE.NearestFilter,
      depthBuffer: true,
      stencilBuffer: false,
      type: THREE.UnsignedByteType,
      samples: 0,
    });
    postMat.uniforms.uLowRes.value.set(lowW, lowH);
  }
  resizeRenderer();

  const scene = new THREE.Scene();
  scene.environment = null;
  scene.fog = USE_FOG ? new THREE.FogExp2(0x0b1020, 0.02) : null;

  const camera = new THREE.PerspectiveCamera(
    CAM_FOV,
    innerWidth / innerHeight,
    0.005,
    5000
  );
  camera.position.copy(CAM_POS);

  const BASE_YAW = THREE.MathUtils.degToRad(
    ((CAM_ROT_DEG.yaw % 360) + 360) % 360
  );
  const BASE_PITCH = THREE.MathUtils.degToRad(CAM_ROT_DEG.pitch);
  camera.rotation.set(BASE_PITCH, BASE_YAW, 0, "YXZ");
  camera.updateProjectionMatrix();

  const MAX_YAW_DELTA = THREE.MathUtils.degToRad(6);
  const MAX_PITCH_DELTA = THREE.MathUtils.degToRad(4);
  let aimYaw = BASE_YAW,
    aimPitch = BASE_PITCH;
  let curYaw = BASE_YAW,
    curPitch = BASE_PITCH;

  addEventListener(
    "pointermove",
    (e) => {
      const nx = (e.clientX / innerWidth) * 2 - 1;
      const ny = (e.clientY / innerHeight) * 2 - 1;
      aimYaw = BASE_YAW - nx * MAX_YAW_DELTA;
      aimPitch = BASE_PITCH + ny * MAX_PITCH_DELTA;
    },
    { passive: true }
  );

  function updateCameraHover(dt) {
    const smooth = 1.0 - Math.pow(0.2, dt * 60);
    curYaw += (aimYaw - curYaw) * smooth;
    curPitch += (aimPitch - curPitch) * smooth;
    camera.rotation.set(curPitch, curYaw, 0, "YXZ");
  }

  scene.add(new THREE.HemisphereLight(0xb0c8ff, 0x0b1020, 0.28));
  const sun = new THREE.DirectionalLight(0xffffff, 0.6);
  sun.position.set(3, 5, 4);
  scene.add(sun);

  const basePath = MODEL_URL.slice(0, MODEL_URL.lastIndexOf("/") + 1) || "/";
  const manager = new THREE.LoadingManager();
  manager.addHandler(/\.tga$/i, new TGALoader(manager));
  const loader = new FBXLoader(manager);
  loader.setResourcePath(basePath);

  let model,
    mixer,
    sceneRadius = 100;
  const patchedMaterials = new Set();
  const perFrame = [];

  function ps1ifyMaterials(root) {
    root.traverse((n) => {
      if (!n.isMesh) return;
      const src = n.material;
      const mats = (Array.isArray(src) ? src : [src]).filter(Boolean);

      const newMats = mats.map((m) => {
        const p = {
          color:
            m.color && m.color.isColor
              ? m.color.clone()
              : new THREE.Color(0xffffff),
          map: m.map || null,
          transparent: false,
          alphaTest:
            (m.alphaTest ?? 0.0) > 0
              ? m.alphaTest
              : m.map?.format === THREE.RGBAFormat
              ? 0.5
              : 0.0,
          side: THREE.DoubleSide,
        };
        const lm = new THREE.MeshLambertMaterial(p);

        ["map", "emissiveMap", "aoMap", "specularMap"].forEach((k) => {
          const tex = lm[k];
          if (tex) {
            tex.generateMipmaps = false;
            tex.minFilter = THREE.NearestFilter;
            tex.magFilter = THREE.NearestFilter;
            tex.anisotropy = 0;
            tex.needsUpdate = true;
          }
        });

        lm.depthWrite = true;
        lm.depthTest = true;
        lm.dithering = false;
        lm.polygonOffset = true;
        lm.polygonOffsetFactor = -0.5;
        lm.polygonOffsetUnits = 1.0;

        lm.onBeforeCompile = (shader) => {
          shader.uniforms.uResolution = {
            value: new THREE.Vector2(innerWidth, innerHeight),
          };
          shader.uniforms.uSnapPixels = { value: VERTEX_SNAP_PIXELS };
          shader.vertexShader = shader.vertexShader.replace(
            /void\s+main\s*\(\)\s*\{/,
            "uniform vec2 uResolution;\nuniform float uSnapPixels;\nvoid main(){"
          );
          if (VERTEX_SNAP_PIXELS > 0) {
            shader.vertexShader = shader.vertexShader.replace(
              "#include <project_vertex>",
              `
              #include <project_vertex>
              vec2 ndc = gl_Position.xy / gl_Position.w;
              vec2 pix = (ndc * 0.5 + 0.5) * uResolution;
              pix = floor(pix / uSnapPixels) * uSnapPixels;
              vec2 ndc2 = (pix / uResolution) * 2.0 - 1.0;
              gl_Position.xy = ndc2 * gl_Position.w;
              `
            );
          }
          lm.userData._ps1Shader = shader;
          patchedMaterials.add(lm);
        };

        return lm;
      });

      n.material = Array.isArray(src) ? newMats : newMats[0];
    });
  }

  function autoFogFor(target) {
    if (!USE_FOG) return;
    const box = new THREE.Box3().setFromObject(target);
    const size = box.getSize(new THREE.Vector3());
    sceneRadius = Math.max(size.x, size.y, size.z) * 0.5 || 100;
    const k = 0.03;
    const density = Math.min(0.06, Math.max(0.000005, k / (sceneRadius || 1)));
    scene.fog = new THREE.FogExp2(0x0b1020, density);
  }

  loader.load(
    MODEL_URL,
    (fbx) => {
      model = fbx;
      if (NORMALIZE_SCALE) {
        const box = new THREE.Box3().setFromObject(model);
        const size = box.getSize(new THREE.Vector3());
        const maxDim = Math.max(size.x, size.y, size.z) || 1;
        const scale = 20.0 / maxDim;
        model.scale.setScalar(scale);
        const center = box.getCenter(new THREE.Vector3()).multiplyScalar(scale);
        model.position.sub(center);
      }
      ps1ifyMaterials(model);
      scene.add(model);

      if (fbx.animations?.length) {
        mixer = new THREE.AnimationMixer(model);
        mixer.clipAction(fbx.animations[0]).play();
      }

      autoFogFor(model);
      addCampfire(FIRE_POS);
    },
    undefined,
    () => {
      addCampfire(FIRE_POS);
    }
  );

  let fireGroup = null,
    fireLight = null,
    fireLight2 = null,
    fireSprite = null;

  function makeFlameTexture(size = 128) {
    const c = document.createElement("canvas");
    c.width = c.height = size;
    const g = c.getContext("2d");
    const grd = g.createRadialGradient(
      size * 0.5,
      size * 0.6,
      size * 0.05,
      size * 0.5,
      size * 0.6,
      size * 0.5
    );
    grd.addColorStop(0.0, "rgba(255,255,255,1)");
    grd.addColorStop(0.25, "rgba(255,200,80,0.95)");
    grd.addColorStop(0.55, "rgba(255,120,30,0.65)");
    grd.addColorStop(0.85, "rgba(200,40,10,0.25)");
    grd.addColorStop(1.0, "rgba(0,0,0,0)");
    g.fillStyle = grd;
    g.fillRect(0, 0, size, size);
    const tex = new THREE.CanvasTexture(c);
    tex.generateMipmaps = false;
    tex.minFilter = THREE.NearestFilter;
    tex.magFilter = THREE.NearestFilter;
    tex.anisotropy = 0;
    return tex;
  }

  function addCampfire(pos) {
    fireGroup = new THREE.Group();
    fireGroup.position.copy(pos);
    scene.add(fireGroup);

    fireLight = new THREE.PointLight(
      0xff7a2a,
      FIRE_MAIN_INTENSITY,
      FIRE_MAIN_DISTANCE,
      FIRE_DECAY
    );
    fireLight.position.set(0, FIRE_HEIGHT_OFFSET + 0.6, 0);
    fireGroup.add(fireLight);

    fireLight2 = new THREE.PointLight(
      0xff3300,
      FIRE_SUB_INTENSITY,
      FIRE_SUB_DISTANCE,
      FIRE_DECAY
    );
    fireLight2.position.set(0.35, FIRE_HEIGHT_OFFSET + 0.4, -0.2);
    fireGroup.add(fireLight2);

    const flameTex = makeFlameTexture(128);
    const mat = new THREE.SpriteMaterial({
      map: flameTex,
      color: 0xffffff,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      depthTest: true,
    });
    fireSprite = new THREE.Sprite(mat);
    fireSprite.position.set(0, FIRE_HEIGHT_OFFSET + 0.25, 0);
    fireSprite.scale.set(FIRE_SPRITE_SIZE, FIRE_SPRITE_SIZE * 1.35, 1);
    fireGroup.add(fireSprite);

    const base1 = FIRE_MAIN_INTENSITY,
      base2 = FIRE_SUB_INTENSITY;
    let t = 0;
    perFrame.push((dt) => {
      t += dt;
      const pulse = 0.45 * Math.sin(t * 5.4) + 0.28 * Math.sin(t * 8.7 + 1.1);
      fireLight.intensity = base1 + pulse;
      fireLight2.intensity = base2 + pulse * 0.75;
      fireLight.color.setHSL(0.06 + Math.sin(t * 1.6) * 0.01, 1.0, 0.55);
      fireLight2.color.setHSL(
        0.03 + Math.sin(t * 1.9 + 0.8) * 0.012,
        1.0,
        0.47
      );
      fireSprite.material.rotation += dt * 0.5;
      const j = 1.0 + Math.sin(t * 6.0) * 0.06;
      fireSprite.scale.set(
        FIRE_SPRITE_SIZE * j,
        FIRE_SPRITE_SIZE * 1.35 * j,
        1
      );
    });
  }

  function animate() {
    if (!active) return;
    const dt = clock.getDelta();
    if (mixer) mixer.update(dt);
    updateCameraHover(dt);
    perFrame.forEach((fn) => fn(dt));

    /* smooth remote players */
    players.forEach(({ group, target, rotY }) => {
      const k = 1 - Math.pow(0.0001, dt * 60);
      group.position.lerp(target, k);
      group.rotation.y += (rotY - group.rotation.y) * k;
    });

    renderer.setRenderTarget(rt);
    postMat.uniforms.tDiffuse.value = null;
    renderer.clear();
    renderer.render(scene, camera);

    renderer.setRenderTarget(null);
    postMat.uniforms.tDiffuse.value = rt.texture;
    postMat.uniforms.uTime.value += dt;
    renderer.render(postScene, postCam);

    requestAnimationFrame(animate);
  }

  const clock = new THREE.Clock();
  let active = true;
  addEventListener("visibilitychange", () => {
    active = document.visibilityState === "visible";
    if (active) requestAnimationFrame(animate);
  });
  const fadeIn = () => requestAnimationFrame(() => (canvas.style.opacity = "1"));
  requestAnimationFrame(() => {
    animate();
    requestAnimationFrame(fadeIn);
  });

  addEventListener("resize", () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    resizeRenderer();
    updateFooterClip();
    patchedMaterials.forEach((m) => {
      const s = m.userData._ps1Shader;
      if (s) s.uniforms.uResolution.value.set(innerWidth, innerHeight);
    });
  });

  const audio = new Audio("https://world.seppjm.com/3js/audio/stalker.mp3");
  audio.loop = true;
  audio.preload = "auto";
  audio.crossOrigin = "anonymous";
  audio.volume = 0.5;

  const btn = document.createElement("button");
  btn.type = "button";
  btn.textContent = "Play music";
  btn.setAttribute("aria-pressed", "false");
  btn.ariaLabel = "Toggle background music";
  Object.assign(btn.style, {
    position: "fixed",
    right: "16px",
    bottom: "16px",
    zIndex: "3",
    padding: "10px 14px",
    borderRadius: "9999px",
    border: "1px solid rgba(255,255,255,0.25)",
    background: "rgba(0,0,0,0.45)",
    color: "white",
    font:
      "500 14px/1.1 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif",
    backdropFilter: "blur(6px)",
    WebkitBackdropFilter: "blur(6px)",
    cursor: "pointer",
    userSelect: "none",
  });
  document.body.appendChild(btn);

  let isPlaying = false,
    fadeRAF = null;
  function fadeTo(target, ms = 700) {
    cancelAnimationFrame(fadeRAF);
    const start = audio.volume,
      delta = target - start,
      t0 = performance.now();
    const tick = (now) => {
      const p = Math.min(1, (now - t0) / ms);
      audio.volume = Math.max(0, Math.min(1, start + delta * p));
      if (p < 1) fadeRAF = requestAnimationFrame(tick);
    };
    fadeRAF = requestAnimationFrame(tick);
  }
  async function playAudio() {
    try {
      await audio.play();
      isPlaying = true;
      btn.textContent = "Pause music";
      btn.setAttribute("aria-pressed", "true");
      fadeTo(0.5, 600);
    } catch {}
  }
  function pauseAudio() {
    fadeTo(0.0, 400);
    setTimeout(() => audio.pause(), 420);
    isPlaying = false;
    btn.textContent = "Play music";
    btn.setAttribute("aria-pressed", "false");
  }
  btn.addEventListener("click", () =>
    isPlaying ? pauseAudio() : playAudio()
  );
  const startOnGesture = () => {
    if (!isPlaying) playAudio();
    removeEventListener("pointerdown", startOnGesture);
    removeEventListener("keydown", startOnGesture);
  };
  addEventListener("pointerdown", startOnGesture, { once: true });
  addEventListener("keydown", startOnGesture, { once: true });

  /* start spectator connection */
  connectSpectator();
})();
