/**
 * La Cale — where the cargo gets checked before it goes on stage.
 *
 * The Foundry cuts sample kits; nothing until now could tell you whether they were any
 * good. A grade in a manifest is a claim, and the claim has been wrong in interesting
 * ways (a fade-out graded S, and then 24 loops of pure silence graded A) — so the
 * kit has to be *heard*, at tempo, against other loops, in a place where a bad one can
 * be noticed in two seconds rather than mid-set.
 *
 * Three things, in the order you need them:
 *   1. **browse** — every kit under the sample root, filtered by family, tier, mode
 *   2. **audition** — one sample, untouched or looped (see `SampleRow`)
 *   3. **rack** — several at once on one bar grid, exported as a `.tidal` block
 *
 * The index is generated by `tools/foundry/kitindex.py`; the header shows when, because
 * a generated file that is never compared against its source is how the stale-preload
 * crackle got to a venue.
 */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Play, Square, Anchor, Search, RefreshCw } from 'lucide-react'
import { SampleRow } from './SampleRow'
import { Rack, type RackRow } from './Rack'
import { familyColor, type Kit, type KitIndex, type Sample, type Tier } from './types'
import { Deck, PHRASE_BARS, CHOP_STEPS, audioCtx, audition, loadBuffer, type Audition, type Voice } from './engine'

/** Drop one key. `const { [id]: _unused, ...rest }` reads worse and lints worse. */
function without<T>(o: Record<string, T>, id: string): Record<string, T> {
  const next = { ...o }
  delete next[id]
  return next
}

const FAMILIES = ['drums', 'hits', 'bass', 'tonal', 'vox', 'fx'] as const
const TIERS: Tier[] = ['S', 'A', 'B', 'C', 'D']
const KEY = 'pv_cale_rack'

/** `?kits=fred_*` narrows the rail on load, so a link can point at one pack. */
const kitGlob = new URLSearchParams(location.search).get('kits') ?? ''
const globMatch = (name: string, glob: string) =>
  !glob || new RegExp('^' + glob.split('*').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$').test(name)

export default function KitAuditioner() {
  const [idx, setIdx] = useState<KitIndex | null>(null)
  const [err, setErr] = useState<string | null>(null)
  const [kitSel, setKitSel] = useState<string | null>(null)
  const [q, setQ] = useState('')
  const [fam, setFam] = useState<Set<string>>(new Set())
  const [minTier, setMinTier] = useState<Tier>('D')
  const [mode, setMode] = useState<'all' | 'loop' | 'chop'>('all')
  const [flaggedOnly, setFlaggedOnly] = useState(false)
  const [focus, setFocus] = useState(0)

  // audition state — one at a time, deliberately: two samples at once is what the rack is
  const [live, setLive] = useState<{ id: string; mode: 'once' | 'loop' } | null>(null)
  const auditionRef = useRef<Audition | null>(null)
  const [head, setHead] = useState(0)

  // the rack
  const deckRef = useRef<Deck | null>(null)
  // Hydrated at construction, not in an effect: the rack must never render empty for
  // a frame and then fill in, or a play pressed early plays nothing.
  const saved = useMemo(() => {
    try { return JSON.parse(localStorage.getItem(KEY) || '{}') } catch { return {} }
  }, [])
  const [voices, setVoices] = useState<Record<string, Voice>>(saved.voices ?? {})
  const [order, setOrder] = useState<string[]>(saved.order ?? [])
  const [bpm, setBpm] = useState<number>(saved.bpm ?? 123)
  const [playing, setPlaying] = useState(false)
  const [pos, setPos] = useState(-1)
  const [master, setMaster] = useState(0.8)

  /** One action, so the focused row and the tempo can never lag the selection. */
  const selectKit = useCallback((name: string, kitBpm?: number | null) => {
    setKitSel(name)
    setFocus(0)
    if (kitBpm) setBpm(Math.round(kitBpm))
  }, [])

  useEffect(() => {
    fetch('/kits.json')
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText} — run tools/foundry/kitindex.py`))))
      .then((d: KitIndex) => {
        setIdx(d)
        const first = d.kits.find((k) => globMatch(k.kit, kitGlob)) ?? d.kits[0]
        if (first) selectKit(first.kit, first.bpm)
      })
      .catch((e) => setErr(String(e.message ?? e)))
  }, [selectKit])

  // ── the kit rail, grouped by track ─────────────────────────────────────────
  const kits = useMemo(
    () => (idx?.kits ?? []).filter((k) => globMatch(k.kit, kitGlob)
      && (!q || k.kit.toLowerCase().includes(q.toLowerCase()))),
    [idx, q],
  )
  const groups = useMemo(() => {
    const g = new Map<string, Kit[]>()
    for (const k of kits) g.set(k.track ?? '—', [...(g.get(k.track ?? '—') ?? []), k])
    return [...g.entries()]
  }, [kits])

  const kit = useMemo(() => idx?.kits.find((k) => k.kit === kitSel) ?? null, [idx, kitSel])

  const samples = useMemo(() => {
    const floor = TIERS.indexOf(minTier)
    return (kit?.samples ?? []).filter((s) => {
      if (fam.size && !fam.has(s.family ?? '')) return false
      if (s.tier && TIERS.indexOf(s.tier) > floor) return false
      if (mode !== 'all' && (s.mode ?? (s.bars ? 'loop' : 'chop')) !== mode) return false
      if (flaggedOnly && !s.flags.length) return false
      return true
    })
  }, [kit, fam, minTier, mode, flaggedOnly])

  // ── audition ───────────────────────────────────────────────────────────────
  const stopAudition = useCallback(() => {
    auditionRef.current?.stop()
    auditionRef.current = null
    setLive(null)
    setHead(0)
  }, [])

  const play = useCallback((s: Sample, m: 'once' | 'loop') => {
    stopAudition()
    void loadBuffer(s.url).then((buf) => {
      auditionRef.current = audition(buf, { loop: m === 'loop' })
      setLive({ id: s.name, mode: m })
    }).catch((e) => setErr(String(e)))
  }, [stopAudition])

  // playhead: read the clock, don't count frames — a dropped frame must not move it
  useEffect(() => {
    if (!live) return
    let raf = 0
    const step = () => {
      const a = auditionRef.current
      if (a) {
        const el = audioCtx().currentTime - a.startedAt
        if (live.mode === 'once' && el > a.durS) stopAudition()
        else setHead((el % a.durS) / a.durS)
      }
      raf = requestAnimationFrame(step)
    }
    raf = requestAnimationFrame(step)
    return () => cancelAnimationFrame(raf)
  }, [live, stopAudition])

  // ── the rack ───────────────────────────────────────────────────────────────
  const deck = () => (deckRef.current ??= new Deck())

  const addToRack = useCallback((s: Sample, kitName: string) => {
    const id = `${kitName}/${s.name}`
    setVoices((vs) => {
      if (vs[id]) return without(vs, id)
      const bars = s.bars ?? 0
      return {
        ...vs,
        [id]: {
          id, url: s.url, bars, durS: s.dur_s ?? 0, gain: 1, muted: false,
          // a new row arrives PLAYING, on every entry it has. Adding a loop and
          // then having to find its cells before hearing anything is friction in
          // the one interaction this page exists for.
          cells: bars > 0
            ? Array.from({ length: PHRASE_BARS }, (_, i) => i % bars === 0)
            : Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0),
        },
      }
    })
    setOrder((o) => (o.includes(id) ? o.filter((x) => x !== id) : [...o, id]))
  }, [])

  // Every voice change is pushed to the deck, buffers included. Decoding happens once
  // per URL in `loadBuffer`, so re-running this on a fader move costs nothing.
  useEffect(() => {
    const d = deck()
    d.bpm = bpm
    const ids = new Set(Object.keys(voices))
    for (const v of Object.values(voices)) void loadBuffer(v.url).then((b) => d.setVoice(v, b))
    for (const id of order) if (!ids.has(id)) d.removeVoice(id)
  }, [voices, bpm, order])

  useEffect(() => { deck().setMaster(master) }, [master])

  useEffect(() => {
    if (!playing) return
    const t = setInterval(() => setPos(deck().position()), 50)
    return () => clearInterval(t)
  }, [playing])

  const togglePlay = useCallback(() => {
    const d = deck()
    if (d.playing) { d.stop(); setPlaying(false); setPos(-1) } else { d.start(); setPlaying(true) }
  }, [])

  // The rack survives a refresh. An arrangement you found by ear and then lost to a
  // reload is the same mistake the set-judge already learned not to make.
  useEffect(() => {
    try { localStorage.setItem(KEY, JSON.stringify({ voices, order, bpm })) } catch { /* full or blocked */ }
  }, [voices, order, bpm])

  const rackRows: RackRow[] = useMemo(() => order.flatMap((id) => {
    const v = voices[id]
    if (!v) return []
    const [kitName, name] = [id.slice(0, id.indexOf('/')), id.slice(id.indexOf('/') + 1)]
    const s = idx?.kits.find((k) => k.kit === kitName)?.samples.find((x) => x.name === name)
    return [{ voice: v, kit: kitName, n: s?.n ?? 0, family: s?.family ?? null, color: familyColor(s?.family) }]
  }), [order, voices, idx])

  // ── keys ───────────────────────────────────────────────────────────────────
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
      const s = samples[focus]
      if (e.key === 'j' || e.key === 'ArrowDown') { setFocus((f) => Math.min(f + 1, samples.length - 1)); e.preventDefault() }
      else if (e.key === 'k' || e.key === 'ArrowUp') { setFocus((f) => Math.max(f - 1, 0)); e.preventDefault() }
      else if (e.key === 'Enter' && s) play(s, 'once')
      else if (e.key === 'l' && s) play(s, 'loop')
      else if (e.key === 'a' && s && kit) addToRack(s, kit.kit)
      else if (e.key === ' ') { togglePlay(); e.preventDefault() }
      else if (e.key === 'Escape') stopAudition()
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [samples, focus, kit, play, addToRack, togglePlay, stopAudition])

  if (err) return <Fail msg={err} />
  if (!idx) return <div className="p-6 text-[13px] text-ink-faint">reading the index…</div>

  const total = idx.kits.reduce((a, k) => a + k.n_samples, 0)
  const flagged = kit?.samples.filter((s) => s.flags.length).length ?? 0

  return (
    <div className="h-screen flex flex-col bg-surface text-ink text-[13px]">
      {/* ── bridge header: identity, then the transport ───────────────────── */}
      <header className="flex items-center gap-3 px-3 py-2 border-b border-hairline shrink-0">
        <Anchor size={15} className="text-magenta" />
        <div>
          <div className="font-semibold leading-tight">La Cale</div>
          <div className="text-[11px] text-ink-faint leading-tight">
            {idx.kits.length} kits · {total} samples · indexed {idx.generated.slice(0, 16).replace('T', ' ')}
          </div>
        </div>

        <div className="flex-1" />

        <button onClick={togglePlay}
                className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-[12px] font-medium
                            ${playing ? 'bg-magenta text-surface' : 'bg-overlay text-ink hover:bg-overlay/70'}`}>
          {playing ? <Square size={13} /> : <Play size={13} />}
          {playing ? 'stop' : 'play rack'}
        </button>

        <label className="flex items-center gap-1.5 text-[11px] text-ink-faint">
          bpm
          <input type="number" min={40} max={220} step={0.5} value={bpm}
                 onChange={(e) => setBpm(Number(e.target.value) || 120)}
                 className="tnum w-16 bg-raised border border-hairline rounded-sm px-1.5 py-0.5 text-[12px] text-ink" />
        </label>
        {kit?.bpm && Math.round(kit.bpm) !== Math.round(bpm) && (
          <button onClick={() => setBpm(Math.round(kit.bpm!))}
                  className="flex items-center gap-1 text-[11px] text-wip hover:text-ink" title="match this kit's tempo">
            <RefreshCw size={11} /> {kit.bpm.toFixed(1)}
          </button>
        )}

        <label className="flex items-center gap-1.5 text-[11px] text-ink-faint" title="master">
          out
          <input type="range" min={0} max={1} step={0.01} value={master}
                 onChange={(e) => setMaster(Number(e.target.value))}
                 className="sx-range w-20" style={{ ['--fill' as string]: `${master * 100}%` }} />
        </label>

        <div className="text-[10px] text-ink-faint/70 font-mono pl-1">
          j/k move · ⏎ once · l loop · a rack · ␣ play
        </div>
      </header>

      <div className="flex flex-1 min-h-0">
        {/* ── the rail ─────────────────────────────────────────────────────── */}
        <aside className="w-60 shrink-0 border-r border-hairline flex flex-col">
          <div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-hairline">
            <Search size={12} className="text-ink-faint" />
            <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter kits"
                   className="flex-1 bg-transparent text-[12px] outline-none placeholder:text-ink-faint/60" />
          </div>
          <div className="flex-1 overflow-y-auto">
            {groups.map(([track, ks]) => (
              <div key={track}>
                <div className="px-2 pt-2 pb-0.5 text-[10px] uppercase tracking-wide text-ink-faint/80">{track}</div>
                {ks.map((k) => (
                  <button key={k.kit} onClick={() => selectKit(k.kit, k.bpm)}
                          className={`w-full flex items-center gap-2 px-2 py-1 text-left
                                      ${k.kit === kitSel ? 'bg-overlay' : 'hover:bg-raised'}`}>
                    <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: familyColor(k.family) }} />
                    <span className="flex-1 truncate text-[12px]">{k.family ?? k.kit}</span>
                    <span className="tnum text-[11px] text-ink-faint">{k.n_samples}</span>
                  </button>
                ))}
              </div>
            ))}
          </div>
        </aside>

        {/* ── the kit ──────────────────────────────────────────────────────── */}
        <main className="flex-1 min-w-0 flex flex-col">
          <div className="flex items-center gap-2 px-2 py-1.5 border-b border-hairline shrink-0">
            <div className="font-mono text-[12px] text-ink">{kit?.kit}</div>
            <div className="flex gap-1">
              {FAMILIES.filter((f) => kit?.samples.some((s) => s.family === f)).map((f) => (
                <button key={f} onClick={() => setFam((p) => {
                  const n = new Set(p)
                  if (n.has(f)) n.delete(f); else n.add(f)
                  return n
                })}
                        className={`px-1.5 py-0.5 text-[11px] rounded-sm border
                                    ${fam.has(f) ? 'border-transparent' : 'border-hairline text-ink-faint'}`}
                        style={fam.has(f) ? { background: familyColor(f), color: 'var(--color-surface)' } : undefined}>
                  {f}
                </button>
              ))}
            </div>
            <div className="flex-1" />
            <select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}
                    className="bg-raised border border-hairline rounded-sm px-1 py-0.5 text-[11px]">
              <option value="all">loops + chops</option>
              <option value="loop">loops</option>
              <option value="chop">chops</option>
            </select>
            <select value={minTier} onChange={(e) => setMinTier(e.target.value as Tier)}
                    className="bg-raised border border-hairline rounded-sm px-1 py-0.5 text-[11px]">
              {TIERS.map((t) => <option key={t} value={t}>{t}+</option>)}
            </select>
            <button onClick={() => setFlaggedOnly((v) => !v)} disabled={!flagged}
                    className={`px-1.5 py-0.5 text-[11px] rounded-sm border disabled:opacity-40
                                ${flaggedOnly ? 'border-wip text-wip' : 'border-hairline text-ink-faint'}`}
                    title="samples the grader flagged — worth a second listen">
              ! {flagged}
            </button>
            <div className="tnum text-[11px] text-ink-faint w-14 text-right">{samples.length} shown</div>
          </div>

          <div className="flex-1 overflow-y-auto">
            {samples.map((s, i) => (
              <SampleRow key={s.name} s={s} kit={kit!.kit}
                         playing={live?.id === s.name ? live.mode : null}
                         head={live?.id === s.name ? head : null}
                         inRack={!!voices[`${kit!.kit}/${s.name}`]}
                         focused={i === focus}
                         onFocus={() => setFocus(i)}
                         onPlay={(m) => play(s, m)}
                         onStop={stopAudition}
                         onRack={() => addToRack(s, kit!.kit)} />
            ))}
            {!samples.length && <div className="p-4 text-[12px] text-ink-faint">nothing matches these filters.</div>}
          </div>
        </main>
      </div>

      {/* ── the rack ───────────────────────────────────────────────────────── */}
      <section className="h-[17rem] shrink-0 border-t border-hairline flex flex-col bg-raised/40">
        <div className="flex items-center gap-2 px-2 py-1 border-b border-hairline shrink-0">
          <div className="text-[11px] uppercase tracking-wide text-ink-faint">rack</div>
          <div className="text-[11px] text-ink-faint">
            {rackRows.length} voice{rackRows.length === 1 ? '' : 's'} · {PHRASE_BARS}-bar phrase @ {bpm} bpm
          </div>
          {playing && pos >= 0 && (
            <div className="tnum font-mono text-[11px] text-magenta">bar {pos + 1}/{PHRASE_BARS}</div>
          )}
        </div>
        <Rack rows={rackRows} bpm={bpm} playing={playing} position={pos}
              onChange={(id, patch) => setVoices((vs) => ({ ...vs, [id]: { ...vs[id], ...patch } }))}
              onRemove={(id) => { setVoices((vs) => without(vs, id)); setOrder((o) => o.filter((x) => x !== id)); deck().removeVoice(id) }}
              onClear={() => { setVoices({}); setOrder([]); deck().clear() }} />
      </section>
    </div>
  )
}

function Fail({ msg }: { msg: string }) {
  return (
    <div className="p-6 max-w-xl">
      <div className="text-blocked text-[13px] mb-2">{msg}</div>
      <pre className="text-[11px] text-ink-faint font-mono">
{`cd tools/foundry
python3 kitindex.py --cuts packs/fred/fred_kits.json --all`}
      </pre>
    </div>
  )
}
