<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, reactive, computed, watch } from 'vue'
import cv from '@techstark/opencv-js'
import { processImage } from './lib/processImage' // ← 根据你的实际路径调整

type Metrics = ReturnType<typeof processImage>

// 画布/视频引用
const videoEl = ref<HTMLVideoElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
// 离屏处理画布：用于抓帧与图像处理，fetch 期间继续更新它
let procCanvasEl: OffscreenCanvas | null = null
let procCtx: OffscreenCanvasRenderingContext2D | null = null
// 视图尺寸（用于 SVG overlay 同步尺寸）
const viewW = ref(0)
const viewH = ref(0)

// 状态
const running = ref(false)
const fps = ref(0)
const err = ref<string | null>(null)
const isFetching = ref(false)
// 是否显示底层可见画布
const showCanvas = ref(true)
// OpenCV 就绪标记：仅在完成 WASM 初始化后才进行图像处理
const cvReady = ref(false)
const metrics = reactive<Metrics>({
  redRectCenterX: null,
  redRectCenterY: null,
  greenRectCenterX: null,
  greenRectCenterY: null,
  blueRectCenterX: null,
  blueRectCenterY: null,
  redRectRotation: null,
  greenRectRotation: null,
  blueRectRotation: null,
  redRectLength: null,
  greenRectLength: null,
  blueRectLength: null,
  redRectWidth: null,
  greenRectWidth: null,
  blueRectWidth: null
})

// 圆心拟合（每180帧）
type Pt = { x: number; y: number }
const redCircleCenter = ref<Pt | null>(null)
const blueCircleCenter = ref<Pt | null>(null)
const greenCircleCenter = ref<Pt | null>(null)
const redPts: Pt[] = []
const bluePts: Pt[] = []
const greenPts: Pt[] = []
const fitWindow = 180
let frameCounter = 0

// 每帧相对圆心的极坐标（cos/sin/角度）
const redPolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
const bluePolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
const greenPolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
// 保存最近半径用于将预测的 pos_sin/cos 转回像素坐标
let redRadius: number | null = null
let greenRadius: number | null = null

// 预测缓冲：仅当填满180帧才触发一次请求
const framesBuffer: number[][] = []
let lastAddedTs: number | null = null
// 预测绘制数据（原始返回），在 fetch 成功后更新
let predictionsRaw: number[][] | null = null
let predictionHorizon = 0

// SVG 渲染所需的细杆数据（仅在可见刷新时更新，fetch 期间冻结）
type Rod = { cx: number; cy: number; longLen: number; shortLen: number; angleDeg: number; angleRad: number; color: string; opacity?: number }
const displayDetectedRods = ref<Rod[]>([])
const displayPredictedRods = ref<Rod[]>([])
type CenterMarker = { x: number; y: number; color: string }
const displayCenters = ref<CenterMarker[]>([])

// 使用代数最小二乘（Kasa）解 x^2 + y^2 + A x + B y + C = 0
function fitCircle(points: Pt[]): { cx: number; cy: number; r: number } | null {
  const n = points.length
  if (n < 3) return null
  let Sx = 0, Sy = 0, Sxx = 0, Syy = 0, Sxy = 0
  let Sxz = 0, Syz = 0, Sz = 0 // z = -(x^2 + y^2)
  for (const { x, y } of points) {
    const xx = x * x
    const yy = y * y
    const z = -(xx + yy)
    Sx += x
    Sy += y
    Sxx += xx
    Syy += yy
    Sxy += x * y
    Sxz += x * z
    Syz += y * z
    Sz += z
  }
  // 正规方程 M p = b, p = [A, B, C]
  const M = [
    [Sxx, Sxy, Sx],
    [Sxy, Syy, Sy],
    [Sx, Sy, n]
  ] as [number, number, number][]
  const b = [Sxz, Syz, Sz] as [number, number, number]

  const p = solveSym3(M, b)
  if (!p) return null
  const [A, B, C] = p
  const cx = -A / 2
  const cy = -B / 2
  const rad2 = cx * cx + cy * cy - C
  if (!(rad2 > 0)) return { cx, cy, r: 0 }
  return { cx, cy, r: Math.sqrt(rad2) }
}

function solveSym3(M: [number, number, number][], b: [number, number, number]): [number, number, number] | null {
  if (M.length < 3 || b.length < 3) return null
  const r0 = M[0]; const r1 = M[1]; const r2 = M[2]
  if (!r0 || !r1 || !r2 || r0.length < 3 || r1.length < 3 || r2.length < 3) return null
  const [m00, m01, m02] = r0
  const [m10, m11, m12] = r1
  const [m20, m21, m22] = r2
  const [d0, d1, d2] = b

  const det =
    m00 * (m11 * m22 - m12 * m21) -
    m01 * (m10 * m22 - m12 * m20) +
    m02 * (m10 * m21 - m11 * m20)
  if (Math.abs(det) < 1e-8) return null

  // 替换第0列
  const detA =
    d0 * (m11 * m22 - m12 * m21) -
    m01 * (d1 * m22 - m12 * d2) +
    m02 * (d1 * m21 - m11 * d2)
  // 替换第1列
  const detB =
    m00 * (d1 * m22 - m12 * d2) -
    d0 * (m10 * m22 - m12 * m20) +
    m02 * (m10 * d2 - d1 * m20)
  // 替换第2列
  const detC =
    m00 * (m11 * d2 - d1 * m21) -
    m01 * (m10 * d2 - d1 * m20) +
    d0 * (m10 * m21 - m11 * m20)

  const A = detA / det
  const B = detB / det
  const C = detC / det
  return [A, B, C]
}

// 录制 CSV
const isRecording = ref(false)
let recordRows: string[] = []
let recordStartT = 0
let recordFrameIndex = 0
// 固定表头顺序（“所有数据”= 所有 metrics 字段）
const metricKeys: (keyof Metrics)[] = [
  'redRectCenterX', 'redRectCenterY',
  'greenRectCenterX', 'greenRectCenterY',
  'blueRectCenterX', 'blueRectCenterY',
  'redRectRotation', 'greenRectRotation', 'blueRectRotation',
  'redRectLength', 'greenRectLength', 'blueRectLength',
  'redRectWidth', 'greenRectWidth', 'blueRectWidth'
]

function startRecording() {
  recordRows = []
  recordStartT = performance.now()
  recordFrameIndex = 0
  isRecording.value = true
}

function stopRecordingAndDownload() {
  isRecording.value = false
  // 生成 CSV 内容
  const header = ['frameIndex', 'time', ...metricKeys]
  const csvLines = [header.join(','), ...recordRows]
  const csvContent = '\ufeff' + csvLines.join('\r\n') // 加 BOM 便于 Excel 识别
  const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  const ts = new Date()
  const pad = (n: number) => String(n).padStart(2, '0')
  const fname = `metrics-${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.csv`
  a.href = url
  a.download = fname
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 0)
}

function toggleRecord() {
  if (!isRecording.value) startRecording()
  else stopRecordingAndDownload()
}

// 输入源：摄像头 | 本地文件
const sourceType = ref<'camera' | 'file'>('camera')
const fileInputEl = ref<HTMLInputElement | null>(null)
const fileUrl = ref<string | null>(null)
const fileName = ref<string | null>(null)
const fileLoop = ref<boolean>(true)

const imageMaxLength = 960

// 摄像头方向
const facingMode = ref<'environment' | 'user'>('environment')
const facingModeLabel = computed(() =>
  facingMode.value === 'environment' ? '后置' : '前置'
)

// RAF 相关
let rafId = 0
let lastT = 0

// 资源引用
let stream: MediaStream | null = null

function fmt(n: number | null) {
  return n == null ? '—' : n.toFixed(1)
}

function resizeCanvasToVideo(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
  const vw = video.videoWidth
  const vh = video.videoHeight

  const scaleRatio = Math.min(1, imageMaxLength / Math.max(vw, vh))
  // 实际像素尺寸
  canvas.width = Math.round(vw * scaleRatio)
  canvas.height = Math.round(vh * scaleRatio)
  // CSS 自适应
  canvas.style.width = '100%'
  canvas.style.height = 'auto'
  // 同步离屏处理画布尺寸
  if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvas.width, canvas.height)
  if (!procCtx) procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
  // 同步 SVG 尺寸
  viewW.value = canvas.width
  viewH.value = canvas.height
}

function copyMetrics(next: Metrics) {
  ; (Object.keys(metrics) as (keyof Metrics)[]).forEach((k) => {
    metrics[k] = next[k] as any
  })
}

async function ensureCVReady(): Promise<void> {
  // @techstark/opencv-js 在 WASM 初始化完成后可调用 getBuildInformation
  try {
    if ((cv as any)?.getBuildInformation) {
      cvReady.value = true
      return
    }
    await new Promise<void>((resolve) => {
      ; (cv as any).onRuntimeInitialized = () => {
        cvReady.value = true
        resolve()
      }
    })
  } catch {
    // 忽略异常，让后续帧内判断 cvReady 决定是否处理
  }
}

async function startStream() {
  try {
    err.value = null
    // 关闭旧流
    await stopStream()

    stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: { ideal: facingMode.value }, width: { ideal: 960 }, height: { ideal: 540 } },
      audio: false
    })
    if (!videoEl.value) return
    // 清空可能存在的文件来源
    videoEl.value.src = ''
    videoEl.value.removeAttribute('src')
    videoEl.value.srcObject = stream

    // 等待元数据并尝试播放（兼容 Safari/部分浏览器自动播放策略）
    await new Promise<void>((res) => {
      if (!videoEl.value) return res()
      const v = videoEl.value
      if (v.readyState >= 1) return res()
      const onMeta = () => {
        v.removeEventListener('loadedmetadata', onMeta)
        res()
      }
      v.addEventListener('loadedmetadata', onMeta)
    })
    try {
      await videoEl.value.play()
    } catch {
      // 若被策略拦截，依赖用户点击“启动”后再次尝试
    }

    if (!canvasEl.value || !videoEl.value) return
    ctx = canvasEl.value.getContext('2d')
    if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
    resizeCanvasToVideo(videoEl.value, canvasEl.value)
    // 初始化离屏处理上下文
    if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvasEl.value.width, canvasEl.value.height)
    procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
    if (!procCtx) throw new Error('离屏 CanvasRenderingContext2D 获取失败')

    // 重置计数与缓存
    frameCounter = 0
    redPts.length = 0
    bluePts.length = 0
    redCircleCenter.value = null
    blueCircleCenter.value = null
    greenPts.length = 0
    greenCircleCenter.value = null
    redPolar.value = null
    bluePolar.value = null
    greenPolar.value = null
    framesBuffer.length = 0
    lastAddedTs = null
    predictionsRaw = null
    predictionHorizon = 0
    isFetching.value = false
    redRadius = null
    greenRadius = null

    running.value = true
    lastT = performance.now()
    loop()
  } catch (e: any) {
    err.value = e?.message ?? String(e)
    running.value = false
  }
}

async function stopStream() {
  cancelAnimationFrame(rafId)
  rafId = 0
  if (stream) {
    stream.getTracks().forEach((t) => t.stop())
    stream = null
  }
  if (videoEl.value) {
    try { videoEl.value.pause() } catch { }
    // 清除摄像头或文件源
    videoEl.value.srcObject = null
    if (videoEl.value.src) {
      videoEl.value.removeAttribute('src')
    }
  }
  running.value = false
}

async function startFilePlayback() {
  try {
    err.value = null
    await stopStream()

    if (!videoEl.value) throw new Error('视频元素不存在')
    if (!fileUrl.value) throw new Error('请先选择本地视频文件')

    videoEl.value.srcObject = null
    videoEl.value.src = fileUrl.value
    videoEl.value.loop = !!fileLoop.value

    await new Promise<void>((res) => {
      if (!videoEl.value) return res()
      const v = videoEl.value
      if (v.readyState >= 1 && v.videoWidth && v.videoHeight) return res()
      const onMeta = () => {
        v.removeEventListener('loadedmetadata', onMeta)
        res()
      }
      v.addEventListener('loadedmetadata', onMeta)
    })

    try {
      await videoEl.value.play()
    } catch {
      // 可能需要用户手势
    }

    if (!canvasEl.value || !videoEl.value) return
    ctx = canvasEl.value.getContext('2d')
    if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
    resizeCanvasToVideo(videoEl.value, canvasEl.value)
    // 初始化离屏处理上下文
    if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvasEl.value.width, canvasEl.value.height)
    procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
    if (!procCtx) throw new Error('离屏 CanvasRenderingContext2D 获取失败')

    // 重置计数与缓存
    frameCounter = 0
    redPts.length = 0
    bluePts.length = 0
    redCircleCenter.value = null
    blueCircleCenter.value = null
    greenPts.length = 0
    greenCircleCenter.value = null
    redPolar.value = null
    bluePolar.value = null
    greenPolar.value = null
    framesBuffer.length = 0
    lastAddedTs = null
    predictionsRaw = null
    predictionHorizon = 0
    isFetching.value = false
    redRadius = null
    greenRadius = null

    running.value = true
    lastT = performance.now()
    loop()
  } catch (e: any) {
    err.value = e?.message ?? String(e)
    running.value = false
  }
}

async function loop() {
  if (!running.value || !videoEl.value || !canvasEl.value || !ctx) return

  const now = performance.now()
  const dt = now - lastT
  lastT = now
  fps.value = 1000 / (dt || 16.7)

  // 将视频帧绘制到离屏画布（可在 fetch 期间继续处理）
  const vw = canvasEl.value.width
  const vh = canvasEl.value.height
  if (procCtx) {
    procCtx.drawImage(videoEl.value, 0, 0, vw, vh)
  }

  try {
    // 本帧是否应在绘制后启动 fetch
    let shouldStartFetch = false
    // 取像素、识别
    const baseCtx = procCtx ?? ctx
    if (!baseCtx) throw new Error('处理上下文获取失败')
    const imgData = baseCtx.getImageData(0, 0, vw, vh)
    if (cvReady.value) {
      const res = processImage(imgData)
      copyMetrics(res)
    }

    // 收集点到滑动窗口
    if (metrics.redRectCenterX != null && metrics.redRectCenterY != null) {
      redPts.push({ x: metrics.redRectCenterX, y: metrics.redRectCenterY })
      if (redPts.length > fitWindow) redPts.shift()
    }
    if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null) {
      bluePts.push({ x: metrics.blueRectCenterX, y: metrics.blueRectCenterY })
      if (bluePts.length > fitWindow) bluePts.shift()
    }
    if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null) {
      greenPts.push({ x: metrics.greenRectCenterX, y: metrics.greenRectCenterY })
      if (greenPts.length > fitWindow) greenPts.shift()
    }

    // 拟合一次圆心
    frameCounter += 1
    if (frameCounter % fitWindow === 0) {
      const rf = fitCircle(redPts)
      const bf = fitCircle(bluePts)
      const gf = fitCircle(greenPts)
      redCircleCenter.value = rf ? { x: rf.cx, y: rf.cy } : null
      blueCircleCenter.value = bf ? { x: bf.cx, y: bf.cy } : null
      greenCircleCenter.value = gf ? { x: gf.cx, y: gf.cy } : null
    }

    // 每帧计算相对圆心的 cos/sin/角度
    if (
      redCircleCenter.value &&
      metrics.redRectCenterX != null && metrics.redRectCenterY != null
    ) {
      const dx = metrics.redRectCenterX - redCircleCenter.value.x
      const dy = metrics.redRectCenterY - redCircleCenter.value.y
      const r = Math.hypot(dx, dy)
      if (r > 1e-6) {
        const c = dx / r
        const s = dy / r
        const ang = (Math.atan2(dy, dx) * 180) / Math.PI
        const angleDeg = ang >= 0 ? ang : ang + 360
        redPolar.value = { cos: c, sin: s, angleDeg }
        redRadius = r
      } else {
        redPolar.value = null
        redRadius = null
      }
    } else {
      redPolar.value = null
    }
    if (
      blueCircleCenter.value &&
      metrics.blueRectCenterX != null && metrics.blueRectCenterY != null
    ) {
      const dx = metrics.blueRectCenterX - blueCircleCenter.value.x
      const dy = metrics.blueRectCenterY - blueCircleCenter.value.y
      const r = Math.hypot(dx, dy)
      if (r > 1e-6) {
        const c = dx / r
        const s = dy / r
        const ang = (Math.atan2(dy, dx) * 180) / Math.PI
        const angleDeg = ang >= 0 ? ang : ang + 360
        bluePolar.value = { cos: c, sin: s, angleDeg }
      } else {
        bluePolar.value = null
      }
    } else {
      bluePolar.value = null
    }

    // 计算绿色相对圆心的 cos/sin/角度
    if (
      greenCircleCenter.value &&
      metrics.greenRectCenterX != null && metrics.greenRectCenterY != null
    ) {
      const dx = metrics.greenRectCenterX - greenCircleCenter.value.x
      const dy = metrics.greenRectCenterY - greenCircleCenter.value.y
      const r = Math.hypot(dx, dy)
      if (r > 1e-6) {
        const c = dx / r
        const s = dy / r
        const ang = (Math.atan2(dy, dx) * 180) / Math.PI
        const angleDeg = ang >= 0 ? ang : ang + 360
        greenPolar.value = { cos: c, sin: s, angleDeg }
        greenRadius = r
      } else {
        greenPolar.value = null
        greenRadius = null
      }
    } else {
      greenPolar.value = null
    }

    // 维护180帧序列，仅在具备红绿圆心与必要数据时加入
    const haveCenters = !!(redCircleCenter.value && greenCircleCenter.value)
    const havePolars = !!(redPolar.value && greenPolar.value)
    const redRotDeg = metrics.redRectRotation
    const greenRotDeg = metrics.greenRectRotation
    const haveAngles = redRotDeg != null && greenRotDeg != null
    if (haveCenters && havePolars && haveAngles) {
      if (lastAddedTs == null) {
        // 初始化上一时间戳，不立即插入，确保 delta_time = t - t_-1
        lastAddedTs = now
      } else {
        const dtSec = (now - lastAddedTs) / 1000
        lastAddedTs = now
        const redRotRad = (redRotDeg! * Math.PI) / 180
        const greenRotRad = (greenRotDeg! * Math.PI) / 180
        const rp = redPolar.value!
        const gp = greenPolar.value!
        const frameRow: number[] = [
          Number(dtSec.toFixed(6)),
          Math.sin(greenRotRad), Math.cos(greenRotRad),
          Math.sin(redRotRad), Math.cos(redRotRad),
          // green_pos_sin, green_pos_cos, red_pos_sin, red_pos_cos
          gp.sin, gp.cos, rp.sin, rp.cos
        ]
        framesBuffer.push(frameRow)
        if (framesBuffer.length > 180) framesBuffer.shift()
        // 满 180 并且未在 fetch 中：在本帧绘制之后再启动 fetch，避免画面永远不刷新
        if (framesBuffer.length === 180 && !isFetching.value) {
          shouldStartFetch = true
        }
      }
    }

    // 若处于录制状态，写入一行
    if (isRecording.value) {
      const tSec = (now - recordStartT) / 1000
      const values: (string | number)[] = [
        recordFrameIndex,
        tSec.toFixed(3)
      ]
      for (const k of metricKeys) {
        const v = metrics[k]
        values.push(v == null ? '' : (typeof v === 'number' ? v.toFixed(3) : String(v)))
      }
      recordRows.push(values.join(','))
      recordFrameIndex += 1
    }

    // 可见画布在 fetch 期间冻结；非 fetch 时才刷新
    if (!isFetching.value && ctx) {
      // 先绘制当前视频帧背景（来自离屏）
      if (procCanvasEl) ctx.drawImage(procCanvasEl, 0, 0, vw, vh)

      // 组装 SVG 检测细杆（仅在可见刷新时更新）
      const rods: Rod[] = []
      if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null && metrics.blueRectLength && metrics.blueRectWidth && metrics.blueRectRotation != null) {
        rods.push({
          cx: metrics.blueRectCenterX,
          cy: metrics.blueRectCenterY,
          longLen: metrics.blueRectLength,
          shortLen: metrics.blueRectWidth,
          angleDeg: metrics.blueRectRotation,
          angleRad: (metrics.blueRectRotation * Math.PI) / 180,
          color: '#2e6bff',
          opacity: 1
        })
      }
      if (metrics.redRectCenterX != null && metrics.redRectCenterY != null && metrics.redRectLength && metrics.redRectWidth && metrics.redRectRotation != null) {
        rods.push({
          cx: metrics.redRectCenterX,
          cy: metrics.redRectCenterY,
          longLen: metrics.redRectLength,
          shortLen: metrics.redRectWidth,
          angleDeg: metrics.redRectRotation,
          angleRad: (metrics.redRectRotation * Math.PI) / 180,
          color: '#ff4d4d',
          opacity: 1
        })
      }
      if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null && metrics.greenRectLength && metrics.greenRectWidth && metrics.greenRectRotation != null) {
        rods.push({
          cx: metrics.greenRectCenterX,
          cy: metrics.greenRectCenterY,
          longLen: metrics.greenRectLength,
          shortLen: metrics.greenRectWidth,
          angleDeg: metrics.greenRectRotation,
          angleRad: (metrics.greenRectRotation * Math.PI) / 180,
          color: '#00ff6a',
          opacity: 1
        })
      }
      displayDetectedRods.value = rods

      // 组装 SVG 圆心指示器（红/蓝/绿）
      const centers: CenterMarker[] = []
      if (redCircleCenter.value) centers.push({ x: redCircleCenter.value.x, y: redCircleCenter.value.y, color: '#ff4d4d' })
      if (blueCircleCenter.value) centers.push({ x: blueCircleCenter.value.x, y: blueCircleCenter.value.y, color: '#2e6bff' })
      if (greenCircleCenter.value) centers.push({ x: greenCircleCenter.value.x, y: greenCircleCenter.value.y, color: '#00ff6a' })
      displayCenters.value = centers

      // 组装 SVG 预测细杆（渐隐）
      const prods: Rod[] = []
      if (
        predictionsRaw && predictionsRaw.length > 0 &&
        redCircleCenter.value && greenCircleCenter.value &&
        redRadius != null && greenRadius != null
      ) {
        const gh = predictionsRaw.length
        const steps = Math.min(15, gh)

        // 小工具：角度归一化到 [0, 360)
        const normDeg = (deg: number) => ((deg % 360) + 360) % 360
        // 小工具：若模型输出的 pos_sin/pos_cos 有数值漂移，先归一化
        const normVec = (c: number, s: number) => {
          const len = Math.hypot(c, s)
          if (len > 1e-6) return [c / len, s / len] as const
          return [c, s] as const
        }

        for (let i = 0; i < steps; i++) {
          const row = predictionsRaw[i]
          if (!row || row.length < 9) continue
          // "delta_time", "green_angle_sin", "green_angle_cos", "red_angle_sin", "red_angle_cos", "green_pos_sin", "green_pos_cos", "red_pos_sin", "red_pos_cos"
          const g_as = Number(row[1])
          const g_ac = Number(row[2])
          const r_as = Number(row[3])
          const r_ac = Number(row[4])
          const g_ps = Number(row[5])
          const g_pc = Number(row[6])
          const r_ps = Number(row[7])
          const r_pc = Number(row[8])

          // 渐隐：越远未来越淡
          const a = Math.max(0, 1 - i / steps)

          // 以拟合圆心 + 半径 + 预测的 位置角（sin/cos）反解中心坐标
          const [g_pc_n, g_ps_n] = normVec(g_pc, g_ps)
          const gx = greenCircleCenter.value.x + (greenRadius as number) * g_pc_n
          const gy = greenCircleCenter.value.y + (greenRadius as number) * g_ps_n
          // 预测杆朝向角：由 (sin, cos) 反解角度（直接用于 SVG，y 轴向下时无需额外翻转）
          const gang = normDeg((Math.atan2(g_as, g_ac) * 180) / Math.PI)
          const gL = (metrics.greenRectLength && metrics.greenRectLength > 0) ? metrics.greenRectLength : 40
          const gW = (metrics.greenRectWidth && metrics.greenRectWidth > 0) ? metrics.greenRectWidth : 4
          prods.push({ cx: gx, cy: gy, longLen: gL as number, shortLen: gW as number, angleDeg: gang, angleRad: (gang * Math.PI) / 180, color: '#00ff6a', opacity: a })

          const [r_pc_n, r_ps_n] = normVec(r_pc, r_ps)
          const rx = redCircleCenter.value.x + (redRadius as number) * r_pc_n
          const ry = redCircleCenter.value.y + (redRadius as number) * r_ps_n
          const rang = normDeg((Math.atan2(r_as, r_ac) * 180) / Math.PI)
          const rL = (metrics.redRectLength && metrics.redRectLength > 0) ? metrics.redRectLength : 40
          const rW = (metrics.redRectWidth && metrics.redRectWidth > 0) ? metrics.redRectWidth : 4
          prods.push({ cx: rx, cy: ry, longLen: rL as number, shortLen: rW as number, angleDeg: rang, angleRad: (rang * Math.PI) / 180, color: '#ff4d4d', opacity: a })
        }
      }
      displayPredictedRods.value = prods
    }

    // 本帧绘制完成后再启动 fetch（若需要）
/*     if (shouldStartFetch) {
      isFetching.value = true
      const payload = framesBuffer.slice() // 不改动缓冲
      fetch('/api/predict', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ frames: payload })
      })
        .then(async (res) => {
          if (!res.ok) throw new Error('预测接口错误: ' + res.status)
          const data = await res.json()
          if (Array.isArray(data?.predictions)) {
            predictionsRaw = data.predictions as number[][]
            predictionHorizon = Number(data?.horizon ?? predictionsRaw.length)
          } else {
            predictionsRaw = null
            predictionHorizon = 0
          }
        })
        .catch(() => {  })
        .finally(() => { isFetching.value = false })
    }
 */  } catch (e: any) {
    // 单帧异常不中断，显示在 HUD
    err.value = e?.message ?? String(e)
  }

  rafId = requestAnimationFrame(loop)
}

async function toggle() {
  if (running.value) {
    await stopStream()
  } else {
    // 不阻塞摄像头启动，让 CV 就绪在后台进行
    ensureCVReady()
    if (sourceType.value === 'camera') {
      await startStream()
    } else {
      await startFilePlayback()
    }
  }
}

async function switchFacing() {
  facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
  if (running.value) {
    await startStream()
  }
}

function onPickFile(e: Event) {
  const input = e.target as HTMLInputElement
  const f = input.files && input.files[0]
  if (!f) return
  // 回收旧 URL
  if (fileUrl.value) {
    URL.revokeObjectURL(fileUrl.value)
    fileUrl.value = null
  }
  fileUrl.value = URL.createObjectURL(f)
  fileName.value = f.name
  // 若当前处于文件模式且已点击启动，则自动重启播放
  if (sourceType.value === 'file' && running.value) {
    startFilePlayback()
  }
}

// 当切换输入源时，根据状态进行处理
watch(
  () => sourceType.value,
  async () => {
    // 切换源：如果正在运行就切换到对应启动；否则仅停止，等待用户点击“启动”
    if (running.value) {
      if (sourceType.value === 'camera') {
        await startStream()
      } else {
        await startFilePlayback()
      }
    } else {
      await stopStream()
    }
  }
)

onMounted(async () => {
  // 后台等待 CV 就绪
  ensureCVReady()
  // 自动启动（可改为手动）
  await startStream()
})

onBeforeUnmount(async () => {
  await stopStream()
  // 统一释放文件 URL
  if (fileUrl.value) {
    URL.revokeObjectURL(fileUrl.value)
    fileUrl.value = null
  }
  ctx = null
})
</script>

<template>
  <div class="wrap">
    <div class="stage">
      <!-- 隐藏视频元素仅用于抓帧 -->
      <video ref="videoEl" playsinline muted autoplay
        class="hidden-video"></video>

      <!-- 可见的绘制画布（可通过 HUD 开关隐藏） -->
      <canvas ref="canvasEl" class="canvas" v-show="showCanvas"></canvas>

      <!-- SVG 叠加层：用于渲染细杆（检测与预测） -->
      <svg class="overlay" :width="viewW" :height="viewH"
        :viewBox="`0 0 ${viewW} ${viewH}`">
        <!-- 圆心指示器（SVG 实现） -->
        <g>
          <g v-for="(c, i) in displayCenters" :key="'c-' + i">
            <circle :cx="c.x" :cy="c.y" r="4" :fill="c.color" />
            <line :x1="c.x - 8" :y1="c.y" :x2="c.x + 8" :y2="c.y"
              :stroke="c.color" stroke-width="2" />
            <line :x1="c.x" :y1="c.y - 8" :x2="c.x" :y2="c.y + 8"
              :stroke="c.color" stroke-width="2" />
          </g>
        </g>
        <!-- 检测到的细杆（实色、全不透明） -->
        <g>
          <g v-for="(rod, i) in displayDetectedRods" :key="'d-' + i"
            :opacity="rod.opacity ?? 1">
            <rect :x="rod.cx - rod.longLen / 2" :y="rod.cy - rod.shortLen / 2"
              :width="rod.longLen" :height="rod.shortLen"
              :transform="`rotate(${rod.angleDeg} ${rod.cx} ${rod.cy})`"
              fill="none" :stroke="rod.color" stroke-width="2" />
            <circle :cx="rod.cx" :cy="rod.cy" r="3" :fill="rod.color" />
            <line :x1="rod.cx" :y1="rod.cy"
              :x2="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.4)"
              :y2="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.4)"
              :stroke="rod.color" stroke-width="2" />
          </g>
        </g>
        <!-- 预测细杆（渐隐） -->
        <g>
          <g v-for="(rod, i) in displayPredictedRods" :key="'p-' + i"
            :opacity="rod.opacity ?? 1">
            <rect :x="rod.cx - rod.longLen / 2" :y="rod.cy - rod.shortLen / 2"
              :width="rod.longLen" :height="rod.shortLen"
              :transform="`rotate(${rod.angleDeg} ${rod.cx} ${rod.cy})`"
              fill="none" :stroke="rod.color" stroke-width="2" />
            <circle :cx="rod.cx" :cy="rod.cy" r="3" :fill="rod.color" />
            <line :x1="rod.cx" :y1="rod.cy"
              :x2="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.4)"
              :y2="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.4)"
              :stroke="rod.color" stroke-width="2" />
            <!-- 预测帧序号（从 1 开始），标注在杆的前端稍远处 -->
            <text
              :x="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.5 + 8)"
              :y="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.5 + 8)"
              :fill="rod.color"
              stroke="black"
              stroke-width="0.8"
              font-size="10"
              dominant-baseline="middle"
              text-anchor="middle"
            >{{ i + 1 }}</text>
          </g>
        </g>
      </svg>

      <!-- HUD 面板 -->
      <div class="hud">
        <div class="row">
          <label class="label">来源：</label>
          <select class="select" v-model="sourceType">
            <option value="camera">摄像头</option>
            <option value="file">本地视频</option>
          </select>
        </div>
        <div class="row" v-if="sourceType === 'file'">
          <input ref="fileInputEl" class="file-input" type="file"
            accept="video/*" @change="onPickFile" />
        </div>
        <div class="row small" v-if="sourceType === 'file'">
          <label class="checkbox">
            <input type="checkbox" v-model="fileLoop" /> 循环播放
          </label>
        </div>
        <div class="row">
          <button class="btn" @click="toggle()">
            {{ running ? '停止' : '启动' }}
          </button>
          <button class="btn" v-if="sourceType === 'camera'"
            @click="switchFacing()">
            切换摄像头（{{ facingModeLabel }}）
          </button>
          <button class="btn" :disabled="!running" @click="toggleRecord()">
            {{ isRecording ? '停止并下载' : '开始记录' }}
          </button>
        </div>
        <div class="row small">
          <span>FPS: {{ fps.toFixed(1) }}</span>
          <span v-if="isRecording">记录中…</span>
          <span v-if="err" class="err">错误：{{ err }}</span>
        </div>
        <div class="row small">
          <label class="checkbox">
            <input type="checkbox" v-model="showCanvas" /> 显示底层画面
          </label>
        </div>
        <div class="metrics">
          <div class="group">
            <h4>Red</h4>
            <div>x: {{ fmt(metrics.redRectCenterX) }}, y: {{
              fmt(metrics.redRectCenterY) }}</div>
            <div>θ: {{ fmt(metrics.redRectRotation) }}°</div>
            <div>L×W: {{ fmt(metrics.redRectLength) }} × {{
              fmt(metrics.redRectWidth) }}</div>
            <div>{{ redPolar?.cos.toFixed(2) }} {{ redPolar?.sin.toFixed(2) }}
            </div>
          </div>
          <div class="group">
            <h4>Green</h4>
            <div>x: {{ fmt(metrics.greenRectCenterX) }}, y: {{
              fmt(metrics.greenRectCenterY) }}</div>
            <div>θ: {{ fmt(metrics.greenRectRotation) }}°</div>
            <div>L×W: {{ fmt(metrics.greenRectLength) }} × {{
              fmt(metrics.greenRectWidth) }}</div>
          </div>
          <div class="group">
            <h4>Blue</h4>
            <div>x: {{ fmt(metrics.blueRectCenterX) }}, y: {{
              fmt(metrics.blueRectCenterY) }}</div>
            <div>θ: {{ fmt(metrics.blueRectRotation) }}°</div>
            <div>L×W: {{ fmt(metrics.blueRectLength) }} × {{
              fmt(metrics.blueRectWidth) }}</div>
            <div>{{ bluePolar?.cos.toFixed(2) }} {{ bluePolar?.sin.toFixed(2) }}
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>


<style scoped>
.wrap {
  display: grid;
  gap: 12px;
}

.stage {
  position: relative;
  border-radius: 12px;
}

.hidden-video {
  display: none;
  /* 仅用于抓帧即可 */
}

.canvas {
  display: block;
  width: 100%;
  height: auto;
  image-rendering: crisp-edges;
}

/* SVG 覆盖层：绝对定位于画布之上 */
.overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;

  /* 低于 HUD，高于画布 */
}

.hud {
  position: absolute;
  top: 12px;
  right: 12px;
  display: grid;
  gap: 8px;
  background: rgba(0, 0, 0, 0.25);
  color: #e9eef7;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  z-index: 3;
  /* 确保在 SVG 之上 */
}

.hud .row {
  display: flex;
  align-items: center;
  gap: 8px;
  justify-content: space-between;
}

.hud .row.small {
  font-size: 12px;
  opacity: 0.9;
  justify-content: flex-start;
  gap: 16px;
}

.label {
  font-size: 12px;
  opacity: 0.9;
}

.select {
  flex: 1;
  padding: 4px 6px;
  border-radius: 6px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.06);
  color: #e9eef7;
}

.file-input {
  flex: 1;
}

.file-name {
  flex: 1;
  font-size: 12px;
  color: #c9d4e5;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.checkbox {
  display: flex;
  align-items: center;
  gap: 6px;
}

.metrics {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px 12px;
  font-size: 10px;
  line-height: 1.35;
}

.metrics .group h4 {
  margin: 0 0 4px 0;
  font-size: 12px;
  font-weight: 700;
  opacity: 0.9;
}

.err {
  color: #ffb4b4;
}

.btn {
  appearance: none;
  background: #1a5cff;
  color: white;
  border: none;
  padding: 6px 10px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
}

.btn:hover {
  filter: brightness(1.05);
}

.hint {
  font-size: 12px;
  margin: 0;
}
</style>