import { Color } from "./Color"
import type { Framebuffer } from "./Framebuffer"
import type { Fog, RenderConfig } from "./RenderConfig"
import { Texture } from "./Texture"
import type { Mat4 } from "../math/Mat4"
import { STRIDE, type Mesh } from "../scene/Mesh"

/** Anything with w below this is treated as behind the camera and clipped. */
const NEAR_W = 0.01
/** Fixed world-space directional light (normalized components). */
const LIGHT_LEN = Math.hypot(0.4, 1, 0.35)
const LIGHT_X = 0.4 / LIGHT_LEN
const LIGHT_Y = 1 / LIGHT_LEN
const LIGHT_Z = 0.35 / LIGHT_LEN
const AMBIENT = 0.4
const DIFFUSE = 0.6

/** Floats per clip-space vertex in the scratch buffers: x, y, w, u, v (clip z is
 *  unused, so it is dropped). */
const CLIP = 5
/** Reused per-triangle scratch: the 3 projected verts (`src`) and the near-clip
 *  result (`dst`, up to 4 verts). Module-level so the hot path never allocates.
 *  Safe because a triangle is fully processed before the next one starts. */
const src = new Float64Array(3 * CLIP)
const dst = new Float64Array(4 * CLIP)

/**
 * Software triangle rasterizer — the heart of the PS1 look.
 *
 * Per triangle the pipeline is: transform to clip space, clip against the near
 * plane, perspective-divide to screen pixels (optionally snapping vertices to a
 * grid), then fill with an edge-function / barycentric scan. Per pixel it
 * interpolates depth as 1/w, perspective-correct texture coords, and applies
 * flat shading plus distance fog.
 *
 * Meshes are stored flat (see `Mesh`) and the whole per-triangle path works in
 * reused scratch buffers, so drawing allocates nothing — no GC churn, no frame
 * spikes. `cull` enables backface culling for solid, consistently-wound meshes.
 *
 * The period-accurate rough edges are deliberate, not unfinished: no mipmaps
 * (so distant textures shimmer/moire) and no antialiasing (jagged silhouettes).
 */
export namespace Rasterizer {
  /** Draw an indexed mesh into the framebuffer through a view-projection matrix.
   *  Shading is flat (one normal per face), computed once per triangle. `cull`
   *  drops back-facing triangles (default off = double-sided). */
  export function draw(
    fb: Framebuffer,
    mesh: Mesh,
    texture: Texture,
    viewProj: Mat4,
    config: RenderConfig,
    cull = false,
    clipY0 = 0,
    clipY1 = 1 << 30,
  ): void {
    const { verts, indices } = mesh
    const flat = config.lighting === "flat"
    for (let t = 0; t + 2 < indices.length; t += 3) {
      const o0 = indices[t] * STRIDE
      const o1 = indices[t + 1] * STRIDE
      const o2 = indices[t + 2] * STRIDE
      const shade = flat ? flatShade(verts, o0, o1, o2) : 1
      project(viewProj, verts, o0, 0)
      project(viewProj, verts, o1, CLIP)
      project(viewProj, verts, o2, CLIP * 2)
      // Near-clipping can turn one triangle into a quad; fan it back to tris.
      const n = clipNear(3)
      for (let k = 1; k + 1 < n; k++) {
        fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull, clipY0, clipY1)
      }
    }
  }

  /** Transform vertex `o` of `verts` by `m` into clip space, written to `src` at
   *  `out`. Only x, y, w are needed (z is unused); the matrix multiply is inlined
   *  to avoid allocating a result object. */
  function project(m: Mat4, verts: number[], o: number, out: number): void {
    const x = verts[o]
    const y = verts[o + 1]
    const z = verts[o + 2]
    src[out] = m[0] * x + m[4] * y + m[8] * z + m[12]
    src[out + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
    src[out + 2] = m[3] * x + m[7] * y + m[11] * z + m[15]
    src[out + 3] = verts[o + 3]
    src[out + 4] = verts[o + 4]
  }

  /** Flat (per-face) directional shade in 0..1: ambient plus diffuse from the
   *  face normal (cross of two edges). `abs()` makes it two-sided so back-facing
   *  tris still light. Reads positions straight from the flat vertex array. */
  function flatShade(verts: number[], o0: number, o1: number, o2: number): number {
    const ax = verts[o0]
    const ay = verts[o0 + 1]
    const az = verts[o0 + 2]
    const e1x = verts[o1] - ax
    const e1y = verts[o1 + 1] - ay
    const e1z = verts[o1 + 2] - az
    const e2x = verts[o2] - ax
    const e2y = verts[o2 + 1] - ay
    const e2z = verts[o2 + 2] - az
    const nx = e1y * e2z - e1z * e2y
    const ny = e1z * e2x - e1x * e2z
    const nz = e1x * e2y - e1y * e2x
    const len = Math.hypot(nx, ny, nz)
    if (len === 0) {
      return AMBIENT
    }
    const d = Math.abs((nx * LIGHT_X + ny * LIGHT_Y + nz * LIGHT_Z) / len)
    return Math.min(1, AMBIENT + DIFFUSE * d)
  }

  /**
   * Clip the `count`-vertex polygon in `src` against the camera plane (w =
   * NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
   * 4 verts) to `dst` and returning its vertex count.
   *
   * This matters even when standing inside the room: a wall to your side has
   * vertices both in front of and behind the eye. Without clipping, the behind
   * vertices have w <= 0 and invert under the perspective divide, smearing the
   * triangle across the whole screen (and risking divide-by-zero).
   */
  function clipNear(count: number): number {
    let out = 0
    for (let i = 0; i < count; i++) {
      const ci = i * CLIP
      const pi = ((i + count - 1) % count) * CLIP
      const curW = src[ci + 2]
      const prevW = src[pi + 2]
      const curIn = curW >= NEAR_W
      const prevIn = prevW >= NEAR_W
      // Crossing the plane emits the intersection point before the inside one.
      if (curIn !== prevIn) {
        const t = (NEAR_W - prevW) / (curW - prevW)
        const o = out * CLIP
        dst[o] = src[pi] + (src[ci] - src[pi]) * t
        dst[o + 1] = src[pi + 1] + (src[ci + 1] - src[pi + 1]) * t
        dst[o + 2] = prevW + (curW - prevW) * t
        dst[o + 3] = src[pi + 3] + (src[ci + 3] - src[pi + 3]) * t
        dst[o + 4] = src[pi + 4] + (src[ci + 4] - src[pi + 4]) * t
        out++
      }
      if (curIn) {
        const o = out * CLIP
        dst[o] = src[ci]
        dst[o + 1] = src[ci + 1]
        dst[o + 2] = curW
        dst[o + 3] = src[ci + 3]
        dst[o + 4] = src[ci + 4]
        out++
      }
    }
    return out
  }

  /**
   * Scan-convert one clip-space triangle (verts `ia`, `ib`, `ic` in `dst`).
   *
   * Vertex snap: real PS1 hardware transformed vertices in low-precision fixed
   * point, so screen positions popped between pixels as the camera moved (the
   * trademark "vertex wobble"). We emulate it by snapping to a `snap`-pixel grid.
   */
  function fillTriangle(
    fb: Framebuffer,
    ia: number,
    ib: number,
    ic: number,
    shade: number,
    texture: Texture,
    config: RenderConfig,
    cull: boolean,
    clipY0: number,
    clipY1: number,
  ): void {
    const oa = ia * CLIP
    const ob = ib * CLIP
    const oc = ic * CLIP
    const width = fb.width
    const height = fb.height
    const snap = config.vertexSnap
    const invWa = 1 / dst[oa + 2]
    const invWb = 1 / dst[ob + 2]
    const invWc = 1 / dst[oc + 2]
    let sxA = (dst[oa] * invWa * 0.5 + 0.5) * width
    let syA = (1 - (dst[oa + 1] * invWa * 0.5 + 0.5)) * height
    let sxB = (dst[ob] * invWb * 0.5 + 0.5) * width
    let syB = (1 - (dst[ob + 1] * invWb * 0.5 + 0.5)) * height
    let sxC = (dst[oc] * invWc * 0.5 + 0.5) * width
    let syC = (1 - (dst[oc + 1] * invWc * 0.5 + 0.5)) * height
    if (snap > 0) {
      sxA = Math.round(sxA / snap) * snap
      syA = Math.round(syA / snap) * snap
      sxB = Math.round(sxB / snap) * snap
      syB = Math.round(syB / snap) * snap
      sxC = Math.round(sxC / snap) * snap
      syC = Math.round(syC / snap) * snap
    }
    // Signed area x2; its sign is the screen winding.
    const area = (sxB - sxA) * (syC - syA) - (syB - syA) * (sxC - sxA)
    if (area === 0) {
      return
    }
    // Backface cull: a back-facing triangle has positive area here. Only for
    // solid, consistently-wound meshes; sprites/room stay double-sided.
    if (cull && area > 0) {
      return
    }
    const invArea = 1 / area
    const uA = dst[oa + 3]
    const vA = dst[oa + 4]
    const uB = dst[ob + 3]
    const vB = dst[ob + 4]
    const uC = dst[oc + 3]
    const vC = dst[oc + 4]
    const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC)))
    const maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC)))
    // Clamp to the caller's Y-band (default full frame) so worker threads can
    // each fill a disjoint slice of rows without ever touching the same pixel.
    const minY = Math.max(0, clipY0, Math.floor(Math.min(syA, syB, syC)))
    const maxY = Math.min(height - 1, clipY1 - 1, Math.ceil(Math.max(syA, syB, syC)))
    // Edge deltas for the three barycentric edge functions (b->c, c->a, a->b).
    const dx0 = sxC - sxB
    const dy0 = syC - syB
    const dx1 = sxA - sxC
    const dy1 = syA - syC
    const dx2 = sxB - sxA
    const dy2 = syB - syA
    const fog = config.fog
    const filter = config.textureFilter
    const color = fb.color
    const depth = fb.depth
    for (let y = minY; y <= maxY; y++) {
      const py = y + 0.5
      const rowStart = y * width
      for (let x = minX; x <= maxX; x++) {
        const px = x + 0.5
        // Barycentric weights, normalized so they sum to 1. Dividing by a signed
        // area accepts either winding.
        const w0 = (dx0 * (py - syB) - dy0 * (px - sxB)) * invArea
        if (w0 < 0) {
          continue
        }
        const w1 = (dx1 * (py - syC) - dy1 * (px - sxC)) * invArea
        if (w1 < 0) {
          continue
        }
        const w2 = (dx2 * (py - syA) - dy2 * (px - sxA)) * invArea
        if (w2 < 0) {
          continue
        }
        // 1/w interpolates linearly in screen space. Larger = nearer.
        const invW = w0 * invWa + w1 * invWb + w2 * invWc
        const idx = rowStart + x
        if (invW <= depth[idx]) {
          continue
        }
        // Perspective-correct texture coords: divide interpolated u/w, v/w by 1/w
        // to undo foreshortening, so textures sit flat on receding surfaces.
        const u = (w0 * uA * invWa + w1 * uB * invWb + w2 * uC * invWc) / invW
        const v = (w0 * vA * invWa + w1 * vB * invWb + w2 * vC * invWc) / invW
        // Alpha cutout: discard transparent texels so sprites read as cutouts.
        const texel = Texture.sample(texture, u, v, filter)
        if (Color.a(texel) < 128) {
          continue
        }
        color[idx] = fog === null ? Color.scale(texel, shade) : shadeFog(texel, shade, fog, invW)
        depth[idx] = invW
      }
    }
  }

  /** Shade a texel then fade it toward the fog color by view-space distance. */
  function shadeFog(texel: Color, shade: number, fog: Fog, invW: number): Color {
    const dist = 1 / invW
    const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near)))
    return Color.lerp(fog.color, Color.scale(texel, shade), f)
  }
}
