// Interactive sequence timeline: stacked lanes (RF magnitude, RF phase,
// gx/gy/gz, ADC) over a shared time axis with wheel zoom, drag pan, and
// click-to-select-block. Canvas-rendered with per-pixel min/max decimation
// so full sequences (hundreds of thousands of RF samples) stay responsive.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Reconstructed, Series } from '../seq/reconstruct.ts'
import { formatTick, formatTime } from './format'

export interface TimelineProps {
  rec: Reconstructed
  selectedBlock: number | null
  onSelectBlock: (index: number | null) => void
}

interface Lane {
  label: string
  unit: string
  color: string
  height: number
  kind: 'series' | 'adc'
  series?: Series
  /** y range; symmetric lanes use [-max, max] */
  vMin: number
  vMax: number
}

const AXIS_HEIGHT = 26
const GUTTER = 58
const MIN_SPAN = 2e-6

function laneRange(s: Series, symmetric: boolean): [number, number] {
  let max = 0
  for (let i = 0; i < s.v.length; i++) {
    const v = s.v[i]
    if (!Number.isNaN(v)) max = Math.max(max, Math.abs(v))
  }
  if (max === 0) max = 1
  max *= 1.08
  return symmetric ? [-max, max] : [0, max]
}

/** Nice tick step: 1/2/5 * 10^k so that span/step is 4..10 ticks. */
function tickStep(span: number): number {
  const raw = span / 7
  const mag = Math.pow(10, Math.floor(Math.log10(raw)))
  for (const m of [1, 2, 5, 10]) {
    if (raw <= m * mag) return m * mag
  }
  return 10 * mag
}

function lowerBound(t: Float64Array, x: number): number {
  let lo = 0
  let hi = t.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if (t[mid] < x) lo = mid + 1
    else hi = mid
  }
  return lo
}

export default function Timeline({ rec, selectedBlock, onSelectBlock }: TimelineProps) {
  const hostRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [view, setView] = useState<[number, number]>([0, rec.duration || 1])
  const [width, setWidth] = useState(800)
  const [hoverT, setHoverT] = useState<number | null>(null)
  const [panning, setPanning] = useState(false)
  const dragRef = useRef<{ x0: number; view0: [number, number]; moved: boolean } | null>(null)

  // Reset the view when a different sequence is shown
  useEffect(() => {
    setView([0, rec.duration || 1])
  }, [rec])

  const lanes: Lane[] = useMemo(() => {
    const [rfLo, rfHi] = laneRange(rec.rfMag, false)
    const gx = laneRange(rec.gx, true)
    const gy = laneRange(rec.gy, true)
    const gz = laneRange(rec.gz, true)
    return [
      { label: 'RF', unit: 'Hz', color: '#ffc861', height: 88, kind: 'series', series: rec.rfMag, vMin: rfLo, vMax: rfHi },
      { label: 'RF φ', unit: 'rad', color: '#c49bff', height: 54, kind: 'series', series: rec.rfPhase, vMin: -Math.PI * 1.15, vMax: Math.PI * 1.15 },
      { label: 'GX', unit: 'Hz/m', color: '#ff6b6b', height: 72, kind: 'series', series: rec.gx, vMin: gx[0], vMax: gx[1] },
      { label: 'GY', unit: 'Hz/m', color: '#51cf66', height: 72, kind: 'series', series: rec.gy, vMin: gy[0], vMax: gy[1] },
      { label: 'GZ', unit: 'Hz/m', color: '#43a7f5', height: 72, kind: 'series', series: rec.gz, vMin: gz[0], vMax: gz[1] },
      { label: 'ADC', unit: '', color: '#2fd3c6', height: 36, kind: 'adc', vMin: 0, vMax: 1 },
    ]
  }, [rec])

  const totalHeight = lanes.reduce((acc, l) => acc + l.height, 0) + AXIS_HEIGHT

  useEffect(() => {
    const host = hostRef.current
    if (!host) return
    const ro = new ResizeObserver(() => setWidth(host.clientWidth))
    ro.observe(host)
    setWidth(host.clientWidth)
    return () => ro.disconnect()
  }, [])

  const clampView = useCallback(
    (t0: number, t1: number): [number, number] => {
      const total = rec.duration || 1
      let span = Math.min(Math.max(t1 - t0, MIN_SPAN), total)
      let lo = t0
      if (lo < 0) lo = 0
      if (lo + span > total) lo = total - span
      return [lo, lo + span]
    },
    [rec.duration],
  )

  // Keep the selected block in view (e.g. when navigated via the inspector
  // arrows). Pans to center it, zooming out only if it's wider than the view.
  // No-op when the block is already fully visible, so clicking a visible block
  // doesn't shift the timeline.
  useEffect(() => {
    if (selectedBlock === null) return
    const b = rec.blockSpans[selectedBlock]
    if (!b) return
    const bStart = b.start
    const bEnd = b.start + b.duration
    setView((v) => {
      const [t0, t1] = v
      if (bStart >= t0 && bEnd <= t1) return v // already visible
      const span = t1 - t0
      const newSpan = b.duration > span ? b.duration * 1.3 : span
      const center = (bStart + bEnd) / 2
      return clampView(center - newSpan / 2, center + newSpan / 2)
    })
  }, [selectedBlock, rec.blockSpans, clampView])

  // ── rendering ─────────────────────────────────────────────────────────
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const dpr = window.devicePixelRatio || 1
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(totalHeight * dpr)
    canvas.style.height = `${totalHeight}px`
    const ctx = canvas.getContext('2d')
    if (!ctx) return
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

    const [t0, t1] = view
    const span = t1 - t0
    const plotW = width - GUTTER
    const xOf = (t: number) => GUTTER + ((t - t0) / span) * plotW

    ctx.fillStyle = '#12161d'
    ctx.fillRect(0, 0, width, totalHeight)

    // Selected block highlight (behind everything)
    if (selectedBlock !== null && rec.blockSpans[selectedBlock]) {
      const b = rec.blockSpans[selectedBlock]
      const x0 = Math.max(GUTTER, xOf(b.start))
      const x1 = Math.min(width, xOf(b.start + b.duration))
      if (x1 > GUTTER && x0 < width) {
        ctx.fillStyle = '#4da3ff1c'
        ctx.fillRect(x0, 0, Math.max(x1 - x0, 2), totalHeight - AXIS_HEIGHT)
      }
    }

    // Block boundaries when they are resolvable
    const spans = rec.blockSpans
    const iFirst = Math.max(0, spans.findIndex((b) => b.start + b.duration >= t0))
    let visibleBlocks = 0
    for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) visibleBlocks++
    if (visibleBlocks > 0 && visibleBlocks < plotW / 6) {
      ctx.strokeStyle = '#ffffff10'
      ctx.beginPath()
      for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) {
        const x = Math.round(xOf(spans[i].start)) + 0.5
        if (x < GUTTER) continue
        ctx.moveTo(x, 0)
        ctx.lineTo(x, totalHeight - AXIS_HEIGHT)
      }
      ctx.stroke()
    }

    let y0 = 0
    for (const lane of lanes) {
      const h = lane.height
      // separator + baseline
      ctx.strokeStyle = '#262d38'
      ctx.beginPath()
      ctx.moveTo(0, y0 + h - 0.5)
      ctx.lineTo(width, y0 + h - 0.5)
      ctx.stroke()

      const pad = 6
      const yOf = (v: number) =>
        y0 + h - pad - ((v - lane.vMin) / (lane.vMax - lane.vMin)) * (h - 2 * pad)

      if (lane.vMin < 0) {
        ctx.strokeStyle = '#ffffff14'
        ctx.beginPath()
        ctx.moveTo(GUTTER, yOf(0) + 0.5)
        ctx.lineTo(width, yOf(0) + 0.5)
        ctx.stroke()
      }

      if (lane.kind === 'adc') {
        ctx.fillStyle = lane.color + '55'
        ctx.strokeStyle = lane.color
        for (const a of rec.adcSpans) {
          if (a.end < t0 || a.start > t1) continue
          const x0 = Math.max(GUTTER, xOf(a.start))
          const x1 = Math.min(width, xOf(a.end))
          ctx.fillRect(x0, y0 + pad, Math.max(x1 - x0, 1.5), h - 2 * pad)
          // sample ticks when resolvable
          const pxPerSample = (x1 - x0) / a.num
          if (pxPerSample > 4) {
            ctx.beginPath()
            for (let k = 0; k < a.num; k++) {
              const x = xOf(a.start + (k + 0.5) * a.dwell)
              if (x < GUTTER || x > width) continue
              ctx.moveTo(x, y0 + h / 2 - 3)
              ctx.lineTo(x, y0 + h / 2 + 3)
            }
            ctx.stroke()
          }
        }
      } else if (lane.series) {
        drawSeries(ctx, lane.series, xOf, yOf, t0, t1, plotW, lane.color)
      }

      // lane label
      ctx.fillStyle = '#12161dd8'
      ctx.fillRect(0, y0, GUTTER - 6, h - 1)
      ctx.fillStyle = lane.color
      ctx.font = '600 11px system-ui'
      ctx.textBaseline = 'top'
      ctx.fillText(lane.label, 8, y0 + 6)
      if (lane.unit) {
        ctx.fillStyle = '#8b95a5'
        ctx.font = '10px system-ui'
        ctx.fillText(lane.unit, 8, y0 + 19)
      }
      y0 += h
    }

    // ── time axis ──
    const axisY = totalHeight - AXIS_HEIGHT
    ctx.fillStyle = '#0f1115'
    ctx.fillRect(0, axisY, width, AXIS_HEIGHT)
    ctx.strokeStyle = '#262d38'
    ctx.beginPath()
    ctx.moveTo(0, axisY + 0.5)
    ctx.lineTo(width, axisY + 0.5)
    ctx.stroke()
    const step = tickStep(span)
    const first = Math.ceil(t0 / step) * step
    ctx.font = '10.5px system-ui'
    ctx.textBaseline = 'top'
    for (let t = first; t <= t1 + 1e-12; t += step) {
      const x = xOf(t)
      if (x < GUTTER - 1) continue
      ctx.strokeStyle = '#3a4452'
      ctx.beginPath()
      ctx.moveTo(x + 0.5, axisY)
      ctx.lineTo(x + 0.5, axisY + 5)
      ctx.stroke()
      ctx.fillStyle = '#8b95a5'
      const label = formatTick(t, step)
      ctx.fillText(label, x - ctx.measureText(label).width / 2, axisY + 8)
    }

    // hover crosshair
    if (hoverT !== null && hoverT >= t0 && hoverT <= t1) {
      const x = xOf(hoverT)
      ctx.strokeStyle = '#ffffff30'
      ctx.beginPath()
      ctx.moveTo(x + 0.5, 0)
      ctx.lineTo(x + 0.5, axisY)
      ctx.stroke()
      ctx.fillStyle = '#d7dde7'
      ctx.font = '11px system-ui'
      const label = formatTime(hoverT)
      const tw = ctx.measureText(label).width
      const lx = Math.min(Math.max(x - tw / 2, GUTTER), width - tw - 4)
      ctx.fillStyle = '#1a212bee'
      ctx.fillRect(lx - 4, axisY + 6, tw + 8, 15)
      ctx.fillStyle = '#d7dde7'
      ctx.fillText(label, lx, axisY + 9)
    }
  }, [view, width, lanes, rec, selectedBlock, hoverT, totalHeight])

  // ── interactions ──────────────────────────────────────────────────────
  const tAtClientX = useCallback(
    (clientX: number): number => {
      const rect = canvasRef.current!.getBoundingClientRect()
      const x = clientX - rect.left
      const [t0, t1] = view
      return t0 + ((x - GUTTER) / (width - GUTTER)) * (t1 - t0)
    },
    [view, width],
  )

  // Native wheel listener: React's onWheel is passive and can't preventDefault
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const onWheel = (e: WheelEvent) => {
      e.preventDefault()
      const t = tAtClientX(e.clientX)
      setView(([t0, t1]) => {
        const factor = Math.exp(e.deltaY * 0.002)
        return clampView(t + (t0 - t) * factor, t + (t1 - t) * factor)
      })
    }
    canvas.addEventListener('wheel', onWheel, { passive: false })
    return () => canvas.removeEventListener('wheel', onWheel)
  }, [tAtClientX, clampView])

  const onMouseDown = (e: React.MouseEvent) => {
    dragRef.current = { x0: e.clientX, view0: view, moved: false }
    setPanning(true)
  }

  useEffect(() => {
    if (!panning) return
    const onMove = (e: MouseEvent) => {
      const drag = dragRef.current
      if (!drag) return
      const dx = e.clientX - drag.x0
      if (Math.abs(dx) > 3) drag.moved = true
      const [t0, t1] = drag.view0
      const dt = (dx / (width - GUTTER)) * (t1 - t0)
      setView(clampView(t0 - dt, t1 - dt))
    }
    const onUp = (e: MouseEvent) => {
      const drag = dragRef.current
      dragRef.current = null
      setPanning(false)
      if (drag && !drag.moved) {
        // click: select the block at this time
        const t = tAtClientX(e.clientX)
        const idx = rec.blockSpans.findIndex((b) => t >= b.start && t < b.start + b.duration)
        onSelectBlock(idx >= 0 ? idx : null)
      }
    }
    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
    return () => {
      window.removeEventListener('mousemove', onMove)
      window.removeEventListener('mouseup', onUp)
    }
  }, [panning, width, clampView, tAtClientX, rec.blockSpans, onSelectBlock])

  return (
    <div className="timeline-host" ref={hostRef}>
      <canvas
        ref={canvasRef}
        className={`timeline-canvas${panning ? ' panning' : ''}`}
        onMouseDown={onMouseDown}
        onDoubleClick={() => setView([0, rec.duration || 1])}
        onMouseMove={(e) => setHoverT(tAtClientX(e.clientX))}
        onMouseLeave={() => setHoverT(null)}
      />
    </div>
  )
}

/** Draw a piecewise-linear series with per-pixel min/max decimation. */
function drawSeries(
  ctx: CanvasRenderingContext2D,
  s: Series,
  xOf: (t: number) => number,
  yOf: (v: number) => number,
  t0: number,
  t1: number,
  plotW: number,
  color: string,
) {
  const { t, v } = s
  if (t.length === 0) return
  let i0 = Math.max(0, lowerBound(t, t0) - 1)
  let i1 = Math.min(t.length - 1, lowerBound(t, t1) + 1)

  ctx.save()
  ctx.beginPath()
  ctx.rect(GUTTER, 0, plotW, ctx.canvas.height)
  ctx.clip()
  ctx.strokeStyle = color
  ctx.lineWidth = 1.2
  ctx.lineJoin = 'round'
  ctx.beginPath()

  const dense = i1 - i0 > plotW * 3
  if (dense) {
    // min/max per pixel column; each column's range includes the previous
    // column's closing value so adjacent strokes connect
    let px = -1
    let lo = 0
    let hi = 0
    let close = 0
    let open = false
    const flush = () => {
      if (open && px >= 0) {
        ctx.moveTo(px + 0.5, yOf(lo))
        ctx.lineTo(px + 0.5, yOf(hi))
      }
    }
    for (let i = i0; i <= i1; i++) {
      if (Number.isNaN(v[i])) {
        flush()
        px = -1
        open = false
        continue
      }
      const x = Math.round(xOf(t[i]))
      if (x !== px) {
        flush()
        const carry = open ? close : v[i]
        px = x
        lo = Math.min(v[i], carry)
        hi = Math.max(v[i], carry)
        open = true
      } else {
        if (v[i] < lo) lo = v[i]
        if (v[i] > hi) hi = v[i]
      }
      close = v[i]
    }
    flush()
  } else {
    let pen = false
    for (let i = i0; i <= i1; i++) {
      if (Number.isNaN(v[i])) {
        pen = false
        continue
      }
      const x = xOf(t[i])
      const y = yOf(v[i])
      if (!pen) {
        ctx.moveTo(x, y)
        pen = true
      } else {
        ctx.lineTo(x, y)
      }
    }
  }
  ctx.stroke()
  ctx.restore()
}
