/**
 * PaintSkin — the core invention. A Canvas-2D colour mask painted onto a mesh's
 * UVs, wrapped in a THREE.CanvasTexture that becomes the body's material.map over
 * the white base. Splats are stamped WHERE the projectile hit (raycast → face UV),
 * and per-colour coverage is an exact integer histogram of a quantized mask.
 *
 * Two coupled representations, generated from the SAME set of sub-circles per
 * splat so they always agree spatially:
 *   - `canvas`  : soft-edged, irregular, pretty — this is what you SEE.
 *   - `mask`    : a Uint8Array of palette indices (0 = base, 1..7 = colour),
 *                 hard-edged — this is what we COUNT. Quantized on stamp, so
 *                 coverage() is exact bucketing, never a fuzzy colour match, and
 *                 we NEVER call getImageData (the real per-frame perf killer).
 */
import * as THREE from 'three'
import type { CoverageReport, PaintColor, PaintSkin as IPaintSkin } from '../contracts'
import { BASE_WHITE, PALETTE } from '../contracts'
import {
  COLOR_ORDER,
  colorIndex,
  countBuckets,
  coverageReportFromCounts,
} from './coverage-math'

export interface PaintSkinOptions {
  /** Mask/texture resolution (square). MVP default 256. */
  size?: number
  /**
   * How many canvas pixels one world-unit of splat radius maps to, expressed as
   * a fraction of the texture width per (radius / boundingRadius). Tune for splat
   * size on screen; exact value is cosmetic (coverage is area-based regardless).
   */
  splatScale?: number
}

interface SubCircle {
  x: number
  y: number
  r: number
}

export class PaintSkin implements IPaintSkin {
  readonly canvas: HTMLCanvasElement
  readonly texture: THREE.CanvasTexture
  readonly boundingRadius: number

  private readonly mesh: THREE.Mesh
  private readonly ctx: CanvasRenderingContext2D
  private readonly size: number
  private readonly splatScale: number
  private readonly mask: Uint8Array
  private readonly raycaster = new THREE.Raycaster()

  // 250ms coverage cache (never recount more often than that).
  private cache: CoverageReport | null = null
  private cacheTime = -1e9

  // scratch vectors (avoid per-splat allocation)
  private readonly _center = new THREE.Vector3()
  private readonly _dir = new THREE.Vector3()
  private readonly _origin = new THREE.Vector3()

  constructor(mesh: THREE.Mesh, opts: PaintSkinOptions = {}) {
    this.mesh = mesh
    this.size = opts.size ?? 256
    this.splatScale = opts.splatScale ?? 0.16

    mesh.geometry.computeBoundingSphere()
    this.boundingRadius = mesh.geometry.boundingSphere?.radius ?? 1

    const canvas = document.createElement('canvas')
    canvas.width = canvas.height = this.size
    const ctx = canvas.getContext('2d')
    if (!ctx) throw new Error('PaintSkin: 2D canvas context unavailable')
    ctx.fillStyle = BASE_WHITE
    ctx.fillRect(0, 0, this.size, this.size)
    this.canvas = canvas
    this.ctx = ctx

    this.mask = new Uint8Array(this.size * this.size) // 0 everywhere = all base

    const tex = new THREE.CanvasTexture(canvas)
    tex.colorSpace = THREE.SRGBColorSpace
    tex.wrapS = THREE.RepeatWrapping // U seam wraps (sphere longitude)
    tex.wrapT = THREE.ClampToEdgeWrapping
    tex.anisotropy = 4
    this.texture = tex

    // Attach as the base colour map. Keep material colour white so the map isn't
    // tinted; the canvas already carries the white base.
    const mat = mesh.material as THREE.MeshStandardMaterial
    mat.map = tex
    if (mat.color) mat.color.set('#ffffff')
    mat.needsUpdate = true
  }

  // ---- public API (contracts.PaintSkin) -----------------------------------

  applySplat(uv: THREE.Vector2, color: PaintColor, radius: number): void {
    const px = uv.x * this.size
    // CanvasTexture default flipY=true: uv v=0 samples canvas bottom row.
    const py = (1 - uv.y) * this.size
    const pr = this.pixelRadius(radius)
    const circles = this.makeSplatCircles(px, py, pr)
    this.stampMask(circles, colorIndex(color))
    this.stampCanvas(circles, PALETTE[color])
    this.texture.needsUpdate = true
  }

  splatAtPoint(worldPoint: THREE.Vector3, color: PaintColor, radius: number): void {
    const uv = this.uvAtWorldPoint(worldPoint)
    if (uv) this.applySplat(uv, color, radius)
  }

  coverage(): CoverageReport {
    const now = performance.now()
    if (this.cache && now - this.cacheTime < 250) return this.cache
    const counts = countBuckets(this.mask, COLOR_ORDER.length)
    this.cache = coverageReportFromCounts(counts, this.mask.length)
    this.cacheTime = now
    return this.cache
  }

  cleanse(fraction: number): void {
    const f = Math.max(0, Math.min(1, fraction))
    if (f <= 0) return

    if (f >= 1) {
      // Full clean: reset everything to base.
      this.mask.fill(0)
      this.ctx.fillStyle = BASE_WHITE
      this.ctx.fillRect(0, 0, this.size, this.size)
      this.invalidate()
      return
    }

    // Partial: punch random circular "scrub" holes until ~fraction of the
    // currently-painted pixels are erased. Looks scrubbed rather than uniform.
    let painted = 0
    for (let i = 0; i < this.mask.length; i++) if (this.mask[i] !== 0) painted++
    if (painted === 0) return
    let target = Math.floor(painted * f)

    this.ctx.fillStyle = BASE_WHITE
    let guard = 0
    while (target > 0 && guard++ < 20000) {
      const cx = Math.random() * this.size
      const cy = Math.random() * this.size
      const r = 6 + Math.random() * 12
      target -= this.eraseCircle(cx, cy, r)
    }
    this.invalidate()
  }

  // ---- geometry: world point -> UV ----------------------------------------

  private uvAtWorldPoint(worldPoint: THREE.Vector3): THREE.Vector2 | null {
    this.mesh.updateWorldMatrix(true, false)
    this.mesh.getWorldPosition(this._center)

    this._dir.copy(worldPoint).sub(this._center)
    if (this._dir.lengthSq() < 1e-12) return null
    this._dir.normalize()

    // Cast from just outside the bounding sphere, straight through the centre.
    // The near-surface hit is the point closest to the impact; THREE gives us the
    // barycentric-interpolated UV for free.
    const worldRadius = this.worldBoundingRadius()
    this._origin.copy(this._center).addScaledVector(this._dir, worldRadius * 2.2)
    this.raycaster.set(this._origin, this._dir.clone().negate())
    this.raycaster.near = 0
    this.raycaster.far = worldRadius * 4.4
    const hits = this.raycaster.intersectObject(this.mesh, false)
    const hit = hits.find((h) => h.uv)
    return hit?.uv ? hit.uv.clone() : null
  }

  private worldBoundingRadius(): number {
    const s = this.mesh.getWorldScale(this._origin) // reuse scratch
    return this.boundingRadius * Math.max(s.x, s.y, s.z)
  }

  private pixelRadius(radius: number): number {
    const px = (radius / this.boundingRadius) * this.size * this.splatScale
    return Math.max(4, Math.min(this.size * 0.4, px))
  }

  // ---- splat rasterization (mask + canvas share the same circles) ----------

  private makeSplatCircles(px: number, py: number, pr: number): SubCircle[] {
    const circles: SubCircle[] = [{ x: px, y: py, r: pr * 0.95 }]
    // irregular lobes
    const lobes = 3 + Math.floor(Math.random() * 3)
    for (let i = 0; i < lobes; i++) {
      const a = Math.random() * Math.PI * 2
      const d = pr * (0.35 + Math.random() * 0.55)
      circles.push({
        x: px + Math.cos(a) * d,
        y: py + Math.sin(a) * d,
        r: pr * (0.3 + Math.random() * 0.35),
      })
    }
    // stray droplets
    const drops = 2 + Math.floor(Math.random() * 3)
    for (let i = 0; i < drops; i++) {
      const a = Math.random() * Math.PI * 2
      const d = pr * (1.0 + Math.random() * 0.7)
      circles.push({
        x: px + Math.cos(a) * d,
        y: py + Math.sin(a) * d,
        r: pr * (0.08 + Math.random() * 0.14),
      })
    }
    return circles
  }

  /** Hard-edged union of circles into the quantized mask, with U-seam wrap. */
  private stampMask(circles: SubCircle[], idx: number): void {
    const S = this.size
    for (const c of circles) {
      // Wrap in X (longitude seam). Draw the circle and, if near an edge, its
      // wrapped twin so a stamp straddling u=0/1 lands on both sides.
      const offsets = this.wrapOffsets(c.x, c.r, S)
      for (const ox of offsets) {
        const cx = c.x + ox
        const r2 = c.r * c.r
        const x0 = Math.max(0, Math.floor(cx - c.r))
        const x1 = Math.min(S - 1, Math.ceil(cx + c.r))
        const y0 = Math.max(0, Math.floor(c.y - c.r))
        const y1 = Math.min(S - 1, Math.ceil(c.y + c.r))
        for (let y = y0; y <= y1; y++) {
          const dy = y - c.y
          for (let x = x0; x <= x1; x++) {
            const dx = x - cx
            if (dx * dx + dy * dy <= r2) this.mask[y * S + x] = idx
          }
        }
      }
    }
  }

  /** Soft, alpha-blended gradient blobs into the visible canvas, with wrap. */
  private stampCanvas(circles: SubCircle[], hex: string): void {
    const S = this.size
    const ctx = this.ctx
    const rgb = hexToRgb(hex)
    for (const c of circles) {
      const offsets = this.wrapOffsets(c.x, c.r, S)
      for (const ox of offsets) {
        const cx = c.x + ox
        const g = ctx.createRadialGradient(cx, c.y, c.r * 0.15, cx, c.y, c.r)
        g.addColorStop(0, `rgba(${rgb},1)`)
        g.addColorStop(0.7, `rgba(${rgb},0.95)`)
        g.addColorStop(1, `rgba(${rgb},0)`)
        ctx.fillStyle = g
        ctx.beginPath()
        ctx.arc(cx, c.y, c.r, 0, Math.PI * 2)
        ctx.fill()
      }
    }
  }

  /** Erase a scrub hole from both mask and canvas; returns pixels erased. */
  private eraseCircle(cx: number, cy: number, r: number): number {
    const S = this.size
    let erased = 0
    const r2 = r * r
    const x0 = Math.max(0, Math.floor(cx - r))
    const x1 = Math.min(S - 1, Math.ceil(cx + r))
    const y0 = Math.max(0, Math.floor(cy - r))
    const y1 = Math.min(S - 1, Math.ceil(cy + r))
    for (let y = y0; y <= y1; y++) {
      const dy = y - cy
      for (let x = x0; x <= x1; x++) {
        const dx = x - cx
        if (dx * dx + dy * dy <= r2) {
          const i = y * S + x
          if (this.mask[i] !== 0) {
            this.mask[i] = 0
            erased++
          }
        }
      }
    }
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, r, 0, Math.PI * 2)
    this.ctx.fill() // fillStyle already BASE_WHITE at call sites
    return erased
  }

  private wrapOffsets(x: number, r: number, S: number): number[] {
    if (x - r < 0) return [0, S]
    if (x + r > S) return [0, -S]
    return [0]
  }

  private invalidate(): void {
    this.texture.needsUpdate = true
    this.cache = null
    this.cacheTime = -1e9
  }
}

function hexToRgb(hex: string): string {
  const h = hex.replace('#', '')
  const n = parseInt(h, 16)
  return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
}
