/**
 * Motion descriptor derivation.
 *
 * Given a motion capture (video + keyframes + per-frame metrics from
 * Playwright), compute structural descriptors the critic can compare
 * across sites. All descriptors are quantitative — no aesthetic
 * judgment in this module.
 */

import type { MotionDescriptors } from './types.js'

/**
 * Per-frame observation collected during Playwright capture.
 * Callers (capturer.ts) fill these as they scroll + hover the page.
 */
export interface FrameSample {
  timestampMs: number
  scrollY: number
  primaryTransform?: string
  framePaintedMs: number
}

export interface HoverTiming {
  triggerAtMs: number
  firstResponseAtMs?: number
  settleAtMs?: number
}

export interface CanvasMeasurement {
  width: number
  height: number
  viewportWidth: number
  viewportHeight: number
}

export interface DescriptorInputs {
  durationMs: number
  frames: readonly FrameSample[]
  hover?: HoverTiming
  canvases: readonly CanvasMeasurement[]
}

/**
 * A 20-curve bank for "nearest named easing" classification.
 * Sourced from widely-used curated banks (Material, IBM, CSS built-ins).
 * Each entry is [P1x, P1y, P2x, P2y].
 */
export const EASING_BANK: Readonly<Record<string, readonly [number, number, number, number]>> = {
  'ease-linear': [0, 0, 1, 1],
  ease: [0.25, 0.1, 0.25, 1],
  'ease-in': [0.42, 0, 1, 1],
  'ease-out': [0, 0, 0.58, 1],
  'ease-in-out': [0.42, 0, 0.58, 1],
  'ease-out-quad': [0.25, 0.46, 0.45, 0.94],
  'ease-out-cubic': [0.215, 0.61, 0.355, 1],
  'ease-out-quart': [0.165, 0.84, 0.44, 1],
  'ease-out-quint': [0.23, 1, 0.32, 1],
  'ease-out-expo': [0.19, 1, 0.22, 1],
  'ease-out-circ': [0.075, 0.82, 0.165, 1],
  'ease-out-back': [0.175, 0.885, 0.32, 1.275],
  'ease-in-out-quad': [0.455, 0.03, 0.515, 0.955],
  'ease-in-out-cubic': [0.645, 0.045, 0.355, 1],
  'ease-in-out-quart': [0.77, 0, 0.175, 1],
  'ease-in-out-expo': [1, 0, 0, 1],
  'material-standard': [0.4, 0, 0.2, 1],
  'material-decelerate': [0, 0, 0.2, 1],
  'material-accelerate': [0.4, 0, 1, 1],
  'ibm-productive': [0.2, 0, 0.38, 0.9],
}

export function deriveDescriptors(inputs: DescriptorInputs): MotionDescriptors {
  const scrollLinkedRatio = computeScrollLinkedRatio(inputs.frames)
  const { fpsMedian, fpsVariance } = computeFpsStats(inputs.frames)
  const canvasCoverage = computeCanvasCoverage(inputs.canvases)
  const animationDensity = computeAnimationDensity(inputs.frames)
  const hoverLatencyMs = computeHoverLatency(inputs.hover)
  const hoverDurationMs = computeHoverDuration(inputs.hover)
  const primaryEasing = inferPrimaryEasing(inputs.frames)

  return {
    durationMs: inputs.durationMs,
    keyframeCount: inputs.frames.length,
    scrollLinkedRatio,
    hoverLatencyMs,
    hoverDurationMs,
    primaryEasing,
    fpsVariance,
    fpsMedian,
    canvasCoverage,
    animationDensity,
  }
}

function computeScrollLinkedRatio(frames: readonly FrameSample[]): number {
  // Accumulate absolute transform-delta divided by absolute scroll-delta
  // across consecutive frames. Only consider transforms whose numeric Y
  // component actually changed.
  let transformDelta = 0
  let scrollDelta = 0

  for (let i = 1; i < frames.length; i++) {
    const prev = frames[i - 1]!
    const curr = frames[i]!
    const dScroll = Math.abs(curr.scrollY - prev.scrollY)
    if (dScroll < 1) continue

    const dTransform = Math.abs(
      parseTransformY(curr.primaryTransform) - parseTransformY(prev.primaryTransform),
    )
    scrollDelta += dScroll
    transformDelta += dTransform
  }

  if (scrollDelta === 0) return 0
  return transformDelta / scrollDelta
}

function parseTransformY(transform: string | undefined): number {
  if (!transform) return 0
  // matrix(a, b, c, d, tx, ty) or translate3d(x, y, z) or translateY(...)
  const matrix = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
  if (matrix && matrix[1]) {
    const parts = matrix[1].split(',').map((s) => Number(s.trim()))
    if (parts.length === 6) return parts[5] ?? 0
    if (parts.length === 16) return parts[13] ?? 0
  }

  const translate = transform.match(/translate(?:3d|Y)?\(([^)]+)\)/)
  if (translate && translate[1]) {
    const parts = translate[1].split(',').map((s) => Number.parseFloat(s.trim()))
    if (parts.length === 1) return 0 // translateX only
    return parts[1] ?? 0
  }

  return 0
}

function computeFpsStats(frames: readonly FrameSample[]): {
  fpsMedian: number
  fpsVariance: number
} {
  if (frames.length < 2) return { fpsMedian: 0, fpsVariance: 0 }

  const deltas: number[] = []
  for (let i = 1; i < frames.length; i++) {
    const dt = frames[i]!.timestampMs - frames[i - 1]!.timestampMs
    if (dt > 0) deltas.push(1000 / dt)
  }

  if (deltas.length === 0) return { fpsMedian: 0, fpsVariance: 0 }

  const sorted = [...deltas].sort((a, b) => a - b)
  const mid = Math.floor(sorted.length / 2)
  const fpsMedian =
    sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2 : (sorted[mid] ?? 0)

  const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length
  const fpsVariance = deltas.reduce((acc, v) => acc + (v - mean) ** 2, 0) / deltas.length

  return { fpsMedian, fpsVariance }
}

function computeCanvasCoverage(canvases: readonly CanvasMeasurement[]): number {
  if (canvases.length === 0) return 0

  let covered = 0
  let viewport = 0
  for (const c of canvases) {
    covered += c.width * c.height
    viewport = Math.max(viewport, c.viewportWidth * c.viewportHeight)
  }

  if (viewport === 0) return 0
  return Math.min(1, covered / viewport)
}

function computeAnimationDensity(frames: readonly FrameSample[]): number {
  if (frames.length === 0) return 0

  let changing = 0
  for (let i = 1; i < frames.length; i++) {
    if (frames[i]!.primaryTransform !== frames[i - 1]!.primaryTransform) {
      changing++
    }
  }
  return changing / Math.max(1, frames.length - 1)
}

function computeHoverLatency(hover?: HoverTiming): number | undefined {
  if (!hover || hover.firstResponseAtMs === undefined) return undefined
  return Math.max(0, hover.firstResponseAtMs - hover.triggerAtMs)
}

function computeHoverDuration(hover?: HoverTiming): number | undefined {
  if (!hover || hover.settleAtMs === undefined || hover.firstResponseAtMs === undefined) {
    return undefined
  }
  return Math.max(0, hover.settleAtMs - hover.firstResponseAtMs)
}

function inferPrimaryEasing(frames: readonly FrameSample[]): string | undefined {
  // Reconstruct a normalized 0..1 position curve from primary transform Y
  // across frames, then find the closest named easing via sampled points.
  if (frames.length < 4) return undefined

  const ys = frames.map((f) => parseTransformY(f.primaryTransform))
  const min = Math.min(...ys)
  const max = Math.max(...ys)
  if (max - min < 1) return undefined

  const normalized = ys.map((y) => (y - min) / (max - min))
  return nearestEasing(normalized)
}

function nearestEasing(observed: readonly number[]): string | undefined {
  let bestName: string | undefined
  let bestDistance = Number.POSITIVE_INFINITY

  for (const [name, control] of Object.entries(EASING_BANK)) {
    const sampled = sampleBezier(control, observed.length)
    const distance = sumSquaredError(observed, sampled)
    if (distance < bestDistance) {
      bestDistance = distance
      bestName = name
    }
  }

  return bestName
}

function sampleBezier(
  control: readonly [number, number, number, number],
  samples: number,
): number[] {
  // Standard cubic bezier with P0=(0,0), P3=(1,1) and the two control points.
  // For each sample t in 0..1 we solve for x(t)≈sampleT, then return y(t).
  const [x1, y1, x2, y2] = control
  const out: number[] = []
  for (let i = 0; i < samples; i++) {
    const target = i / Math.max(1, samples - 1)
    const t = solveCubicBezierX(target, x1, x2)
    out.push(cubicBezierY(t, y1, y2))
  }
  return out
}

function cubicBezierX(t: number, x1: number, x2: number): number {
  const u = 1 - t
  return 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t
}

function cubicBezierY(t: number, y1: number, y2: number): number {
  const u = 1 - t
  return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t
}

function solveCubicBezierX(target: number, x1: number, x2: number, iterations = 8): number {
  let t = target
  for (let i = 0; i < iterations; i++) {
    const x = cubicBezierX(t, x1, x2)
    const dx = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2)
    if (Math.abs(dx) < 1e-6) break
    t -= (x - target) / dx
    t = Math.max(0, Math.min(1, t))
  }
  return t
}

function sumSquaredError(a: readonly number[], b: readonly number[]): number {
  const n = Math.min(a.length, b.length)
  let sum = 0
  for (let i = 0; i < n; i++) {
    const av = a[i] ?? 0
    const bv = b[i] ?? 0
    sum += (av - bv) ** 2
  }
  return sum
}
