import { Color } from "./Color"
import type { Framebuffer } from "./Framebuffer"
import { Camera } from "../scene/Camera"
import { Vec3 } from "../math/Vec3"
import { Texture } from "./Texture"

/** Fields shared by every cumulus style. */
export type CumulusBase = {
  color: Color
  /** Roughly the fraction of sky covered, 0..1. */
  coverage: number
  /** Puff size: larger = smaller, busier clouds. */
  scale: number
  /** Scroll speed (wind), in noise units per second. */
  speed: number
  /** Edge softness: small = crisp cumulus rims, large = hazy. */
  edge: number
}

/** Flat, hard-thresholded white cumulus. Cheap: one noise lookup per pixel. */
export type BasicCumulus = CumulusBase & { kind: "basic" }

/** Domain-warped, heightfield-shaded cumulus with faked volume. Pricier
 *  (~5 noise lookups per pixel) but reads as billowing 3D puffs. */
export type FancyCumulus = CumulusBase & {
  kind: "fancy"
  /** Domain-warp amount: bends the noise into bulbous, cauliflower puffs.
   *  0 = round blobs, higher = more billowing. */
  warp: number
  /** Fake vertical relief for lighting: 0 = flat, higher = deeper, more
   *  three-dimensional bulges (bright sun-side, shaded underside). */
  relief: number
}

/** One procedural cloud layer. Add more styles by extending this union and
 *  branching on `kind` in the cloud shader. */
export type CloudLayer = BasicCumulus | FancyCumulus

/** An equirectangular panorama used as the sky's base color in place of the
 *  vertical gradient. The sun glow and clouds still layer over it. Set it on
 *  `SkyConfig.skybox` to switch a level over. */
export type Skybox = {
  texture: Texture
  /** Azimuth offset in turns (0..1) to spin the panorama to taste. Default 0. */
  yaw?: number
}

/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds.
 *  If `skybox` is set, the panorama replaces the gradient base while the sun and
 *  clouds still draw over it. */
export type SkyConfig = {
  zenith: Color
  horizon: Color
  sun: Color
  /** World-space direction toward the sun (need not be normalized). */
  sunDir: Vec3
  /** Angular radius of the sun's core, in radians. */
  sunSize: number
  clouds: CloudLayer | null
  /** Optional equirectangular panorama; when present, replaces the gradient
   *  base color (sun + clouds still layer over it). */
  skybox?: Skybox
}

const UP: Vec3 = { x: 0, y: 1, z: 0 }
const INV_TAU = 1 / (2 * Math.PI)
const INV_PI = 1 / Math.PI
/** Keep equirect V a hair off the exact poles: Texture wraps V, so a ray pointing
 *  straight up/down would otherwise blend the panorama's top row into its bottom. */
const POLE_EPS = 1e-3

export namespace Sky {
  /**
   * Fill the whole framebuffer with the sky and reset depth to 0. Run first each
   * frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
   * wherever it is nearer. `time` (seconds) drives cloud motion.
   *
   * Per pixel it reconstructs the view ray from the camera basis, shades the
   * base (the equirect panorama if `sky.skybox` is set, else a horizon->zenith
   * gradient), brightens toward `sun` near `sunDir`, then composites cumulus
   * over the top.
   *
   * The base + sun are shaded per pixel so they stay crisp. `step` (>= 1) only
   * lowers the *cloud* resolution: the cloud fbm dominates the frame, so it is
   * sampled once per step x step block and composited across it. Clouds are
   * low-frequency, so 2 is nearly free visually and quarters the cloud cost; 1
   * is full cloud resolution.
   */
  export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
    const { width, height, color, depth } = fb
    const bottom = y1 < 0 ? height : y1
    const forward = Camera.forward(camera)
    const right = Vec3.normalize(Vec3.cross(forward, UP))
    const up = Vec3.cross(right, forward)
    const tanY = Math.tan(camera.fov / 2)
    const tanX = tanY * (width / height)
    const sun = Vec3.normalize(sky.sunDir)
    const cosSun = Math.cos(sky.sunSize)
    const clouds = sky.clouds
    const skybox = sky.skybox ?? null
    const skyboxYaw = skybox?.yaw ?? 0
    const cloud: CloudSample = { cover: 0, shade: 1 }
    const s = Math.max(1, step | 0)
    // The cheap base (skybox/gradient + sun) is shaded per pixel so it stays
    // crisp; only the pricey cloud fbm is amortized -- sampled once per `s`x`s`
    // block and composited over every pixel in it. So `step` lowers cloud
    // resolution, not the whole sky. Bands must be step-aligned (callers ensure
    // it) so the cloud block grid stays global and neighboring bands don't seam.
    for (let by = y0; by < bottom; by += s) {
      const yEnd = Math.min(bottom, by + s)
      // Block-center elevation, used only for the shared cloud sample.
      const sampleY = Math.min(height - 1, by + (s >> 1))
      const ndcYc = 1 - ((sampleY + 0.5) / height) * 2
      for (let bx = 0; bx < width; bx += s) {
        const xEnd = Math.min(width, bx + s)
        // Sample the clouds once for the block, from the block-center ray.
        const sampleX = Math.min(width - 1, bx + (s >> 1))
        const ndcXc = ((sampleX + 0.5) / width) * 2 - 1
        let cdx = forward.x + right.x * ndcXc * tanX + up.x * ndcYc * tanY
        let cdy = forward.y + right.y * ndcXc * tanX + up.y * ndcYc * tanY
        let cdz = forward.z + right.z * ndcXc * tanX + up.z * ndcYc * tanY
        const cinv = 1 / Math.hypot(cdx, cdy, cdz)
        cdx *= cinv
        cdy *= cinv
        cdz *= cinv
        cloud.cover = 0
        cloud.shade = 1
        if (clouds !== null && cdy > 0.02) {
          if (clouds.kind === "fancy") {
            fancyCumulus(cdx, cdy, cdz, clouds, time, sun, cloud)
          } else {
            basicCumulus(cdx, cdy, cdz, clouds, time, cloud)
          }
        }
        const cover = cloud.cover
        const cloudColor = clouds !== null ? Color.scale(clouds.color, cloud.shade) : 0
        // Per-pixel base: reconstruct this pixel's ray, shade the panorama (or
        // gradient) + sun, then composite the block's shared cloud on top.
        for (let y = by; y < yEnd; y++) {
          const ndcY = 1 - ((y + 0.5) / height) * 2
          const o = y * width
          for (let x = bx; x < xEnd; x++) {
            const ndcX = ((x + 0.5) / width) * 2 - 1
            let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
            let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
            let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
            const inv = 1 / Math.hypot(dx, dy, dz)
            dx *= inv
            dy *= inv
            dz *= inv
            let c: Color
            if (skybox !== null) {
              // Equirectangular lookup: azimuth -> u (wraps at the seam, which
              // Texture.sample handles), elevation -> v, clamped off the poles
              // (Texture also wraps V, which would smear top into bottom).
              const u = Math.atan2(dx, -dz) * INV_TAU + 0.5 + skyboxYaw
              const lat = Math.acos(Math.max(-1, Math.min(1, dy))) * INV_PI
              const v = Math.min(1 - POLE_EPS, Math.max(POLE_EPS, lat))
              c = Texture.sample(skybox.texture, u, v, "linear")
            } else {
              // dy is the ray elevation: 0 at the horizon, 1 straight up.
              c = Color.lerp(sky.horizon, sky.zenith, Math.max(0, Math.min(1, dy)))
            }
            const facing = dx * sun.x + dy * sun.y + dz * sun.z
            if (facing > cosSun) {
              const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
              c = Color.lerp(c, sky.sun, glow)
            }
            if (cover > 0) {
              c = Color.lerp(c, cloudColor, cover)
            }
            color[o + x] = c
            depth[o + x] = 0
          }
        }
      }
    }
  }

  /** Reusable per-pixel cloud result, to avoid allocating in the sky loop. */
  type CloudSample = { cover: number; shade: number }

  /** Hard-threshold a noise density into cloud coverage (distinct puffy edges),
   *  then fade it out near the horizon where the cloud-plane projection breaks
   *  down. Shared by both cumulus styles. */
  function coverage(dy: number, layer: CumulusBase, density: number): number {
    const threshold = 0.72 - layer.coverage * 0.4
    return smoothstep(threshold - layer.edge, threshold + layer.edge, density) * smoothstep(0.02, 0.22, dy)
  }

  /** basicCumulus -- flat, hard-thresholded white puffs, no lighting. Cheap:
   *  one noise lookup per pixel. `shade` stays 1 (uniform white). */
  function basicCumulus(dx: number, dy: number, dz: number, layer: CloudLayer, time: number, out: CloudSample): void {
    const u = (dx / dy) * layer.scale + time * layer.speed
    const v = (dz / dy) * layer.scale
    out.cover = coverage(dy, layer, fbm(u, v))
    out.shade = 1
  }

  /** fancyCumulus -- domain-warped clouds with faked volume. The noise doubles
   *  as a heightfield whose gradient is a fake surface normal, lit so up-facing
   *  tops read bright and steep bulge sides shade into shadow (the sun picks the
   *  lit side). ~5 noise lookups per pixel, so noticeably pricier. */
  function fancyCumulus(dx: number, dy: number, dz: number, layer: FancyCumulus, time: number, sun: Vec3, out: CloudSample): void {
    const u = (dx / dy) * layer.scale + time * layer.speed
    const v = (dz / dy) * layer.scale
    // Domain warp: nudge the sample point by another noise field for bulges.
    const wu = u + layer.warp * fbm(u * 0.5 + 5.2, v * 0.5 + 1.3)
    const wv = v + layer.warp * fbm(u * 0.5 + 9.1, v * 0.5 + 4.7)
    const density = fbm(wu, wv)
    out.cover = coverage(dy, layer, density)
    if (out.cover <= 0) {
      return
    }
    // Treat density as height; its gradient is a fake surface normal (nx, ny, 1).
    const e = 0.15
    const nx = -(fbm(wu + e, wv) - density) * layer.relief
    const ny = -(fbm(wu, wv + e) - density) * layer.relief
    const inv = 1 / Math.hypot(nx, ny, 1)
    // Up-facing tops read bright; steep bulge sides fall into shadow, and the
    // sun (mapped x -> u, z -> v, y -> up) picks out the lit side.
    const up = inv
    const sunFace = Math.max(0, (nx * sun.x + ny * sun.z + sun.y) * inv)
    out.shade = Math.min(1, 0.4 + 0.35 * up + 0.35 * sunFace)
  }

  /** Fractal (value-noise) sum, ~0..1, giving lumpy cumulus shapes. */
  function fbm(x: number, y: number): number {
    let sum = 0
    let amplitude = 0.5
    let frequency = 1
    for (let octave = 0; octave < 4; octave++) {
      sum += amplitude * valueNoise(x * frequency, y * frequency)
      frequency *= 2
      amplitude *= 0.5
    }
    return sum
  }

  function valueNoise(x: number, y: number): number {
    const xi = Math.floor(x)
    const yi = Math.floor(y)
    const xf = x - xi
    const yf = y - yi
    const u = xf * xf * (3 - 2 * xf)
    const v = yf * yf * (3 - 2 * yf)
    const a = hash(xi, yi)
    const b = hash(xi + 1, yi)
    const c = hash(xi, yi + 1)
    const d = hash(xi + 1, yi + 1)
    return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
  }

  /** Deterministic 0..1 hash of an integer lattice point. */
  function hash(x: number, y: number): number {
    let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263)) | 0
    h = Math.imul(h ^ (h >>> 13), 1274126177)
    return ((h ^ (h >>> 16)) >>> 0) / 4294967295
  }

  function smoothstep(a: number, b: number, x: number): number {
    const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
    return t * t * (3 - 2 * t)
  }
}
