<script lang="ts" module>
  // Cached dither result for the current source/size, so a theme change only
  // re-paints (cheap) instead of re-running the error-diffusion pass.
  export interface DitherCache {
    key: string
    bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
    alpha: Uint8Array
    w: number
    h: number
  }
</script>

<script lang="ts">
  import { onMount } from 'svelte'

  let {
    src,
    alt = '',
    width = 256,
    class: className = '',
    plain = false,
    bias = 0
  }: {
    src: string
    alt?: string
    /** CSS display width in px. The image is dithered at this resolution. */
    width?: number
    class?: string
    /** Skip dithering; render the crisp source <img> instead. */
    plain?: boolean
    /** -255..255. Positive → more pixels resolve to ink (foreground). */
    bias?: number
  } = $props()

  let canvas = $state<HTMLCanvasElement | null>(null)
  let imgEl = $state<HTMLImageElement | null>(null)
  let loaded = $state(false)
  let tainted = $state(false)
  let cache: DitherCache | null = null

  const showSkeleton = $derived(!loaded)
  const showCanvas = $derived(loaded && !plain && !tainted)

  function readRgb(varName: string): [number, number, number] {
    const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
    const m = raw.match(/#([0-9a-fA-F]{6})/)
    const hex = m ? m[1] : '000000'
    return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
  }

  function paint() {
    if (!cache || !canvas) return
    const ctx = canvas.getContext('2d')
    if (!ctx) return
    const { bits, alpha, w, h } = cache
    canvas.width = w
    canvas.height = h
    // dark source (ink) → foreground; light source (paper) → background
    const fg = readRgb('--foreground')
    const bg = readRgb('--background')
    const out = ctx.createImageData(w, h)
    const d = out.data
    for (let p = 0, i = 0; p < w * h; p++, i += 4) {
      if (alpha[p] < 64) {
        d[i + 3] = 0
        continue
      }
      const c = bits[p] ? bg : fg
      d[i] = c[0]
      d[i + 1] = c[1]
      d[i + 2] = c[2]
      d[i + 3] = 255
    }
    ctx.putImageData(out, 0, 0)
  }

  function process(img: HTMLImageElement) {
    const nw = img.naturalWidth || img.width
    const nh = img.naturalHeight || img.height
    if (!nw || !nh) return
    const w = Math.max(1, Math.round(width))
    const h = Math.max(1, Math.round((w * nh) / nw))
    const off = document.createElement('canvas')
    off.width = w
    off.height = h
    const octx = off.getContext('2d', { willReadFrequently: true })
    if (!octx) return
    octx.drawImage(img, 0, 0, w, h)
    let data: Uint8ClampedArray
    try {
      data = octx.getImageData(0, 0, w, h).data
    } catch {
      tainted = true
      return
    }
    const n = w * h
    const lum = new Float32Array(n)
    const alpha = new Uint8Array(n)
    for (let p = 0, i = 0; p < n; p++, i += 4) {
      lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
      alpha[p] = data[i + 3]
    }
    const thr = 128 - bias
    const bits = new Uint8Array(n)
    // Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
    // error. Softer & more "screen-printed" than Floyd–Steinberg.
    for (let y = 0; y < h; y++) {
      for (let x = 0; x < w; x++) {
        const idx = y * w + x
        const oldv = lum[idx]
        const newv = oldv < thr ? 0 : 255
        bits[idx] = newv === 255 ? 1 : 0
        const e = (oldv - newv) / 8
        if (x + 1 < w) lum[idx + 1] += e
        if (x + 2 < w) lum[idx + 2] += e
        if (y + 1 < h) {
          if (x - 1 >= 0) lum[idx + w - 1] += e
          lum[idx + w] += e
          if (x + 1 < w) lum[idx + w + 1] += e
        }
        if (y + 2 < h) lum[idx + 2 * w] += e
      }
    }
    cache = { key: src + w, bits, alpha, w, h }
    tainted = false
    paint()
  }

  function onLoad() {
    loaded = true
  }

  // Re-dither when the source image, target width, or plain flag changes.
  $effect(() => {
    void src
    void width
    void plain
    if (!loaded || !imgEl || plain || tainted) return
    if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
  })

  onMount(() => {
    // Theme flip changes --foreground/--background on <html>'s class; re-paint
    // the cached bits with the new palette (no re-dither needed).
    const mo = new MutationObserver(() => {
      if (cache && !plain) paint()
    })
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
    return () => mo.disconnect()
  })
</script>

<div class="raster-wrap {className}" style="--rw:{width}px">
  {#if showSkeleton}
    <div class="raster-skeleton" aria-hidden="true"></div>
  {/if}
  {#if !plain}
    <canvas
      bind:this={canvas}
      class="raster-canvas"
      class:hidden={!showCanvas}
      role="img"
      aria-label={alt}
    ></canvas>
  {/if}
  <img
    bind:this={imgEl}
    {src}
    {alt}
    class="raster-fallback"
    class:hidden={showCanvas}
    onload={onLoad}
    onerror={() => {
      tainted = true
      loaded = true
    }}
  />
</div>

<style>
  .raster-wrap {
    display: inline-block;
    position: relative;
    width: var(--rw);
    line-height: 0;
  }
  .raster-canvas,
  .raster-fallback {
    display: block;
    width: 100%;
    height: auto;
    image-rendering: pixelated;
  }
  .raster-canvas.hidden,
  .raster-fallback.hidden {
    display: none;
  }
  /* No-flash placeholder while the source decodes — matches cyberspace's
     raster-image-skeleton (empty, fills with the theme background). */
  .raster-skeleton {
    width: 100%;
    min-height: calc(var(--rw) * 0.6);
    aspect-ratio: 1 / 1;
    background: var(--background);
  }
</style>
