// SPDX-FileCopyrightText: 2026 fuddlesworth
// SPDX-License-Identifier: GPL-3.0-or-later

// Neon City — Image Pass (compositing, zones, borders, labels, DOF)
//
// Reads the full-screen 3D cityscape from iChannel0 (buffer pass) and the
// depth buffer from uDepthBuffer. Composites per-zone with rounded borders,
// holographic labels, inner edge glow, outer glow, DOF, and a vignette.

#include <audio.glsl>
#include <multipass.glsl>
#include <depth.glsl>

// ─── Per-zone rendering ─────────────────────────────────────────────

vec4 renderZone(vec2 fragCoord, vec4 rect, vec4 params, vec4 fillColor, vec4 borderColor,
                bool isHighlighted,
                float bass, float mids, float treble, float overall, bool hasAudio)
{
    // Corner radius: logical px to device px, clamped to half the zone's smaller side.
    // Shared with the decoration side via zoneSdf() in shared/common.glsl.
    ZoneSDF zoneShape = zoneSdf(fragCoord, rect, params.x);
    float borderWidth  = zoneBorderWidth(params.y);

    float reactivity   = p_reactivity >= 0.0 ? p_reactivity : 1.5;
    float bassImpact   = p_bassImpact >= 0.0 ? p_bassImpact : 1.5;
    float trebleImpact = p_trebleImpact >= 0.0 ? p_trebleImpact : 1.5;
    float midsImpact   = p_midsImpact >= 0.0 ? p_midsImpact : 1.0;
    float idleSpeed    = p_idleSpeed >= 0.0 ? p_idleSpeed : 1.0;
    float fillOpacity  = p_fillOpacity >= 0.0 ? p_fillOpacity : 0.92;
    float showLabels   = p_showLabels;  // bool: >0.5 = show
    float dofStrength  = p_dofStrength >= 0.0 ? p_dofStrength : 0.35;
    float edgeGlow     = p_edgeGlow >= 0.0 ? p_edgeGlow : 1.4;

    vec3 accent  = colorWithFallback(p_accentColor.rgb, vec3(0.50, 1.50, 2.00));
    vec3 bassCol = colorWithFallback(p_bassColor.rgb, vec3(0.00, 0.00, 1.50));
    vec3 lightC  = colorWithFallback(p_lightColor.rgb, vec3(0.80, 0.45, 0.18));

    // Audio channels scaled by reactivity AND per-band impact — consistent
    // with the buffer pass so the reactivity slider affects every element.
    float aBass   = hasAudio ? bass   * reactivity * bassImpact   : 0.0;
    float aMids   = hasAudio ? mids   * reactivity * midsImpact   : 0.0;
    float aTreble = hasAudio ? treble * reactivity * trebleImpact : 0.0;
    float energy  = hasAudio ? overall * reactivity : 0.0;

    float vitality = zoneVitality(isHighlighted);

    if (!isHighlighted) {
        accent  = vitalityDesaturate(accent, 0.3);
        bassCol = vitalityDesaturate(bassCol, 0.3);
        lightC  = vitalityDesaturate(lightC, 0.3);
        edgeGlow *= 0.4;
        aBass    *= 0.5;
        aMids    *= 0.5;
        aTreble  *= 0.5;
        energy   *= 0.5;
    }


    vec2 center   = zoneShape.center;  // already computed by zoneSdf()
    vec2 p        = fragCoord - center;
    float d       = zoneShape.d;

    vec4 result = vec4(0.0);

    if (d < 0.0) {
        vec2 sceneUv   = channelUv(0, fragCoord);
        vec2 texelSize = 1.0 / max(iChannelResolution[0], vec2(1.0));
        float pixelDepth = readDepth(sceneUv);

        // ── Depth-of-field — bokeh disc sampling ───────────
        // Two concentric rings of 6 taps + center, with bright-sample
        // weighting so neon signs and street lights become circular
        // bokeh discs when out of focus instead of square box-blur.
        // Focal depth picked from a wide 3×3 lookup near screen center
        // so it doesn't flicker when the camera crosses a thin building.
        vec4 scene;
        if (dofStrength > 0.001) {
            float focalDepth = 1.0;
            for (int fy = -1; fy <= 1; fy++) {
                for (int fx = -1; fx <= 1; fx++) {
                    vec2 sampleCoord = iResolution * 0.5
                        + vec2(float(fx), float(fy)) * iResolution * 0.08;
                    float sd = readDepth(channelUv(0, sampleCoord));
                    if (sd < focalDepth) focalDepth = sd;
                }
            }
            focalDepth = min(focalDepth, 0.6);

            float coc = abs(pixelDepth - focalDepth) * 2.0 * dofStrength;

            vec4 centerSample = texture(iChannel0, sceneUv);
            float cw = 1.0 + max(max(centerSample.r, centerSample.g), centerSample.b);
            vec4 bokeh = centerSample * cw;
            float total = cw;

            const int RINGS = 2;
            const int SAMPLES_PER_RING = 6;
            for (int r = 1; r <= RINGS; r++) {
                float radius = float(r) / float(RINGS);
                for (int s = 0; s < SAMPLES_PER_RING; s++) {
                    // Offset each ring's start angle so taps don't align
                    // radially — gives the bokeh disc a fuller fill.
                    float angle = (float(s) / float(SAMPLES_PER_RING)) * TAU
                                + float(r) * 0.7;
                    vec2 offset = vec2(cos(angle), sin(angle))
                                * radius * texelSize * coc * 12.0;
                    vec4 samp = texture(iChannel0, sceneUv + offset);
                    // Bright samples weighted higher → neon highlights
                    // expand into circular discs (true bokeh look) rather
                    // than smearing uniformly.
                    float w = 1.0 + max(max(samp.r, samp.g), samp.b);
                    bokeh += samp * w;
                    total += w;
                }
            }
            scene = bokeh / total;
        } else {
            scene = texture(iChannel0, sceneUv);
        }

        vec3 sceneRgb = scene.rgb;

        // ── Depth-edge silhouettes ──────────────────────────
        // Sobel-light gradient on the depth buffer detects building
        // outlines (against sky, against deeper buildings). Near edges
        // glow brighter than far edges (atmospheric perspective); mids
        // drive a subtle pulse. pixelDepth ≥ ~0.99 == sky/miss → skip.
        if (pixelDepth < 0.99) {
            float dL = readDepth(sceneUv - vec2(texelSize.x, 0.0));
            float dR = readDepth(sceneUv + vec2(texelSize.x, 0.0));
            float dU = readDepth(sceneUv - vec2(0.0, texelSize.y));
            float dD = readDepth(sceneUv + vec2(0.0, texelSize.y));
            float gx = dR - dL;
            float gy = dD - dU;
            float gradient = sqrt(gx * gx + gy * gy);
            // Lower bound of 0.006 skips quantization noise on smooth
            // building faces; upper bound saturates on sky silhouettes.
            float edge = smoothstep(0.006, 0.04, gradient);
            edge *= 1.0 - pixelDepth * 0.55;

            vec3 silhouette = mix(accent, bassCol, 0.35)
                            * edgeGlow * (1.0 + aMids * 0.4 + aBass * 0.25);
            sceneRgb += silhouette * edge * mix(0.25, 0.7, vitality);
        }

        // ── Depth-based atmospheric haze ────────────────────
        // Layered on top of the buffer's baked-in fog. Highlighted zones
        // get less haze (≈0.18 ceiling) so they read as the focus while
        // dimmed zones recede further into the city. Bass momentarily
        // clears the haze on the kick.
        float hazeStrength = mix(0.55, 0.18, vitality);
        float hazeAmount   = pixelDepth * hazeStrength
                           * (1.0 - clamp(aBass, 0.0, 1.0) * 0.35);
        vec3 hazeColor = mix(bassCol * 0.35, accent * 0.45, pixelDepth);
        hazeColor = mix(hazeColor, lightC * 0.4, clamp(aMids, 0.0, 1.0) * 0.3);
        sceneRgb = mix(sceneRgb, hazeColor, clamp(hazeAmount, 0.0, 0.85));

        // Non-highlighted zones: desaturate + darken sampled scene so the
        // active zone clearly reads as the focus.
        if (!isHighlighted) {
            float lum = dot(sceneRgb, vec3(0.2126, 0.7152, 0.0722));
            sceneRgb = mix(vec3(lum), sceneRgb, 0.45);
            sceneRgb *= 0.4;
        }
        // Fold in the zone's own fill colour as a light identity tint, the same
        // shape every other pack uses. zoneTint() returns sceneRgb untouched
        // when the fill is fully transparent.
        result.rgb = zoneTint(sceneRgb, fillColor, 0.35);
        result.a   = fillOpacity;

        // Inner edge glow — SDF-based tint that rides the zone's rim.
        // Fade distance is zoneLen(), not pxScale(): it is measured against the
        // zone edge, so it has to track the display scale the corner radius and
        // border width now use rather than the 1080p-relative resolution.
        float innerGlow = exp(d / zoneLen(mix(28.0, 14.0, vitality)))
                        * mix(0.04, 0.12, vitality);
        innerGlow *= edgeGlow * (0.5 + energy * 0.4 + aMids * 0.25);
        vec3 innerCol = mix(accent, bassCol, clamp(energy, 0.0, 1.0));
        result.rgb += innerCol * innerGlow;

        // ── Zone labels: white core + neon-sign halo ─────────
        // Structure follows the generic "soft Gaussian halo + bright core"
        // that reads cleanly, but swaps the generic vocabulary
        // (chromatic fringe, CRT scanlines, data-stream sin*sin flicker)
        // for the city's own sign behavior: per-zone flash phase derived
        // from the zone's rect, sign-palette halo (bassCol → signColorB
        // white-hot on flash), occasional treble-driven dropout, bass
        // pump. The label itself stays white — it's the halo/flash that
        // carries the city's identity.
        if (showLabels > 0.5) {
            vec2 labelUv = fragCoord / max(iResolution, vec2(0.001));
            vec2 texel   = 1.0 / max(iResolution, vec2(1.0));
            float labelAlpha = texture(uZoneLabels, labelUv).a;

            // Per-zone seed derived from the zone's normalized rect coords
            // scaled by a prime to spread bits — one seed per zone so the
            // entire label animates in sync (a per-fragment seed tiles the
            // label into visibly colored rectangles).
            float signSeed = hash21(rect.xy * 997.0 + rect.zw * 131.0);
            float phase    = signSeed * TAU;

            // Sign flash rate: 2.5-5Hz per-zone. When no audio, idleSpeed
            // multiplies the rate so users can slow down/speed up idle
            // animation to taste.
            float idleScale = hasAudio ? 1.0 : idleSpeed;
            float flashRate = (2.5 + signSeed * 2.5) * idleScale;
            float raw       = 0.5 + 0.5 * cos(flashRate * iTime + phase);
            float flash     = 0.25 + 0.75 * smoothstep(0.1, 0.9, raw);

            // Treble-driven neon glitch — noticeable blackouts per zone.
            float glitch = 1.0;
            if (aTreble > 0.09) {
                float slot = floor(iTime * (6.0 + aTreble * 10.0));
                float gh = hash21(vec2(signSeed * 97.0, slot));
                glitch = 1.0 - step(0.85 - aTreble * 0.18, gh) * 0.9;
            }

            // Gaussian halo — pixel radius scaled by pxScale() so angular
            // reach is resolution-independent. Computed here, the only place it
            // is used, rather than unconditionally at the top of renderZone.
            vec2 haloReach = texel * 3.5 * pxScale();
            float halo = 0.0;
            for (int hy = -2; hy <= 2; hy++) {
                for (int hx = -2; hx <= 2; hx++) {
                    float w = exp(-float(hx * hx + hy * hy) * 0.3);
                    halo += texture(uZoneLabels,
                        labelUv + vec2(float(hx), float(hy)) * haloReach).a * w;
                }
            }
            halo /= 16.5;

            // Halo stays in the sign palette (bassCol → accent on flash),
            // never drifts to white. Bass pump further saturates.
            vec3 haloColor = mix(bassCol * 1.6, accent * 1.6, flash)
                           * (1.0 + aBass * 0.8);

            if (halo > 0.003) {
                float haloEdge = halo * (1.0 - labelAlpha);
                float haloPower = edgeGlow * (0.3 + flash * 1.1) * glitch;
                result.rgb += haloColor * haloEdge * haloPower;
                result.a = max(result.a, haloEdge * 0.45);

                // Treble sparkle: additive hot-white bursts on the halo
                // edge — the label's "character flash" analogue.
                if (aTreble > 0.06) {
                    float sslot = floor(iTime * (5.0 + aTreble * 9.0));
                    float shash = hash21(vec2(signSeed * 31.0, sslot));
                    float spark = step(0.7, shash) * aTreble * 0.8;
                    result.rgb += vec3(2.0) * haloEdge * spark * glitch;
                }
            }

            if (labelAlpha > 0.01) {
                // Per-zone hue: each zone's label picks a tube color from
                // the sign palette (blue ↔ cyan) using signSeed.
                vec3 baseColor = mix(bassCol, accent, signSeed);
                vec3 core = baseColor * 1.7;

                // Arc-flash at the peak of the flash envelope only.
                core = mix(core, vec3(2.7),
                           smoothstep(0.75, 1.0, flash));

                // Bass kick briefly pushes the tube white-hot.
                float bassPunch = clamp(aBass - 0.35, 0.0, 0.55);
                core = mix(core, vec3(2.5), bassPunch);

                core *= glitch * (1.0 + aTreble * 0.25);
                result.rgb = mix(result.rgb, core, labelAlpha);
                result.a = max(result.a, labelAlpha);
            }
        }
    }

    // ── Border ───────────────────────────────────────────────
    // timeSin/timeCos used wherever phase continuity matters across the
    // K_TIME_WRAP boundary (~17 min).  Raw sin(iTime*k) would visibly
    // jump.  angularNoise/segments are noise/sawtooth and tolerate a
    // small reseed at the wrap, so they keep using iTime directly.
    // zoneStrokeWidth re-floors the derived stroke at one device pixel.
    // zoneBorderWidth() floors the border itself, but scaling that down
    // puts it straight back under a pixel, where it shimmers out on a
    // fractional scale. A width of 0 still passes through as 0.
    float coreWidth = zoneStrokeWidth(borderWidth * mix(0.5, 0.9, vitality));
    float borderCore = softBorder(d, coreWidth);
    if (borderCore > 0.0) {
        float borderAngle   = atan(p.x, -p.y) / TAU + 0.5;
        float borderEnergy  = 1.0 + energy * mix(0.2, 0.8, vitality)
                            + aMids * 0.15;

        // Slow 0.5Hz color sweep — must use timeSin() (wrap would snap).
        // When no audio, idleSpeed scales the sweep rate.
        float sweepRate = 0.5 * (hasAudio ? 1.0 : idleSpeed);
        // The zone's own border colour folds in HERE, at the border stroke and at
        // the sibling packs' weight of 0.3 — not into `accent`, which is a
        // scene-wide palette variable feeding the silhouettes, haze, label halo
        // and outer glow. buffer.frag renders that scene full-screen with no
        // zone to key off, so a scene-wide fold could never be consistent.
        vec3 borderAccent = mix(accent, colorWithFallback(borderColor.rgb, accent), 0.3);
        vec3 coreColor = mix(borderAccent, bassCol, 0.5 + 0.5 * timeSin(sweepRate))
                        * edgeGlow * borderEnergy;

        float flowRate = mix(0.3, 1.5, vitality) * (hasAudio ? 1.0 : idleSpeed);
        float flow     = angularNoise(borderAngle, 12.0, -iTime * flowRate);
        float segments = fract(borderAngle * 16.0 - iTime * flowRate);
        float segPulse = smoothstep(0.0, 0.1, segments) * smoothstep(1.0, 0.9, segments);
        coreColor *= mix(0.6, 1.2, flow) * mix(0.8, 1.0, segPulse);

        if (isHighlighted) {
            float breathe = 0.85 + 0.15 * timeSin(2.5, energy * 3.0);
            coreColor *= breathe;
            // Per-pixel sparkle: phase = borderAngle*TAU*8 + timeCos(3.0)
            // is harder to factor; use timeSin with the angle as offset.
            float sparkle = pow(max(timeSin(3.0, borderAngle * TAU * 8.0), 0.0), 8.0);
            coreColor = mix(coreColor, lightC * edgeGlow * borderEnergy, sparkle * 0.5);
        }

        if (aBass > 0.5) {
            float bassFlash = (aBass - 0.5) * 2.0 * vitality;
            coreColor = mix(coreColor, lightC * 2.0, bassFlash * borderCore * 0.4);
        }

        result.rgb = max(result.rgb, coreColor * borderCore * borderColor.a);
        result.a   = max(result.a, borderCore * borderColor.a);
    }

    // ── Outer glow ───────────────────────────────────────────
    // The outer glow is measured from the zone edge, so it is zoneLen() like
    // the border and corner radius beside it, not pxScale(). Under pxScale a 4K
    // monitor at scale 1 drew a 1x border with a 2x glow, and a 1080p monitor
    // at scale 2 drew the reverse.
    float baseGlowR  = zoneLen(mix(6.0, 16.0, vitality));
    float pulseRate  = 0.8 * (hasAudio ? 1.0 : idleSpeed);
    float glowRadius = baseGlowR
        + (hasAudio ? aBass * zoneLen(5.0) : timeSin(pulseRate) * zoneLen(2.0));
    glowRadius += energy * zoneLen(4.0);

    // Both lobes have their pedestal at the bound subtracted (expGlowBounded),
    // so they reach exactly 0 at glowRadius and the gate cuts nothing. The wide
    // lobe would otherwise still be at exp(-2) = 13.5% of its peak there.
    if (d > 0.0 && d < glowRadius) {
        float glow1 = expGlowBounded(d, glowRadius * 0.2, edgeGlow * mix(0.08, 0.25, vitality), glowRadius);
        float glow2 = expGlowBounded(d, glowRadius * 0.5, edgeGlow * mix(0.03, 0.08, vitality), glowRadius);

        vec3 glowColor = mix(accent, bassCol, clamp(energy * 0.8, 0.0, 1.0));
        if (isHighlighted) {
            float glowAngle = atan(p.x, -p.y) / TAU + 0.5;
            glowColor = mix(glowColor, lightC, angularNoise(glowAngle, 5.0, iTime * 0.6) * 0.5);
        }
        if (aBass > 0.3) {
            glowColor = mix(glowColor, lightC, (aBass - 0.3) * vitality);
        }

        result.rgb += glowColor * (glow1 + glow2);
        result.a   = max(result.a, (glow1 + glow2) * 0.4);
    }

    return result;
}

// ─── Main ───────────────────────────────────────────────────────────

vec4 pImage(vec2 fragCoord) {
    vec4 color = vec4(0.0);

    if (zoneCount == 0) {
        return vec4(0.0);
    }

    bool  hasAudio = iAudioSpectrumSize > 0;
    float bass    = getBass();
    float mids    = getMids();
    float treble  = getTreble();
    float overall = getOverall();

    for (int i = 0; i < zoneCount && i < 64; i++) {
        vec4 rect = zoneRects[i];
        if (rect.z <= 0.0 || rect.w <= 0.0) continue;

        vec4 zoneColor = renderZone(fragCoord, rect, zoneParams[i],
            zoneFillColors[i], zoneBorderColors[i],
            zoneParams[i].z > 0.5,
            bass, mids, treble, overall, hasAudio);

        color = blendOver(color, zoneColor);
    }

    // Vignette
    vec2 q = fragCoord / max(iResolution, vec2(1.0));
    float vig = 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.1);
    color.rgb *= vig;

    return color;
}
