import { useCallback, useEffect, useMemo, useState } from 'react'
import { Download, Check, HelpCircle, AlertTriangle } from 'lucide-react'
import { WaveformPlayer } from '@/components/WaveformPlayer'
import { mmss } from '@/lib/format'

/**
 * Boundary lab — decide exactly where one track becomes the next.
 *
 * The set-judge answers "is this a keeper"; this answers "where is the cut",
 * which turned out to be the harder question. Three automated attempts failed
 * (see cut_lens3.py): on transitions where both tracks share their sounds, a
 * timbral lens is blind by construction, so some cuts are ear-only by nature.
 * The tool's whole job is therefore to make one ear call cost seconds.
 *
 * Times: the clip is a ±45 s excerpt, so everything on screen is CLIP time and
 * everything exported is MASTER time. The conversion lives in exactly two
 * places (`toClip`, `toMaster`) because doing it ad hoc is how every marker
 * ends up silently shifted.
 */

interface Mark { key: string; label: string; t: number }
interface Boundary {
  track: number
  title: string
  fromTitle: string
  nominal: number
  clipStart: number
  clipEnd: number
  url: string
  peaks: number[]
  marks: Mark[]
  settled: boolean
  noCandidate: boolean
  cutFromRelease?: boolean
  role?: string
  note: string
}
interface BoundsDoc { gig: string; title: string; pad: number; boundaries: Boundary[] }

// `noise` came from PLN auditioning CosmicFest: "at 22:42 its end of reose_rouge,
// theres a loud blip noise, at 22:44.9 its the start of rose rouge, could we cut
// noise and blend better?" — a defect in the 2.9 s BETWEEN two tracks, belonging
// to neither. Until now the lab could only answer "where does the next track
// start", so a transition carrying garbage had nowhere to be recorded, and the
// note survived only as prose in a comment box.
const MARK_COLOR: Record<string, string> = {
  noise: 'rgba(255, 96, 64, 0.95)',     // a defect, not a candidate — warm and loud
  takeover: 'rgba(217, 0, 255, 0.9)',   // brand magenta — the one to try first
  mid: 'rgba(120, 170, 255, 0.75)',
  onset: 'rgba(120, 170, 255, 0.45)',
  ear: 'rgba(80, 220, 140, 0.9)',
  alt: 'rgba(80, 220, 140, 0.5)',
  nominal: 'rgba(255, 255, 255, 0.35)',
}

type Call = { t: number | null; comment: string; source: string; noise?: number[] }

export function BoundaryLab({ gig }: { gig: string }) {
  const [doc, setDoc] = useState<BoundsDoc | null>(null)
  const [err, setErr] = useState<string | null>(null)
  const [i, setI] = useState(0)
  const [now, setNow] = useState(0)
  const [seek, setSeek] = useState<{ t: number; n: number } | undefined>()
  const [calls, setCalls] = useState<Record<number, Call>>(() => {
    try { return JSON.parse(localStorage.getItem(`bounds:${gig}`) || '{}') } catch { return {} }
  })

  useEffect(() => {
    fetch(`/bounds-${gig}.json`)
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText}`))))
      .then(setDoc)
      .catch((e) => setErr(String(e)))
  }, [gig])

  useEffect(() => {
    localStorage.setItem(`bounds:${gig}`, JSON.stringify(calls))
  }, [calls, gig])

  const b = doc?.boundaries[i]
  const toClip = useCallback((t: number) => (b ? t - b.clipStart : 0), [b])
  const toMaster = useCallback((t: number) => (b ? t + b.clipStart : 0), [b])

  const markers = useMemo(() => {
    if (!b) return []
    const mine = (calls[b.track]?.noise ?? []).map((t, n) => ({
      key: 'noise', label: `noise ${n + 1}`, t,
    }))
    const all = [...b.marks, ...mine, { key: 'nominal', label: 'nominal', t: b.nominal }]
    return all.map((m) => ({
      t: toClip(m.t),
      label: m.label,
      color: MARK_COLOR[m.key] ?? MARK_COLOR.nominal,
    }))
  }, [b, toClip, calls])

  const jump = useCallback((tMaster: number, lead = 4) => {
    // land a few seconds BEFORE the candidate: a cut is judged by what leads
    // into it, and starting exactly on it gives you nothing to judge against.
    setSeek({ t: Math.max(0, toClip(tMaster) - lead), n: Date.now() })
  }, [toClip])

  const setCall = useCallback((t: number | null, source: string) => {
    if (!b) return
    setCalls((c) => ({ ...c, [b.track]: { t, source, comment: c[b.track]?.comment ?? '' } }))
  }, [b])

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
      if (!doc) return
      if (e.key === 'j') setI((v) => Math.min(doc.boundaries.length - 1, v + 1))
      else if (e.key === 'k') setI((v) => Math.max(0, v - 1))
      else if (e.key === 'Enter' && b) setCall(toMaster(now), 'playhead')
      // `n` marks garbage AT the playhead and leaves the cut call untouched: a
      // blip between two tracks is not an opinion about where either begins.
      else if (e.key === 'n' && b) {
        const t = Number(toMaster(now).toFixed(2))
        setCalls((c) => {
          const cur = c[b.track] ?? { t: null, source: 'noise', comment: '' }
          const noise = [...(cur.noise ?? []), t].sort((x, y) => x - y)
          return { ...c, [b.track]: { ...cur, noise } }
        })
      }
      // `N` clears them, because a mis-tapped defect marker you cannot remove is
      // worse than none.
      else if (e.key === 'N' && b) {
        setCalls((c) => (c[b.track] ? { ...c, [b.track]: { ...c[b.track], noise: [] } } : c))
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [doc, b, now, toMaster, setCall])

  const download = () => {
    if (!doc) return
    const out = {
      gig: doc.gig,
      _comment: [
        'Ear calls from the boundary lab. Times are absolute MASTER seconds.',
        'These are PLN\'s answers — merge them into the `verified` block of',
        'judge_specs/<gig>_boundaries_ear.json, which is the ground truth.',
        'BOTH blocks carry notes: a skipped seam usually means the seam itself is',
        'wrong, which is more informative than a confirmed cut time.',
      ],
      decided: Object.fromEntries(
        Object.entries(calls)
          .filter(([, c]) => c.t !== null)
          .map(([k, c]) => [k, {
            start: Number(c.t!.toFixed(2)), source: c.source, note: c.comment,
            ...(c.noise?.length ? { noise: c.noise } : {}),
          }]),
      ),
      // Skipped boundaries keep their NOTE. They used to export as bare integers,
      // which silently threw away the most valuable thing in the file: PLN skipped
      // eight CosmicFest seams and typed a correction into every one of them —
      // "theres no blue_gold in this set", "no funk->piment transition", "could be
      // called Outro Dub Siren" — and the export emitted `[2,4,9,10,15,16,17,18]`.
      // A skip is not an absence of judgement; it is usually a judgement that the
      // SEAM is wrong rather than that the moment is unclear.
      // The comment was always kept in state (the textarea writes it with t ?? null),
      // so this recovers notes already in localStorage, not just future ones.
      skipped: Object.fromEntries(
        Object.entries(calls)
          .filter(([, c]) => c.t === null)
          .map(([k, c]) => [k, {
            source: c.source, note: c.comment,
            ...(c.noise?.length ? { noise: c.noise } : {}),
          }]),
      ),
    }
    const url = URL.createObjectURL(new Blob([JSON.stringify(out, null, 1)], { type: 'application/json' }))
    const a = document.createElement('a')
    a.href = url
    a.download = `boundaries-${doc.gig}.json`
    a.click()
    URL.revokeObjectURL(url)
  }

  if (err) return <p className="p-6 font-mono text-sm text-red-400">bounds data not loaded: {err}</p>
  if (!doc || !b) return <p className="p-6 font-mono text-sm text-ink-faint">loading…</p>

  const decided = Object.values(calls).filter((c) => c.t !== null).length
  const call = calls[b.track]

  return (
    <div className="mx-auto max-w-4xl px-4 pb-24">
      <header className="sticky top-0 z-20 -mx-4 flex flex-col gap-3 border-b border-hairline
                         bg-surface/95 px-4 pb-3 pt-5 backdrop-blur-sm">
        <div className="flex items-baseline justify-between gap-4">
          <h1 className="text-lg font-semibold">
            Boundary lab <span className="text-ink-faint">· {doc.title}</span>
          </h1>
          <div className="flex items-center gap-3 font-mono text-xs text-ink-muted tnum">
            <span>{decided}/{doc.boundaries.length} decided</span>
            <button
              onClick={download}
              className="flex items-center gap-1 rounded bg-ink px-2 py-1 text-surface hover:opacity-90"
            >
              <Download size={12} /> boundaries.json
            </button>
          </div>
        </div>
        <nav className="flex flex-wrap gap-1">
          {doc.boundaries.map((x, n) => (
            <button
              key={x.track}
              onClick={() => setI(n)}
              className={`rounded px-2 py-0.5 font-mono text-xs transition-colors ${
                n === i ? 'bg-magenta text-white'
                : calls[x.track]?.t != null ? 'bg-emerald-500/20 text-emerald-300'
                : 'text-ink-faint hover:text-ink'
              }`}
            >
              #{x.track}
            </button>
          ))}
        </nav>
      </header>

      <section className="mt-5">
        <p className="font-mono text-xs text-ink-faint">
          {b.fromTitle} <span className="text-magenta">→</span> {b.title}
        </p>
        <h2 className="text-xl font-semibold">
          #{b.track} {b.title}
          {b.settled && <span className="ml-2 align-middle text-xs font-normal text-emerald-300">settled</span>}
        </h2>
        {b.cutFromRelease && (
          <p className="mt-2 rounded border border-sky-500/30 bg-sky-500/10 px-3 py-2 text-xs text-sky-200">
            <strong>{b.role}.</strong> {b.title} is cut from the release, so this edge is not a
            track start — it is where the last official track <em>ends</em>. It still has to be
            right, or {b.title}&rsquo;s intro bleeds into the end of the record.
          </p>
        )}
        {b.noCandidate && (
          <p className="mt-2 flex items-start gap-2 rounded border border-amber-500/30 bg-amber-500/10
                        px-3 py-2 text-xs text-amber-200">
            <AlertTriangle size={14} className="mt-0.5 shrink-0" />
            <span>
              No machine candidate exists for this cut, and none ever will from the timbral
              lens — both tracks share their sounds across the switch, so it is blind here by
              construction. This one is yours: scrub, then press <kbd>Enter</kbd> at the moment
              the incoming track takes over.
            </span>
          </p>
        )}
        {b.note && <p className="mt-2 text-xs text-ink-muted">{b.note}</p>}
      </section>

      <section className="mt-4">
        <WaveformPlayer
          url={b.url}
          dur={b.clipEnd - b.clipStart}
          peaks={b.peaks}
          markers={markers}
          seek={seek}
          onTime={setNow}
        />
      </section>

      <section className="mt-4 flex flex-wrap items-center gap-2">
        {[...b.marks, { key: 'nominal', label: 'nominal', t: b.nominal }].map((m) => (
          <button
            key={m.key}
            onClick={() => { jump(m.t); setCall(m.t, m.key) }}
            className={`rounded border px-2.5 py-1 font-mono text-xs transition-colors ${
              call?.source === m.key
                ? 'border-magenta bg-magenta/15 text-magenta'
                : 'border-hairline text-ink-muted hover:text-ink'
            }`}
            title="Audition from 4 s before, and take it as the call"
          >
            {m.label} · {mmss(m.t)}
          </button>
        ))}
        <span className="mx-1 text-hairline">|</span>
        <button
          onClick={() => setCall(toMaster(now), 'playhead')}
          className="rounded border border-hairline px-2.5 py-1 font-mono text-xs
                     text-ink-muted hover:text-ink"
        >
          <Check size={12} className="mr-1 inline" /> use playhead · {mmss(toMaster(now))}
        </button>
        <button
          onClick={() => setCall(null, 'skip')}
          className="rounded border border-hairline px-2.5 py-1 font-mono text-xs
                     text-ink-faint hover:text-ink"
        >
          <HelpCircle size={12} className="mr-1 inline" /> undecided
        </button>
      </section>

      <section className="mt-4">
        <div className="flex items-baseline gap-3">
          <span className="font-mono text-xs text-ink-faint">call</span>
          <span className="font-mono text-lg tnum">
            {call?.t != null
              ? <span className="text-emerald-300">{mmss(call.t)} <span className="text-xs text-ink-faint">({call.t.toFixed(2)}s · {call.source})</span></span>
              : <span className="text-ink-faint">—</span>}
          </span>
        </div>
        <textarea
          value={call?.comment ?? ''}
          onChange={(e) =>
            setCalls((c) => ({
              ...c,
              [b.track]: { t: c[b.track]?.t ?? null, source: c[b.track]?.source ?? 'comment', comment: e.target.value },
            }))
          }
          placeholder="what you heard — feeds performance_notes.md"
          className="mt-2 h-16 w-full resize-y rounded border border-hairline bg-transparent
                     px-2 py-1.5 text-sm outline-none focus:border-magenta/60"
        />
      </section>

      <p className="mt-6 font-mono text-[10px] text-ink-faint/70">
        n = mark noise (N clears) · j/k next-prev · space play/pause · Enter = take the playhead · drag to loop a span ·
        clip is ±{doc.pad}s around the current cut, all exported times are master time
      </p>
    </div>
  )
}
