import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'

const LAYOUT_KEY  = (vid) => `pf_layout_v${vid}`   // last-used layout (auto restore)
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}`  // named layout list

let perspectivePromise = null
function loadPerspective() {
  if (perspectivePromise) return perspectivePromise
  perspectivePromise = Promise.all([
    import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
    import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
    import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
    import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
  ]).then(([{ default: perspective }]) => perspective)
  return perspectivePromise
}

function cleanLayout(cfg, validCols) {
  if (!cfg) return cfg
  const c = { ...cfg }
  const exprNames = new Set(Object.keys(cfg.expressions || {}))
  const ok = (col) => validCols.has(col) || exprNames.has(col)
  if (c.columns)  c.columns  = c.columns.filter(col => col == null || ok(col))
  if (c.group_by) c.group_by = c.group_by.filter(ok)
  if (c.split_by) c.split_by = c.split_by.filter(ok)
  if (c.sort)     c.sort     = c.sort.filter(([col]) => ok(col))
  if (c.filter)   c.filter   = c.filter.filter(([col]) => ok(col))
  return c
}

export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
  const { dark } = useTheme()
  const [loading,      setLoading]      = useState(false)
  const [largeDataset, setLargeDataset] = useState(false)
  const [loadProgress, setLoadProgress] = useState(null)  // { received, total }
  const [msg,          setMsg]          = useState(null)

  // layouts
  const [layouts,        setLayouts]        = useState([])
  const [activeLayoutId, setActiveLayoutId] = useState(null)
  const [showSaveAs,     setShowSaveAs]     = useState(false)
  const [saveAsName,     setSaveAsName]     = useState('')

  // operation panel
  const [slice,          setSlice]          = useState({})
  const [activeOp,       setActiveOp]       = useState('scale')
  const [currentTotals,  setCurrentTotals]  = useState(null)  // { value, units }
  const [scaleMode,      setScaleMode]      = useState('target')  // 'target' | 'delta'
  const [scaleValue,     setScaleValue]     = useState('')
  const [scaleUnits,     setScaleUnits]     = useState('')
  const [scalePrice,     setScalePrice]     = useState('')
  const [scalePct,       setScalePct]       = useState(false)
  const [scaleNote,      setScaleNote]      = useState('')
  const [recodeSet,      setRecodeSet]      = useState({})
  const [recodeNote,     setRecodeNote]     = useState('')
  const [cloneSet,       setCloneSet]       = useState({})
  const [cloneScale,     setCloneScale]     = useState('1')
  const [cloneNote,      setCloneNote]      = useState('')

  const [panelWidth, setPanelWidth] = useState(224)

  // history modal
  const [showLog,       setShowLog]       = useState(false)
  const [logEntries,    setLogEntries]    = useState([])
  const [logLoading,    setLogLoading]    = useState(false)
  const [editingNote,   setEditingNote]   = useState(null)  // { id, text }
  const [undoingId,     setUndoingId]     = useState(null)

  const viewerRef      = useRef(null)
  const workerRef      = useRef(null)
  const tableRef       = useRef(null)
  const colMetaRef     = useRef([])
  const expandDepthRef = useRef(null)
  const initIdRef      = useRef(0)

  function onDragStart(e) {
    e.preventDefault()
    const startX = e.clientX
    const startW = panelWidth
    const onMove = (ev) => setPanelWidth(Math.max(160, Math.min(480, startW - (ev.clientX - startX))))
    const onUp   = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
  }

  useEffect(() => {
    if (!versionId || !sourceId) return
    loadLayouts(versionId)
    initViewer(versionId, sourceId)
  }, [versionId, sourceId])

  useEffect(() => {
    if (viewerRef.current) {
      viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
    }
  }, [dark, versionId])

  useEffect(() => {
    const blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
    setRecodeSet(blank)
    setCloneSet(blank)
    setScaleValue('')
    setScaleUnits('')
    setScalePrice('')
    if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
    else setCurrentTotals(null)
  }, [slice])

  async function fetchCurrentTotals(sliceObj) {
    if (!tableRef.current) return
    const valueCol = colMetaRef.current.find(c => c.role === 'value')?.cname
    const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
    if (!valueCol && !unitsCol) return
    try {
      const dimNames  = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
      const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
      const filters = [
        ...Object.entries(sliceObj)
          .filter(([col]) => dimNames.has(col))
          .map(([col, val]) => [col, '==', val]),
        ...Object.entries(sliceObj)
          .filter(([col]) => dateNames.has(col))
          .map(([col, val]) => [col, '==', Number(val)]),
        ['pf_iter', '!=', 'reference'],
      ]
      const view = await tableRef.current.view({ filter: filters })
      const rows = await view.to_json()
      await view.delete()
      const buckets = new Map()
      for (const r of rows) {
        const k = r.pf_iter || '?'
        const t = buckets.get(k) || { value: 0, units: 0 }
        if (valueCol) t.value += parseFloat(r[valueCol]) || 0
        if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
        buckets.set(k, t)
      }
      const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
      const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
        .sort((a, b) => {
          const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
          return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
        })
      const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
      setCurrentTotals({ byIter, total, valueCol, unitsCol })
    } catch {
      setCurrentTotals(null)
    }
  }

  // Stream an Arrow IPC endpoint into one buffer, reporting download progress.
  // Both /data and /agg speak the same protocol — a single record batch plus an
  // X-Row-Count header — so the caller only picks the URL.
  async function fetchArrow(url) {
    const r = await fetch(url)
    if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
    const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
    const total    = parseInt(r.headers.get('Content-Length') || '0') || null
    const reader   = r.body.getReader()
    const chunks   = []
    let received   = 0
    let lastUpdate = 0
    setLoadProgress({ received: 0, total })
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      chunks.push(value)
      received += value.byteLength
      const now = Date.now()
      if (now - lastUpdate >= 100) {
        setLoadProgress({ received, total })
        lastUpdate = now
      }
    }
    setLoadProgress({ received, total })
    const merged = new Uint8Array(received)
    let pos = 0
    for (const c of chunks) { merged.set(c, pos); pos += c.byteLength }
    return { buffer: merged.buffer, rowCount }
  }

  function loadLayouts(vid) {
    const stored = localStorage.getItem(LAYOUTS_KEY(vid))
    setLayouts(stored ? JSON.parse(stored) : [])
    setActiveLayoutId(null)
  }

  async function initViewer(vid, sid) {
    const viewer = viewerRef.current
    if (!viewer) return
    const myId = ++initIdRef.current
    setLoading(true)
    setLargeDataset(false)
    setLoadProgress(null)
    setSlice({})
    expandDepthRef.current = null
    try {
      // col_meta first — it decides which endpoint to load from, and it is a tiny
      // query next to the data fetch it gates.
      const meta = await fetch(`/api/sources/${sid}/cols`).then(r => r.json())
      colMetaRef.current = meta

      // Grain mode: the source declares a display grain, so the server ships rows
      // already aggregated to it and the table is indexed on pf_gkey. Without a
      // grain we load raw forecast rows indexed on pf_id, as before.
      const grainMeta  = meta.filter(c => c.in_grain && ['dimension','date'].includes(c.role))
      const grainMode  = grainMeta.length > 0
      const indexCol   = grainMode ? 'pf_gkey' : 'pf_id'

      const [perspective, dataResult] = await Promise.all([
        loadPerspective(),
        fetchArrow(`/api/versions/${vid}/${grainMode ? 'agg' : 'data'}`),
      ])

      const { buffer, rowCount } = dataResult
      const validCols = new Set(grainMode
        ? [
            ...grainMeta.map(c => c.cname),
            ...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname),
            'pf_gkey', 'pf_iter', 'pf_logid',
          ]
        : [
            ...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
            'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at',
          ])
      const tableName = `fc_${vid}`

      if (rowCount >= 500000) setLargeDataset(true)

      if (myId !== initIdRef.current) return

      if (!workerRef.current) workerRef.current = await perspective.worker()
      const worker = workerRef.current

      // Clean up the previous table — by JS reference first, then by name in the
      // worker registry (covers the case where the ref was lost or delete failed).
      if (tableRef.current) {
        try { await tableRef.current.delete() } catch {}
        tableRef.current = null
      }
      try {
        const stale = await worker.open_table(tableName)
        if (stale) await stale.delete()
      } catch {}

      const opts = { name: tableName, index: indexCol }
      tableRef.current = await (rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts))

      if (myId !== initIdRef.current) {
        try { await tableRef.current.delete() } catch {}
        tableRef.current = null
        return
      }

      // Load by direct table reference — avoids "No Table attached" on large datasets
      // that occurs when viewer.load(worker) + restore({ table: name }) can't resolve
      // the named table in time.
      await viewer.load(tableRef.current)
      viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')

      // restore last-used layout or build default
      // Strip cfg.table — table is already loaded by reference above; a stale name
      // in a saved config would cause Perspective to fail the name lookup.
      const saved = localStorage.getItem(LAYOUT_KEY(vid))
      if (saved) {
        const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
        const cfg = { ...rest, plugin_config: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } }
        await viewer.restore(cfg)
        if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
      } else {
        const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
        let cfg
        if (sourceDefault && Object.keys(sourceDefault).length > 0) {
          const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
          cfg = { ...rest, plugin_config: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } }
        } else {
          const valueCol = meta.find(c => c.role === 'value')?.cname
          cfg = {
            settings: false,
            group_by: ['pf_iter'],
            columns: valueCol ? [valueCol] : [],
            plugin_config: { edit_mode: 'SELECT_REGION' }
          }
        }
        await viewer.restore(cfg)
      }

      // auto-persist viewer state (formatting, columns, etc.) to the last-used cache
      if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
      viewer._pspUpdate = async () => {
        try {
          const cfg = await captureConfig()
          if (cfg) await persistLayout(vid, cfg)
        } catch {}
      }
      viewer.addEventListener('perspective-config-update', viewer._pspUpdate)

      // click → slice via event filters (Perspective encodes row position as [col,'==',val] triples)
      if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
      viewer._pspClick = async (e) => {
        const detail = e.detail || {}
        if (!detail.row) return
        const config = await viewer.save()
        if (!(config.group_by || []).length) return
        const eventFilters = (detail.config || {}).filter || []
        const s = {}
        eventFilters.forEach(([col, op, val]) => {
          if (op === '==' && val != null) s[col] = String(val)
        })
        if (Object.keys(s).length > 0) setSlice(s)
      }
      viewer.addEventListener('perspective-click', viewer._pspClick)
      setLargeDataset(false)

    } catch (err) {
      console.error('[initViewer]', err)
      flash(err.message || String(err), 'error')
    } finally {
      setLoading(false)
    }
  }

  async function applyDepth(d) {
    const viewer = viewerRef.current
    if (!viewer) return
    const view   = await viewer.getView()
    await view.set_depth(d)
    const plugin = await viewer.getPlugin()
    await plugin.draw(view)
    expandDepthRef.current = d
  }

  async function captureConfig() {
    const viewer = viewerRef.current
    if (!viewer) return null
    const cfg = await viewer.save()
    return { ...cfg, expand_depth: expandDepthRef.current }
  }

  async function persistLayout(vid, cfg) {
    localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
  }

  async function handleSaveAs() {
    const name = saveAsName.trim()
    if (!name) return
    const cfg = await captureConfig()
    if (!cfg) return
    const id      = Date.now()
    const updated = [...layouts, { id, name, config: cfg }]
    localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
    await persistLayout(versionId, cfg)
    setLayouts(updated)
    setActiveLayoutId(id)
    setShowSaveAs(false)
    setSaveAsName('')
    flash('Saved')
  }

  async function saveAsSourceDefault() {
    const cfg = await captureConfig()
    if (!cfg) return
    const { table, expand_depth, ...rest } = cfg
    try {
      const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(rest)
      })
      if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
      if (refreshSources) await refreshSources()
      flash('Saved as source default')
    } catch (err) { flash(err.message, 'error') }
  }

  async function handleSaveOver() {
    const layout = layouts.find(l => l.id === activeLayoutId)
    if (!layout) return
    const cfg     = await captureConfig()
    if (!cfg) return
    const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l)
    localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
    await persistLayout(versionId, cfg)
    setLayouts(updated)
    flash('Saved')
  }

  async function applyLayout(layout) {
    const viewer = viewerRef.current
    if (!viewer) return
    const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
    const cfg = cleanLayout(layout.config, validCols)
    cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
    await viewer.restore(cfg)
    if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
    setActiveLayoutId(layout.id)
    await persistLayout(versionId, cfg)
  }

  function deleteLayout(id, e) {
    e.stopPropagation()
    const updated = layouts.filter(l => l.id !== id)
    localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
    setLayouts(updated)
    if (activeLayoutId === id) setActiveLayoutId(null)
  }

  function resetLayout() {
    localStorage.removeItem(LAYOUT_KEY(versionId))
    setActiveLayoutId(null)
    const viewer = viewerRef.current
    if (viewer) viewer.restore({ settings: true })
  }

  async function submitOp(op) {
    if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
    const effectiveSlice = buildEffectiveSlice(slice)
    if (!Object.keys(effectiveSlice).length) { flash('No dimension or date columns in slice — check col_meta', 'error'); return }
    let body = { pf_user: 'admin', slice: effectiveSlice }

    if (op === 'scale') {
      let vi = null, ui = null
      if (scaleMode === 'target') {
        const curValue = currentTotals?.total?.value
        const curUnits = currentTotals?.total?.units
        if (scalePrice !== '' && curUnits != null && curValue != null) {
          // hold units constant; new value = price × current units
          vi = (parseFloat(scalePrice) * curUnits) - curValue
        }
        if (scaleValue !== '' && curValue != null)
          vi = parseFloat(scaleValue) - curValue
        if (scaleUnits !== '' && curUnits != null)
          ui = parseFloat(scaleUnits) - curUnits
      } else {
        if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
        if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
      }
      if (vi == null && ui == null) { flash('Enter a target or increment', 'error'); return }
      body = { ...body, note: scaleNote, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
    } else if (op === 'recode') {
      const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
      if (!Object.keys(set).length) { flash('Enter at least one new dimension value', 'error'); return }
      body = { ...body, note: recodeNote, set }
    } else if (op === 'clone') {
      const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
      if (!Object.keys(set).length) { flash('Enter at least one override value', 'error'); return }
      body = { ...body, note: cloneNote, set, scale: parseFloat(cloneScale) || 1 }
    }

    try {
      const res  = await fetch(`/api/versions/${versionId}/${op}`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
      })
      const data = await res.json()
      if (!res.ok) { flash(data.error, 'error'); return }
      if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
      flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
      if (op === 'scale')  { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
      if (op === 'recode') { setRecodeNote('') }
      if (op === 'clone')  { setCloneNote(''); setCloneScale('1') }
    } catch (err) { flash(err.message, 'error') }
  }

  async function lookupDerivedCols(col, value, setter) {
    if (!sourceId || !value.trim()) return
    const res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
    if (!res.ok) return
    const derived = await res.json()
    if (!derived) return
    setter(prev => {
      const next = { ...prev }
      for (const [k, v] of Object.entries(derived)) {
        if (!prev[k] || prev[k] === '') next[k] = String(v ?? '')
      }
      return next
    })
  }

  function buildEffectiveSlice(raw) {
    const dimCols  = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
    const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
    const out = {}
    for (const [k, v] of Object.entries(raw)) {
      if (dimCols.has(k)) { out[k] = v; continue }
      if (dateCols.has(k)) {
        const ms = Number(v)
        out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v
      }
    }
    return out
  }

  function buildPayload(op) {
    if (!Object.keys(slice).length) return null
    const effectiveSlice = buildEffectiveSlice(slice)
    let body = { pf_user: 'admin', slice: effectiveSlice }
    if (op === 'scale') {
      let vi = null, ui = null
      if (scaleMode === 'target') {
        const curValue = currentTotals?.total?.value
        const curUnits = currentTotals?.total?.units
        if (scalePrice !== '' && curUnits != null && curValue != null)
          vi = (parseFloat(scalePrice) * curUnits) - curValue
        if (scaleValue !== '' && curValue != null)
          vi = parseFloat(scaleValue) - curValue
        if (scaleUnits !== '' && curUnits != null)
          ui = parseFloat(scaleUnits) - curUnits
      } else {
        if (scaleValue !== '') vi = parseFloat(scaleValue)
        if (scaleUnits !== '') ui = parseFloat(scaleUnits)
      }
      body = { ...body, note: scaleNote || undefined, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
    } else if (op === 'recode') {
      const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
      body = { ...body, note: recodeNote || undefined, set }
    } else if (op === 'clone') {
      const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
      body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
    }
    return body
  }

  function flash(text, type = 'ok') {
    setMsg({ text, type })
    if (type !== 'error') setTimeout(() => setMsg(null), 3000)
  }

  async function openLog() {
    setShowLog(true)
    setLogLoading(true)
    try {
      const data = await fetch(`/api/versions/${versionId}/log`).then(r => r.json())
      setLogEntries(data)
    } catch (err) {
      flash(err.message, 'error')
    } finally {
      setLogLoading(false)
    }
  }

  async function undoEntry(logId) {
    setUndoingId(logId)
    try {
      const res  = await fetch(`/api/log/${logId}`, { method: 'DELETE' })
      const data = await res.json()
      if (!res.ok) { flash(data.error, 'error'); return }
      setLogEntries(prev => prev.filter(e => e.id !== logId))
      // grain versions report pf_gkeys, raw versions pf_ids — either way these are
      // the index values of the rows to drop, and the view re-sums what remains
      const removed = data.pf_gkeys ?? data.pf_ids
      if (removed?.length && tableRef.current) {
        await tableRef.current.remove(removed)
      }
      flash(`Undone — ${data.rows_deleted} rows removed`)
    } catch (err) {
      flash(err.message, 'error')
    } finally {
      setUndoingId(null)
    }
  }

  async function saveNote(logId, text) {
    try {
      const res  = await fetch(`/api/log/${logId}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ note: text })
      })
      if (!res.ok) { flash('Failed to save note', 'error'); return }
      setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
      setEditingNote(null)
    } catch (err) {
      flash(err.message, 'error')
    }
  }

  const dimCols  = colMetaRef.current.filter(c => c.role === 'dimension')
  const hasSlice = Object.keys(slice).length > 0

  return (
    <div className="h-full flex flex-col">

      {/* Toolbar */}
      <div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">

        {/* Layout group */}
        <div className="flex items-center gap-1.5">
          <span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
          {layouts.map(l => (
            <div key={l.id} onClick={() => applyLayout(l)}
              className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
                ${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
              {l.name}
              <button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
            </div>
          ))}
          {showSaveAs ? (
            <div className="flex items-center gap-1">
              <input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
                onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
                placeholder="Layout name…" className="border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
              <button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
              <button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 px-1">Cancel</button>
            </div>
          ) : (
            <>
              {activeLayoutId !== null && (
                <button onClick={handleSaveOver} className="border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
              )}
              <button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
                Save as…
              </button>
              <button onClick={saveAsSourceDefault} disabled={!sourceId}
                className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
                title="Use this layout as the default for new versions of this source">
                Set source default
              </button>
              {activeLayoutId !== null && (
                <button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
              )}
            </>
          )}
        </div>

        <div className="w-px h-4 bg-gray-200 shrink-0" />

        {/* Expand group */}
        <div className="flex items-center gap-1.5">
          <span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
          {[0, 1, 2, 3].map(d => (
            <button key={d} onClick={() => applyDepth(d)}
              className={`border rounded px-1.5 py-0.5 transition-colors
                ${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
              {d}
            </button>
          ))}
        </div>

        <div className="w-px h-4 bg-gray-200 shrink-0" />

        {/* Data group */}
        <div className="flex items-center gap-1.5">
          <button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
            className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
            {loading ? 'Loading…' : 'Refresh data'}
          </button>
          <button onClick={openLog} disabled={!versionId}
            className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
            Change log
          </button>
        </div>

        {msg && (
          <span className={`ml-2 text-xs font-medium px-2 py-0.5 rounded flex items-center gap-1.5 ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
            {msg.text}
            {msg.type === 'error' && (
              <button onClick={() => setMsg(null)} className="opacity-60 hover:opacity-100 leading-none">×</button>
            )}
          </span>
        )}

      </div>

      {/* History modal */}
      {showLog && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
          <div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
            <div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
              <span className="font-medium text-gray-700 text-sm">Change History</span>
              <button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
            </div>

            <div className="overflow-y-auto flex-1">
              {logLoading ? (
                <div className="p-8 text-center text-sm text-gray-400">Loading…</div>
              ) : logEntries.length === 0 ? (
                <div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
              ) : (
                <table className="w-full text-xs border-collapse">
                  <thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
                    <tr>
                      <th className="text-left px-4 py-2 font-medium w-32">Time</th>
                      <th className="text-left px-4 py-2 font-medium w-24">Op</th>
                      <th className="text-left px-4 py-2 font-medium">Slice</th>
                      <th className="text-left px-4 py-2 font-medium">Note</th>
                      <th className="text-right px-4 py-2 font-medium w-16">Rows</th>
                      <th className="px-4 py-2 w-16"></th>
                    </tr>
                  </thead>
                  <tbody>
                    {logEntries.map(entry => (
                      <tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
                        <td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
                        <td className="px-4 py-2">
                          <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
                            {entry.operation}
                          </span>
                        </td>
                        <td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
                        <td className="px-4 py-2 text-gray-600 max-w-xs">
                          {editingNote?.id === entry.id ? (
                            <div className="flex items-center gap-1">
                              <input autoFocus value={editingNote.text}
                                onChange={e => setEditingNote(n => ({ ...n, text: e.target.value }))}
                                onKeyDown={e => {
                                  if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
                                  if (e.key === 'Escape') setEditingNote(null)
                                }}
                                className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
                              <button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800">✓</button>
                              <button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600">✕</button>
                            </div>
                          ) : (
                            <span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
                              className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
                              title={entry.note || 'Click to add note'}>
                              {entry.note || <span className="text-gray-300 italic">add note</span>}
                            </span>
                          )}
                        </td>
                        <td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
                        <td className="px-4 py-2">
                          <button
                            onClick={() => undoEntry(entry.id)}
                            disabled={undoingId === entry.id}
                            className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
                            {undoingId === entry.id ? '…' : 'Undo'}
                          </button>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              )}
            </div>
          </div>
        </div>
      )}

      {/* Main area */}
      <div className="flex-1 flex min-h-0">
        {/* Perspective viewer */}
        <div className="relative flex-1 min-w-0">
          {loading && (
            <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 z-10 gap-2">
              <span className="text-sm text-gray-400">Loading…</span>
              {loadProgress && (
                <>
                  <span className="text-xs text-gray-400 font-mono">
                    {fmtBytes(loadProgress.received)}
                    {loadProgress.total ? ` / ${fmtBytes(loadProgress.total)}` : ''}
                  </span>
                  {loadProgress.total > 0 && (
                    <div className="w-48 h-1 bg-gray-200 rounded overflow-hidden">
                      <div
                        className="h-full bg-blue-400 transition-all"
                        style={{ width: `${Math.min(100, (loadProgress.received / loadProgress.total) * 100)}%` }}
                      />
                    </div>
                  )}
                </>
              )}
            </div>
          )}
          {!loading && largeDataset && (
            <div className="absolute top-2 left-1/2 -translate-x-1/2 z-10 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-3 py-1.5 rounded shadow-sm">
              Large dataset — pivot may take a moment to render
            </div>
          )}
          <perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
        </div>

        {/* Drag handle */}
        <div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />

        {/* Operation panel */}
        <div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
          <div className="p-3 border-b border-gray-100">
            <div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
            {!hasSlice ? (
              <div className="text-gray-300 italic">Click a pivot row to select a slice</div>
            ) : (
              <div className="flex flex-col gap-1">
                {Object.entries(slice).map(([k, v]) => (
                  <div key={k} className="text-gray-700">
                    <span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
                  </div>
                ))}
                <button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
              </div>
            )}
          </div>

          {hasSlice && currentTotals?.byIter?.length > 0 && (
            <div className="px-3 py-2 border-b border-gray-100">
              <div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
              <table className="w-full text-xs">
                <thead>
                  <tr className="text-gray-400" style={{fontSize:'10px'}}>
                    <th className="text-left font-normal pb-1"></th>
                    {currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
                    {currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
                    {currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
                  </tr>
                </thead>
                <tbody>
                  {currentTotals.byIter.map(r => (
                    <tr key={r.iter}>
                      <td className="text-gray-500 capitalize pr-1">{r.iter}</td>
                      {currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
                      {currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
                      {currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
                    </tr>
                  ))}
                  {currentTotals.byIter.length > 1 && (
                    <tr className="border-t border-gray-100">
                      <td className="text-gray-600 font-medium pt-1 pr-1">total</td>
                      {currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
                      {currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
                      {currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
                    </tr>
                  )}
                </tbody>
              </table>
            </div>
          )}

          {hasSlice && (
            <>
              <div className="flex border-b border-gray-100">
                {['scale', 'recode', 'clone'].map(op => (
                  <button key={op} onClick={() => setActiveOp(op)}
                    className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
                    {op}
                  </button>
                ))}
              </div>

              <div className="p-3 flex flex-col gap-2.5">
                {activeOp === 'scale' && <>
                  {/* Mode toggle */}
                  <div className="flex rounded border border-gray-200 overflow-hidden">
                    {[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
                      <button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
                        className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
                        {label}
                      </button>
                    ))}
                  </div>

                  {currentTotals?.valueCol && (
                    <Row label={currentTotals.valueCol}>
                      <input type="number" step="any" value={scaleValue}
                        onChange={e => setScaleValue(e.target.value)}
                        placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
                        className={inp} />
                    </Row>
                  )}

                  {currentTotals?.unitsCol && (
                    <Row label={currentTotals.unitsCol}>
                      <input type="number" step="any" value={scaleUnits}
                        onChange={e => setScaleUnits(e.target.value)}
                        placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
                        className={inp} />
                    </Row>
                  )}

                  {scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
                    <Row label="price">
                      <input type="number" step="any" value={scalePrice}
                        onChange={e => setScalePrice(e.target.value)}
                        placeholder="target price (holds units)"
                        className={inp} />
                    </Row>
                  )}

                  {scaleMode === 'delta' && (
                    <label className="flex items-center gap-2 text-gray-500">
                      <input type="checkbox" checked={scalePct} onChange={e => setScalePct(e.target.checked)} /> % of slice
                    </label>
                  )}

                  <Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
                  <PayloadPreview payload={buildPayload('scale')} />
                  <Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
                </>}

                {activeOp === 'recode' && <>
                  <p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
                  {dimCols.map(c => (
                    <Row key={c.cname} label={c.label || c.cname}>
                      <input
                        value={recodeSet[c.cname] || ''}
                        onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
                        onBlur={c.is_key && c.dim_group
                          ? e => lookupDerivedCols(c.cname, e.target.value, setRecodeSet)
                          : undefined}
                        placeholder={slice[c.cname] || '—'}
                        className={`${inp} font-mono`} />
                    </Row>
                  ))}
                  <Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
                  <PayloadPreview payload={buildPayload('recode')} />
                  <Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
                </>}

                {activeOp === 'clone' && <>
                  <p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
                  {dimCols.map(c => (
                    <Row key={c.cname} label={c.label || c.cname}>
                      <input
                        value={cloneSet[c.cname] || ''}
                        onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
                        onBlur={c.is_key && c.dim_group
                          ? e => lookupDerivedCols(c.cname, e.target.value, setCloneSet)
                          : undefined}
                        placeholder={slice[c.cname] || '—'}
                        className={`${inp} font-mono`} />
                    </Row>
                  ))}
                  <Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
                  <Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
                  <PayloadPreview payload={buildPayload('clone')} />
                  <Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
                </>}
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  )
}

const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'

function fmtBytes(n) {
  if (n < 1024) return `${n} B`
  if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
  return `${(n / 1048576).toFixed(1)} MB`
}

function fmtNum(n, decimals = 2) {
  if (n == null || !isFinite(n)) return '—'
  return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
}

function fmtStamp(stamp) {
  return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}

function fmtSlice(slice) {
  if (!slice || !Object.keys(slice).length) return '—'
  return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
}

const OP_BADGE = {
  baseline:  'bg-gray-100 text-gray-600',
  reference: 'bg-blue-50 text-blue-600',
  scale:     'bg-green-50 text-green-700',
  recode:    'bg-amber-50 text-amber-700',
  clone:     'bg-purple-50 text-purple-700',
}
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }

function Row({ label, children }) {
  return (
    <div className="flex items-center gap-2">
      <span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
      {children}
    </div>
  )
}

function Submit({ onClick, children }) {
  return (
    <button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
      {children}
    </button>
  )
}

function PayloadPreview({ payload }) {
  if (!payload) return null
  return (
    <pre className="text-xs font-mono text-gray-400 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-36 leading-relaxed">
      {JSON.stringify(payload, null, 2)}
    </pre>
  )
}
