// world/wall_material.js (Lane A) — the synthetic-scanner wall (ART_BIBLE.md §Rendering recipe).
// No real-time lights anywhere in GUTS: the wall is biome tint x detail luminance + a fresnel
// rim (the SEM edge glow) + exponential fog to the biome void. Peristalsis is vertex work.
//
// Attributes the geometry must supply (tube.js and arena.js both do):
//   position  vec3   baked ring/shell vertex
//   aInward   vec3   unit normal pointing into the playable space
//   aTangent  vec3   unit centreline tangent at this vertex (for the wave's normal tilt)
//   uv        vec2   (theta/2pi, s) — s in world units, NOT normalized: it's the wave's phase
//                    axis and the tiling axis, and it must stay continuous across chunks
//   aPhase    float  K(s) = ∫k ds, baked (see spline.js)
//   aK        float  local wavenumber, for the analytic d(pulse)/ds
//   aWaveA    float  local peristalsis amplitude. Per-VERTEX, not a uniform, because schema v2
//                    lets C set `wave.amp` per segment and segments share a biome material.
//   aRadius   float  the ring's radius here. Per-VERTEX for the same reason: one biome material
//                    now spans radius 12 corridors AND radius 70 rooms (see uThetaSpan below).
//
// Colorspace: `#include <colorspace_fragment>` at the end of main() is LAW (TECH §Shader law).
// three converts THREE.Color inputs to linear but does not convert a raw shader's output back,
// so without it every ART_BIBLE colour ships wrong (B measured amber #ff5a2a displaying as
// pure red). Round 1 shipped without it; this is the fix.

import * as THREE from 'three';

export function createWallMaterial({
  biome,
  omega,
  fog = biome.fog,        // overridable: arenas size their own fog to the room (see index.js)
  breatheAmp = biome.wave.breathe,
  detail = null,          // THREE.Texture | null — Lane D's grayscale detail/AO map
  detailB = null,         // THREE.Texture | null — the _b variant, macro variation
  normalMap = null,       // THREE.Texture | null — tangent-space normals
  matcap = null,          // THREE.Texture | null — wet-tissue specular ball
  repeat = null,          // [repeats around theta, repeats per unit of s] (= D's .repeat)
  repeatB = null,
  normalScale = 0.6,      // D: "0.6 looks right; 0 = off, 1 = full relief"
  matcapGain = 0.14,
  radiusHint = 10,        // used only to pick a square-ish default tiling
  side = THREE.FrontSide,
}) {
  for (const t of [detail, detailB, normalMap]) {   // we consume D's textures; set what we rely on
    if (t) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.needsUpdate = true; }
  }
  // Default tiling if D has no `tile` hint: ~square texels for this biome's radius.
  const fallbackRepeat = [3, 3 / Math.max(4, (2 * Math.PI * radiusHint) / 3)];
  const rep = repeat ?? fallbackRepeat;

  // Tiling around theta is a DENSITY, not a count — the fix for rooms.
  // D's `tile[0]` is "repeats around", which is a count, and a count is only a texel size if
  // the radius never changes. It doesn't: with `mode:"open"` one stomach material now spans a
  // radius-12 cardia and a radius-70 sea, and 4 repeats around a 440u circumference is one
  // repeat per 110u against 18u along s — a measured 6.1:1 smear (round 2 prototype). So:
  // convert D's count into the arc length it implies AT THIS BIOME'S REFERENCE RADIUS, and let
  // the shader re-derive the count from each ring's own radius.
  //
  // Precisely: at the reference radius this is arithmetically identical to before, so **D's
  // authored `tile` numbers keep their exact meaning** — but away from it the count deliberately
  // moves, because that is the entire fix. L2's hiatus (radius 7 against a reference of ~10)
  // now takes 3 repeats where it took 4, and that IS the texel size holding still while the
  // pipe narrows. So: not a byte-for-byte no-op on existing corridors, a small and correct
  // change to them, eyeballed (docs/shots/laneA/round2_L2_hiatus_retuned.png).
  const thetaSpan = (2 * Math.PI * radiusHint) / Math.max(0.001, rep[0]);
  // The _b layer deliberately runs at a different, non-integer-multiple scale: sampling the
  // same tiling twice would just reinforce the repeat it's meant to hide.
  const repB = repeatB ?? [rep[0] / 2.7, rep[1] / 2.7];

  const defines = {};
  if (detail) defines.USE_DETAIL = '';
  if (detail && detailB) defines.USE_DETAIL_B = '';
  if (normalMap) defines.USE_NORMAL = '';
  if (matcap) defines.USE_MATCAP = '';

  const mat = new THREE.ShaderMaterial({
    side,
    defines,
    uniforms: {
      uTime: { value: 0 },
      uTint: { value: new THREE.Color(biome.palette.tint) },
      uRim: { value: new THREE.Color(biome.palette.rim) },
      uVoid: { value: new THREE.Color(biome.palette.void) },
      uFog: { value: fog },
      uBreatheA: { value: breatheAmp },
      uOmega: { value: omega },
      // Retuned for the colorspace law (round 2). The round-1 values (pow 2.2, gain 0.9)
      // were eyeballed on the raw framebuffer; with <colorspace_fragment> encoding the
      // output, every midtone gained ~a stop and the rim — which the whole tube sees at
      // grazing angles — flooded the frame mint. Tighter pow confines it to fold edges
      // (where the TBN normals vary), lower gain returns the wall to "dark tissue, rim
      // light" (ART_BIBLE). Re-eyeballed on the six-biome contact sheet, ?lvl=biomes.
      uRimPow: { value: 3.0 },
      uRimGain: { value: 0.35 },
      uDetail: { value: detail },
      uDetailB: { value: detailB },
      uNormal: { value: normalMap },
      uMatcap: { value: matcap },
      uNormalScale: { value: normalScale },
      uMatcapGain: { value: matcapGain },
      uRepeat: { value: new THREE.Vector2(rep[0], rep[1]) },
      uRepeatB: { value: new THREE.Vector2(repB[0], repB[1]) },
      uThetaSpan: { value: thetaSpan },
    },
    vertexShader: /* glsl */`
      attribute vec3 aInward;
      attribute vec3 aTangent;
      attribute float aPhase;
      attribute float aK;
      attribute float aWaveA;
      attribute float aRadius;
      uniform float uTime, uBreatheA, uOmega, uThetaSpan;
      varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
      varying float vThetaU; varying float vThetaUB;

      void main() {
        vUv = uv;
        // Texture coordinate around theta, in REPEATS, from this ring's own arc length — so a
        // texel is the same size on a radius-12 pipe and a radius-70 room. Computed here, not
        // per-pixel, so it interpolates linearly across the quad.
        //
        // ROUNDED TO AN INTEGER, and that is not fussiness — it is the whole trick. uv.x runs
        // 0..1 around the ring, so the wrap is only seamless if the texture completes a WHOLE
        // number of repeats; a raw arc-length count is fractional and lays a permanent seam
        // down the length of the canal (seen, then fixed — round 2). Rounding costs at most
        // half a repeat of density error, which is worst where the count is smallest, hence
        // the floor of 2. Where radius is ~constant this is exactly D's authored count, so
        // corridors are untouched. Where it climbs, the count steps 4->5->6; each step shears
        // the texture across one 0.5u ring, which is invisible next to a 6:1 smear.
        float nTheta = max(2.0, floor((6.2831853 * max(0.001, aRadius)) / uThetaSpan + 0.5));
        vThetaU = uv.x * nTheta;
        // The _b layer wraps too, so it needs its own WHOLE count — dividing vThetaU by 2.7
        // would re-introduce the exact seam the rounding above just removed. Rounded from its
        // own span, floored at 1, and nudged off nTheta so the two layers can't lock into the
        // same repeat and reinforce the tiling they exist to hide.
        float nB = max(1.0, floor(nTheta / 2.7 + 0.5));
        vThetaUB = uv.x * (nB == nTheta ? max(1.0, nTheta - 1.0) : nB);
        float phi = aPhase - uOmega * uTime;
        float sn = max(0.0, sin(phi));
        float pulse = sn * sn * sn;                     // sharp crest, long trough: a muscle, not a sine
        float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0);
        float disp = aWaveA * pulse + breathe;

        // Tilt the normal with the wave. Without this the crests are silhouette-only and the
        // rim light slides over them as if the wall were flat — the effective wall radius is
        // rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds.
        float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK;
        vec3 nIn = normalize(aInward - aTangent * (aWaveA * dPulse_ds));

        vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
        vN = normalize(normalMatrix * nIn);
        vView = -mv.xyz;
        vPulse = pulse;
        gl_Position = projectionMatrix * mv;
      }`,
    fragmentShader: /* glsl */`
      uniform vec3 uTint, uRim, uVoid;
      uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain;
      uniform vec2 uRepeat, uRepeatB;
      varying float vThetaU; varying float vThetaUB;
      #ifdef USE_DETAIL
        uniform sampler2D uDetail;
      #endif
      #ifdef USE_DETAIL_B
        uniform sampler2D uDetailB;
      #endif
      #ifdef USE_NORMAL
        uniform sampler2D uNormal;
      #endif
      #ifdef USE_MATCAP
        uniform sampler2D uMatcap;
      #endif
      varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;

      #ifdef USE_NORMAL
      // three's perturbNormal2Arb, lifted from Lane D's web/dev/laneD_texview.html.
      // The tube carries no tangent attribute, so rebuild the TBN per-pixel from screen
      // derivatives. DO NOT "simplify" this to normalize(vN + tn * k): a tangent-space sample
      // is ~(0,0,1), so adding it tilts every normal toward the camera, dot(n,view) -> 1, the
      // fresnel rim dies and the tube renders near-black. The rim IS the biome's only light.
      // That failure looks exactly like "Lane D's textures are too dark" and is not.
      vec3 perturb(vec3 N, vec3 viewPos, vec2 st, vec3 mapN) {
        vec3 q0 = dFdx(viewPos), q1 = dFdy(viewPos);
        vec2 st0 = dFdx(st), st1 = dFdy(st);
        vec3 S = normalize(q0 * st1.t - q1 * st0.t);
        vec3 T = normalize(-q0 * st1.s + q1 * st0.s);
        return normalize(mat3(S, T, N) * mapN);
      }
      #endif

      void main() {
        // .x from the arc-length density (vThetaU), .y from D's units-of-s-per-repeat.
        vec2 duv = vec2(vThetaU, vUv.y * uRepeat.y);

        #ifdef USE_DETAIL
          // Lane D authors grayscale luminance/AO; the biome tint is applied here, so one
          // texture serves two biomes at different tints (ART_BIBLE §FLUX prompt kit).
          float detail = texture2D(uDetail, duv).r;
          #ifdef USE_DETAIL_B
            // macro variation: the _b wall at a coarser, non-multiple scale breaks _a's repeat
            float db = texture2D(uDetailB, vec2(vThetaUB, vUv.y * uRepeatB.y)).r;
            detail = mix(detail, detail * (0.55 + 0.9 * db), 0.6);
          #endif
          // D's round-1 curve was (0.35 + d*0.95)*0.9, measured on the raw framebuffer.
          // Post-colorspace that pedestal alone displays as ~60% grey; re-measured on the
          // contact sheet so a mid-grey texel lands near the tint's own luminance.
          vec3 base = uTint * (0.12 + detail * 0.60);
        #else
          // Assets-optional law: no texture is the *shipping* look until D lands, not an error
          // state. Ridged folds around theta + striation along s = a passable SEM stand-in.
          float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0);
          float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
          // detail² not detail: the sine bands only span ~0.4..1.0, and post-colorspace that
          // narrow range reads as a flat bright wall (the oral biome was near white-out).
          // Squaring restores the dark troughs the SEM look needs; still fallback art.
          vec3 base = uTint * detail * detail * 0.65;
        #endif

        vec3 N = normalize(vN);
        #ifdef USE_NORMAL
          vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0;
          tn.xy *= uNormalScale;
          N = perturb(N, -vView, duv, normalize(tn));
        #endif

        vec3 V = normalize(vView);
        float fres = pow(1.0 - abs(dot(N, V)), uRimPow);
        // crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little
        // help beyond what its own geometry earns from the rim term
        vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.04;

        #ifdef USE_MATCAP
          // wet specular. Keyed off the perturbed normal so the sheen follows the folds, and
          // weighted toward grazing angles so it reads as a film of mucus, not a plastic gloss.
          vec2 muv = N.xy * 0.5 + 0.5;
          col += texture2D(uMatcap, muv).rgb * uMatcapGain * (0.25 + 0.75 * fres);
        #endif

        float d = length(vView);
        gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
        #include <colorspace_fragment>
      }`,
  });
  // WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core.
  mat.extensions = { derivatives: true };
  return mat;
}
