-- GBC Effects post-process ("Pixel Transparency" style, see
-- github.com/mattakins/Pixel_Transparency).  A cumulative 4-level ladder
-- applied after palette colorization and before the CRT pass:
--   1  reflective screen: bright pixels blend toward a procedurally
--      grained warm backing (the unlit-GBC "transparent whites" look)
--   2  + LCD subpixel grid
--   3  + drop shadows (dark pixels float above the backing)
--   4  + sunlight: specular glare + rainbow QWP shimmer with a slowly
--      drifting light source
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
-- Spec: docs/new-features.md (Custom Options / GBC FX)
--
-- One shader for all levels: features are gated by the `level` uniform
-- (float comparisons), so cycling never recompiles.  All spatial effects
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
-- shadow offsets and grain stay window-size independent.

local GBCFX = {}

GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
GBCFX.level = 0

local shader -- false = unavailable (headless / no shader support)

-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136).  Desktop is unchanged; Android/iOS refuse the effect.
--
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
-- "0" force off), same tri-state as POKEPORT_TOUCH.  Handheld packs whose
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
-- checkout exercise the unsupported path without stubbing love.system.
function GBCFX.isSupported()
  local env = os.getenv("POKEPORT_GBCFX")
  if env == "1" then return true end
  if env == "0" then return false end
  if not love or not love.system or not love.system.getOS then return true end
  local osName = love.system.getOS()
  return osName ~= "Android" and osName ~= "iOS"
end

-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
extern number level;
extern number time;
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)

#define PI 3.14159265359

// ---- level thresholds (cumulative ladder) ----
#define L_GRID    1.5
#define L_SHADOW  2.5
#define L_SUN     3.5

// ---- level 1: reflective backing ----
#define BACK_BRIGHTNESS 0.48
#define GRAIN_INTENSITY 0.065
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
#define BASE_ALPHA  0.20
#define WHITE_EXTRA 0.75
// front polarizer film tint
#define POLARIZER vec3(0.94, 1.0, 0.865)

// ---- level 2: LCD grid (lcd1x style) ----
#define BRIGHTEN_SCANLINES 16.0
#define BRIGHTEN_LCD 4.0

// ---- level 3: drop shadow ----
#define SHADOW_OFFSET 3.0
#define SHADOW_OPACITY 0.5

// ---- level 4: sunlight ----
#define GLARE_INTENSITY 0.15
#define GLARE_SIGMA 0.25
#define SHIMMER_INTENSITY 0.25
// chroma amplification so the bands read on the already-desaturated,
// backing-blended image (reference applies 0.25 to raw film reflectance)
#define SHIMMER_CHROMA_GAIN 3.0
#define SHIMMER_SPREAD 1.8
#define LIGHT_RANGE 0.6
#define FILM_NOISE_AMOUNT 0.5
#define REFLECT_FLOOR 0.03

float hash21(vec2 p)
{
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

// smooth value noise, ~[0,1]
float vnoise(vec2 p)
{
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    float a = hash21(i);
    float b = hash21(i + vec2(1.0, 0.0));
    float c = hash21(i + vec2(0.0, 1.0));
    float d = hash21(i + vec2(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float luma(vec3 c)
{
    return dot(c, vec3(0.2126, 0.7152, 0.0722));
}

vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
{
    vec4 src = Texel(tex, tc);
    if (level < 0.5) {
        return src * color;
    }

    float ps = max(pixelScale, 1.0);
    vec2 gbPix = pc / ps;                       // GB-pixel coordinates
    vec2 texel = 1.0 / love_ScreenSize.xy;      // one screen pixel in tc
    vec2 gbTexel = texel * ps;                  // one GB pixel in tc

    // Drifting light position (normalized screen coords, upper area).
    // Computed unconditionally: level 3 borrows it for shadow drift.
    vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
                         0.3 + 0.2 * sin(time * 0.07));

    // ---- level 1: procedural backing material ----
    // flat gray + 3-octave paper grain, tinted warm
    float grain = vnoise(gbPix * 0.9)  * 0.5
                + vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
                + vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
    float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
    vec3 back = backLum * POCKET_TINT;

    // ---- level 3: dark pixels cast a soft shadow onto the backing ----
    if (level >= L_SHADOW) {
        vec2 shOff = vec2(SHADOW_OFFSET);
        if (level >= L_SUN) {
            // subtle drift opposite the light's wander
            shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
        }
        vec2 so = tc - shOff * gbTexel;
        vec2 e = gbTexel;
        // 9-tap gaussian blur of the offset sample's brightness (unrolled)
        float s = 0.0;
        s += luma(Texel(tex, so).rgb) * 4.0;
        s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
        s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
        s += luma(Texel(tex, so + vec2(0.0,  e.y)).rgb) * 2.0;
        s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
        s += luma(Texel(tex, so + vec2( e.x,  e.y)).rgb) * 1.0;
        s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
        s += luma(Texel(tex, so + vec2(-e.x,  e.y)).rgb) * 1.0;
        s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
        s /= 16.0;
        float dark = 1.0 - s;
        // deadzone: near-white pixels (dark ~ 0) cast no shadow at all
        float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
        back = mix(back, back * 0.2, shadow);
    }

    // ---- level 2: LCD subpixel grid on the lit image only ----
    vec3 lit = src.rgb;
    if (level >= L_GRID) {
        vec2 angle = 2.0 * PI * (gbPix - 0.25);
        float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
                   / (BRIGHTEN_SCANLINES + 1.0);
        float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
        lit *= yfac * xfac;
    }

    // ---- level 1: brightness-proportional pixel transparency ----
    float lum = luma(src.rgb);
    float a = BASE_ALPHA * lum;
    // near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
    // fully transparent -- narrow smoothsteps stand in for the hard AND
    float mn = min(src.r, min(src.g, src.b));
    a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
    vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));

    // ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
    float glare = 0.0;
    if (level >= L_SUN) {
        float aspect = love_ScreenSize.x / love_ScreenSize.y;
        vec2 p = vec2(tc.x * aspect, tc.y);
        vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
        float d = distance(p, lp);

        // specular gaussian hotspot (added after the polarizer tint:
        // it reflects off the front glass, not the LCD)
        glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));

        // quarter-wave-plate film: effective retardance (nm) grows with
        // distance from the light point -> concentric interference bands;
        // smooth "film thickness" noise makes the bands splotchy
        float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
        float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
                       * (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
        float ph = 4.0 * PI * gammaEff;

        // 7 wavelength samples 400..700nm with approximate spectral RGB,
        // unrolled (no const arrays in GLSL 1.20)
        vec3 rb = vec3(0.0);
        float cw;
        cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
        cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
        cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
        cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
        cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
        cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
        cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
        rb /= vec3(3.05, 2.60, 1.90);   // per-channel weight sums -> peak 1.0

        // fade with distance from the light, kill on dark pixels,
        // weight by pixel color squared
        float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
        float refl = max(lum, REFLECT_FLOOR);
        // luminance-preserving tint: add only the chroma of the rainbow
        vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
                     * src.rgb * src.rgb * refl * att;
        col += shimmer * SHIMMER_INTENSITY;
    }

    // front polarizer tint, then front-surface glare on top
    col *= POLARIZER;
    col += vec3(glare);

    return vec4(col, src.a) * color;
}
]]

GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check

function GBCFX.shader()
  if not GBCFX.isSupported() then return nil end
  if shader == nil then
    local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
    shader = ok and sh or false
  end
  return shader or nil
end

function GBCFX.setLevel(level)
  if not GBCFX.isSupported() then
    GBCFX.level = 0
    return
  end
  level = math.floor(tonumber(level) or 0)
  if level < 0 then level = 0 end
  if level > 4 then level = 4 end
  GBCFX.level = level
end

-- Advance OFF → 1 → 2 → 3 → 4 → OFF.  Returns the new level.
function GBCFX.cycle()
  if not GBCFX.isSupported() then
    GBCFX.level = 0
    return 0
  end
  GBCFX.setLevel((GBCFX.level + 1) % 5)
  return GBCFX.level
end

-- Apply opts.gbcfx.  On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick.  Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts)
  if not GBCFX.isSupported() then
    local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
    if opts then opts.gbcfx = 0 end
    GBCFX.level = 0
    return had and true or false
  end
  GBCFX.setLevel(opts and opts.gbcfx or 0)
  return false
end

function GBCFX.levelLabel(level)
  return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
end

function GBCFX.active()
  return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end

-- Draw `canvas` fullscreen through the GBC FX shader into the current
-- render target (or plain if the shader is unavailable).  pixelScale is
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
-- stay window-size independent.
function GBCFX.present(canvas, pixelScale)
  local sh = GBCFX.shader()
  if not sh or GBCFX.level <= 0 then
    love.graphics.setColor(1, 1, 1, 1)
    love.graphics.draw(canvas, 0, 0)
    return
  end
  local t = 0
  if love.timer and love.timer.getTime then
    t = love.timer.getTime()
  end
  sh:send("level", GBCFX.level)
  sh:send("time", t)
  sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
  love.graphics.setShader(sh)
  love.graphics.setColor(1, 1, 1, 1)
  love.graphics.draw(canvas, 0, 0)
  love.graphics.setShader()
end

return GBCFX
