/**
 * 게이트 씬 — 유리판 여섯 장이 깊이 축에 서고, 스크롤하면 카메라가 통과한다.
 *
 * 파이프라인 섹션 전용이다. 한때 히어로 배경으로도 썼는데, 그러면 같은 그림이
 * 페이지에 두 번 나온다. 그리고 히어로에는 옆에서 반응할 콘텐츠가 없어서
 * 의미를 판독기가 자막으로 설명해야 했다 — 그림이 스스로 말하지 못한다는 뜻이다.
 * 히어로 배경은 `shader-plane.ts` 의 흐름 필드가 맡는다.
 *
 * 성능 게이트는 references/three.md 그대로다.
 *   - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
 *   - DPR 클램프 1.5. 저사양이면 판을 여섯에서 셋으로 줄인다
 *   - 화면 밖이거나 탭이 숨으면 그리지 않는다
 *   - WebGL 이 없거나 모션을 줄이는 설정이면 아예 시작하지 않는다.
 *     그때 보이는 CSS 그라디언트가 폴백이 아니라 결과물이다
 */

export interface GateSceneOptions {
  canvas: HTMLCanvasElement;
  /** CSS 커스텀 프로퍼티 이름 */
  accentVar: string;
  glowVar: string;
  /**
   * 통과한 게이트가 바뀔 때만 부른다. 프레임마다 부르지 않는다 —
   * R3(Codrops HAOQI) 에서 가져온 규칙이다. DOM 은 구조를 소유하고
   * WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
   */
  onGate?: (index: number, total: number) => void;
  /**
   * 스크롤 진행도를 이 요소로 잰다. 기본은 캔버스 자신.
   * 캔버스를 sticky/fixed 로 화면에 붙여두면 캔버스의 rect 는 변하지 않으므로
   * 진행도가 고정된다 — 그때는 실제로 지나가는 섹션을 넘겨야 한다.
   */
  progressFrom?: HTMLElement;
}

const NOISE = `
  float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
               mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
  }
  float fbm(vec2 p) {
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
    return v;
  }
`;

export function mountGateScene(opts: GateSceneOptions): void {
  if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const { canvas } = opts;

  const io = new IntersectionObserver(
    (entries) => {
      if (!entries.some((e) => e.isIntersecting)) return;
      io.disconnect();
      void boot(opts);
    },
    { rootMargin: "0px" },
  );
  io.observe(canvas);
}

async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSceneOptions): Promise<void> {
  let THREE: typeof import("three");
  try {
    THREE = await import("three");
  } catch {
    return;
  }

  let renderer: import("three").WebGLRenderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
  } catch {
    return;
  }

  const css = getComputedStyle(document.documentElement);
  const color = (v: string, fb: string) =>
    new THREE.Color((css.getPropertyValue(v).trim() || fb) as string);
  const accent = color(accentVar, "#4FD6C4");
  const glow = color(glowVar, "#6E7BFF");

  // 저사양 판정.
  // 코어 수만 보면 요즘 폰(8코어)이 전부 고사양으로 잡힌다. 화면 폭을 같이 본다 —
  // 좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
  const narrow = innerWidth < 760;
  const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
  const PANE_COUNT = lean ? 3 : 6;

  // 좁은 화면에서는 DPR 도 한 단계 더 내린다. 폰의 DPR 은 3 까지 간다.
  const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
  renderer.setPixelRatio(dpr);

  /* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */
  const rtScene = new THREE.Scene();
  const rtCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const rt = new THREE.WebGLRenderTarget(1, 1, {
    magFilter: THREE.LinearFilter,
    minFilter: THREE.LinearFilter,
  });

  const bgUniforms = {
    uTime: { value: 0 },
    uAspect: { value: 1 },
    uPointer: { value: new THREE.Vector2(0.5, 0.5) },
    uProgress: { value: 0 },
    uA: { value: accent },
    uB: { value: glow },
  };

  rtScene.add(
    new THREE.Mesh(
      new THREE.PlaneGeometry(2, 2),
      new THREE.ShaderMaterial({
        uniforms: bgUniforms,
        vertexShader: `
          varying vec2 vUv;
          void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
        `,
        fragmentShader: `
          precision mediump float;
          varying vec2 vUv;
          uniform float uTime; uniform float uAspect;
          uniform vec2 uPointer; uniform float uProgress;
          uniform vec3 uA; uniform vec3 uB;
          ${NOISE}
          void main() {
            vec2 p = vUv; p.x *= uAspect;
            float t = uTime * 0.045;
            vec2 m = uPointer; m.x *= uAspect;

            float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6));
            float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55);

            // 통과할수록 빛이 뒤로 물러난다.
            // 중심을 화면 세로 중앙에 두고 반경을 넓게 잡는다 — 좁으면
            // 히어로 위아래가 검게 남아 배경이 '떠 있는 덩어리'로 보인다.
            vec2 c1 = mix(vec2(0.34 * uAspect, 0.52), m, 0.20) + vec2(0.0, uProgress * 0.14);
            vec2 c2 = vec2(0.78 * uAspect, 0.44) - vec2(uProgress * 0.10, 0.0);

            float a = smoothstep(1.05, 0.05, distance(p, c1) + f1 * 0.34);
            float b = smoothstep(0.95, 0.04, distance(p, c2) + f2 * 0.30);

            vec3 col = uA * a + uB * b * 0.9;
            float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.78;
            gl_FragColor = vec4(col, alpha);
          }
        `,
        transparent: true,
      }),
    ),
  );

  /* ---- 2) 유리 판. 뒤 텍스처를 밀어서 샘플링한다 ------------- */
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 60);
  camera.position.set(0, 0, 9);

  const paneMat = new THREE.ShaderMaterial({
    transparent: true,
    depthWrite: false,
    uniforms: {
      uScene: { value: rt.texture },
      uTime: { value: 0 },
      // 테두리 광. 흰색을 박지 않고 강조색을 밝게 올려 쓴다 —
      // 토큰을 바꾸면 유리 테두리도 같이 따라와야 한다.
      uEdge: { value: accent.clone().lerp(new THREE.Color(1, 1, 1), 0.55) },
      uResolution: { value: new THREE.Vector2(1, 1) },
    },
    vertexShader: `
      varying vec2 vUv;
      varying vec3 vNormalW;
      varying vec3 vViewDir;
      void main() {
        vUv = uv;
        vec4 world = modelMatrix * vec4(position, 1.0);
        vNormalW = normalize(mat3(modelMatrix) * normal);
        vViewDir = normalize(cameraPosition - world.xyz);
        gl_Position = projectionMatrix * viewMatrix * world;
      }
    `,
    fragmentShader: `
      precision mediump float;
      varying vec2 vUv;
      varying vec3 vNormalW;
      varying vec3 vViewDir;
      uniform sampler2D uScene;
      uniform float uTime;
      uniform vec3 uEdge;
      uniform vec2 uResolution;
      ${NOISE}

      void main() {
        // 판을 미세하게 굽힌다.
        // 완전히 평평한 판이 카메라를 정면으로 보면 dot(n, view) 가 1 이라
        // 프레넬이 구조적으로 0 이 된다. 그러면 유리로 읽히게 하는 신호가
        // 하나도 남지 않는다 — 이전 판이 흐린 얼룩으로 보였던 이유다.
        vec3 n = normalize(vNormalW + vec3((vUv - 0.5) * 0.9, 0.0));
        float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2);

        // 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
        // 배경이므로 경계를 넓게 풀어 선이 아니라 번짐으로 만든다 —
        // 좁게 두면 사각형 프레임이 또렷하게 서서 추상 패턴이 아니라 '상자'가 된다.
        vec2 e = min(vUv, 1.0 - vUv);
        float edge = 1.0 - smoothstep(0.0, 0.17, min(e.x, e.y));
        edge *= edge;

        // 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
        vec2 screen = gl_FragCoord.xy / uResolution;
        vec2 flow = vec2(
          fbm(vUv * 2.4 + uTime * 0.05),
          fbm(vUv * 2.4 - uTime * 0.04 + 3.1)
        ) - 0.5;
        vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;

        // 배경이다. 테두리 광을 절반으로 낮춘다 — 액자 안에서 주인공일 때의
        // 값을 그대로 두면 글자 뒤에서 형광 막대처럼 튄다.
        vec3 col = back * 1.03 + uEdge * (fres * 0.20 + edge * 0.30);
        float alpha = 0.12 + fres * 0.18 + edge * 0.32;
        gl_FragColor = vec4(col, alpha);
      }
    `,
  });

  // 판은 깊이 축에 정면으로 선다. 떠다니는 사각형이 아니라 통과하는 문이다.
  //
  // 폭이 중요하다. 액자는 가로로 길어(약 2.9:1) 카메라의 수평 시야가
  // 거리 d 에서 2.23*d 인데, 판이 그보다 좁으면 통과하는 내내 좌우 테두리가
  // 화면 안에 남는다. 그러면 "문을 지난다"가 아니라 세로 기둥 두 개가
  // 지나가는 것으로 보인다 — 실제로 그렇게 보였다.
  // 판을 액자 비율에 맞춰 첫 판이 화면을 덮게 하면, 가까워질수록 좌우가
  // 화면 밖으로 빠지고 뒤 판들이 원근으로 남아 터널이 된다.
  const GATE_GAP = 4.6;
  const panes: import("three").Mesh[] = [];
  const geo = new THREE.PlaneGeometry(15, 6, 1, 1);
  for (let i = 0; i < PANE_COUNT; i++) {
    const m = new THREE.Mesh(geo, paneMat);
    // 좌우로 조금씩 어긋나게 세워 통과할 때 옆면이 스친다
    // 어긋남은 아주 작게. 판이 좌우로 흩어지면 '통과하는 문'이 아니라
    // '쌓아둔 사각형'이 된다. 중심축이 살아야 원근이 터널로 읽힌다.
    const sway = (i % 2 === 0 ? 1 : -1) * (0.16 + i * 0.05);
    m.position.set(sway, Math.sin(i * 1.3) * 0.32, -i * GATE_GAP);
    m.rotation.set(sway * 0.03, sway * 0.11, sway * 0.05);
    m.userData["index"] = i;
    m.userData["sway"] = sway;
    scene.add(m);
    panes.push(m);
  }

  /* ---- 3) 크기·포인터·루프 --------------------------------- */
  const pointer = new THREE.Vector2(0.5, 0.5);
  const pointerTarget = new THREE.Vector2(0.5, 0.5);

  // 판이 화면을 덮는 데 필요한 최소 폭.
  // 수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
  // 데스크톱(2.5:1)에 맞춘 폭 15 를 세로 화면(0.45:1)에 그대로 쓰면
  // 수평 시야의 여섯 배가 되어 첫 판이 화면을 완전히 덮는다 —
  // 게이트가 보이지 않고 그냥 밝은 화면이 된다.
  const GEO_W = 15;
  const fitPanes = (aspect: number) => {
    const halfH = Math.tan((camera.fov * Math.PI) / 360);
    const viewW = 2 * 7.2 * halfH * aspect;
    const want = Math.max(6.5, viewW * 1.12);
    const sx = Math.min(1, want / GEO_W);
    for (const m of panes) m.scale.x = sx;
  };

  const fit = () => {
    const r = canvas.getBoundingClientRect();
    if (!r.width || !r.height) return;
    renderer.setSize(r.width, r.height, false);
    rt.setSize(Math.round(r.width * dpr * 0.6), Math.round(r.height * dpr * 0.6));
    // gl_FragCoord 는 캔버스의 드로잉버퍼 좌표다. 렌더 타겟은 그 0.6배라
    // 타겟 크기로 나누면 UV 가 1 을 넘어 텍스처가 잘린다.
    renderer.getDrawingBufferSize(paneMat.uniforms["uResolution"]!.value);
    camera.aspect = r.width / r.height;
    camera.updateProjectionMatrix();
    bgUniforms.uAspect.value = r.width / r.height;
    fitPanes(camera.aspect);
  };
  fit();
  // window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
  // 히어로 높이는 폰트가 늦게 오거나 콘텐츠가 접힐 때도 바뀌는데
  // 그건 resize 이벤트가 아니다 — 그때 캔버스가 옛 크기로 남아
  // 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
  new ResizeObserver(fit).observe(canvas);

  addEventListener(
    "pointermove",
    (e) => {
      const r = canvas.getBoundingClientRect();
      pointerTarget.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
    },
    { passive: true },
  );

  let tabVisible = true;
  document.addEventListener("visibilitychange", () => {
    tabVisible = !document.hidden;
  });
  let onScreen = true;
  new IntersectionObserver((es) => {
    onScreen = es.some((e) => e.isIntersecting);
  }).observe(canvas);

  canvas.classList.add("is-live");

  renderer.setAnimationLoop((ms) => {
    if (!tabVisible || !onScreen) return;
    const t = ms * 0.001;

    pointer.lerp(pointerTarget, 0.04);

    // 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
    // rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
    const rect = (progressFrom ?? canvas).getBoundingClientRect();
    // 진행 거리.
    //   한때 (섹션 높이 - 뷰포트)로 쟀다. 그러면 진행 구간이 섹션 길이에 묶여서,
    //   게이트를 다 돌리려면 목록 뒤에 62dvh 짜리 빈 공간을 둬야 했다.
    //   화면 절반이 넘는 여백이 생기고, 그건 콘텐츠가 아니라 스크롤 연료였다.
    //
    //   대신 **읽는 대상이 화면을 지나는 동안**으로 맞춘다.
    //   위 70% 지점에 닿으면 0, 아래 30% 지점을 빠져나가면 1.
    //   목록이 눈앞에 있는 동안만 게이트가 돌아서 빈 공간이 필요 없다.
    const start = innerHeight * 0.7;
    const end = innerHeight * 0.3;
    const travel = progressFrom
      ? rect.height + (start - end)
      : rect.height * 0.92;
    const offset = progressFrom ? start - rect.top : -rect.top;
    const progress = Math.min(Math.max(offset / travel, 0), 1);

    bgUniforms.uProgress.value = progress;
    bgUniforms.uTime.value = t;
    bgUniforms.uPointer.value.copy(pointer);
    paneMat.uniforms["uTime"]!.value = t;

    onGate?.(Math.min(PANE_COUNT - 1, Math.floor(progress * PANE_COUNT)), PANE_COUNT);

    // 카메라가 여섯 개의 문을 차례로 통과한다
    const depth = (PANE_COUNT - 1) * GATE_GAP;
    // 배경은 천천히 흐른다. 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다.
    camera.position.z = 7.2 - progress * depth * 0.6;
    camera.position.x = (pointer.x - 0.5) * 1.1;
    camera.position.y = (pointer.y - 0.5) * -0.5;
    camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);

    for (const m of panes) {
      const i = m.userData["index"] as number;
      const sway = m.userData["sway"] as number;
      // 통과 직전의 판만 미세하게 숨쉰다. 전부 흔들면 다시 부유물이 된다
      const dist = Math.abs(m.position.z - camera.position.z);
      const near = Math.max(0, 1 - dist / (GATE_GAP * 1.6));
      m.rotation.z = sway * 0.018 + Math.sin(t * 0.3 + i) * 0.012 * near;
      m.position.y = Math.sin(i * 1.3) * 0.32 + Math.sin(t * 0.24 + i) * 0.06;
    }

    // 배경을 먼저 타겟에 굽고, 그 텍스처를 유리가 읽는다
    renderer.setRenderTarget(rt);
    renderer.clear();
    renderer.render(rtScene, rtCam);
    renderer.setRenderTarget(null);

    renderer.clear();
    renderer.render(rtScene, rtCam);
    renderer.render(scene, camera);
  });
}
