/**
 * Unit tests for motion descriptor derivation.
 *
 * Descriptors are pure — given identical inputs they must produce
 * identical outputs regardless of environment. Tests lock the
 * math so refactors can't silently break the critic's reference frame.
 */

import { describe, expect, it } from 'vitest'
import { deriveDescriptors, EASING_BANK, type FrameSample } from '../descriptors.js'

function makeFrames(samples: Array<{ t: number; y: number; transformY?: number }>): FrameSample[] {
  return samples.map((s) => ({
    timestampMs: s.t,
    scrollY: s.y,
    primaryTransform:
      s.transformY !== undefined ? `translate3d(0px, ${s.transformY}px, 0px)` : undefined,
    framePaintedMs: s.t,
  }))
}

describe('deriveDescriptors', () => {
  it('returns zeros for an empty capture', () => {
    const result = deriveDescriptors({
      durationMs: 0,
      frames: [],
      canvases: [],
    })

    expect(result.durationMs).toBe(0)
    expect(result.keyframeCount).toBe(0)
    expect(result.scrollLinkedRatio).toBe(0)
    expect(result.fpsMedian).toBe(0)
    expect(result.canvasCoverage).toBe(0)
    expect(result.animationDensity).toBe(0)
  })

  it('computes scroll-linked ratio from transform-to-scroll delta', () => {
    // Element translates 50px per 500px of scroll = 0.1 ratio
    const frames = makeFrames([
      { t: 0, y: 0, transformY: 0 },
      { t: 100, y: 500, transformY: 50 },
      { t: 200, y: 1000, transformY: 100 },
    ])

    const result = deriveDescriptors({
      durationMs: 200,
      frames,
      canvases: [],
    })

    expect(result.scrollLinkedRatio).toBeCloseTo(0.1, 2)
  })

  it('ignores scroll samples with no movement', () => {
    const frames = makeFrames([
      { t: 0, y: 0, transformY: 0 },
      { t: 100, y: 0, transformY: 20 }, // transform changed but no scroll
      { t: 200, y: 500, transformY: 50 },
    ])

    const result = deriveDescriptors({
      durationMs: 200,
      frames,
      canvases: [],
    })

    // Only the second window contributes: 30px transform / 500px scroll = 0.06
    expect(result.scrollLinkedRatio).toBeCloseTo(0.06, 2)
  })

  it('detects median FPS close to target sample rate', () => {
    // 60 FPS → 16.67ms between frames
    const frames: FrameSample[] = []
    for (let i = 0; i < 30; i++) {
      frames.push({
        timestampMs: Math.round(i * 16.67),
        scrollY: 0,
        framePaintedMs: i * 16.67,
      })
    }

    const result = deriveDescriptors({
      durationMs: 500,
      frames,
      canvases: [],
    })

    expect(result.fpsMedian).toBeGreaterThan(55)
    expect(result.fpsMedian).toBeLessThan(65)
  })

  it('measures canvas coverage relative to viewport', () => {
    const result = deriveDescriptors({
      durationMs: 100,
      frames: [],
      canvases: [
        {
          width: 720,
          height: 450,
          viewportWidth: 1440,
          viewportHeight: 900,
        },
      ],
    })

    // 720×450 / 1440×900 = 0.25
    expect(result.canvasCoverage).toBeCloseTo(0.25, 2)
  })

  it('clamps canvas coverage to [0, 1]', () => {
    const result = deriveDescriptors({
      durationMs: 100,
      frames: [],
      canvases: [
        {
          width: 3000,
          height: 3000,
          viewportWidth: 1440,
          viewportHeight: 900,
        },
      ],
    })

    expect(result.canvasCoverage).toBe(1)
  })

  it('computes hover timings when all three markers are present', () => {
    const result = deriveDescriptors({
      durationMs: 3000,
      frames: [],
      canvases: [],
      hover: {
        triggerAtMs: 1000,
        firstResponseAtMs: 1100,
        settleAtMs: 1400,
      },
    })

    expect(result.hoverLatencyMs).toBe(100)
    expect(result.hoverDurationMs).toBe(300)
  })

  it('leaves hover timings undefined when markers are missing', () => {
    const result = deriveDescriptors({
      durationMs: 3000,
      frames: [],
      canvases: [],
      hover: { triggerAtMs: 1000 },
    })

    expect(result.hoverLatencyMs).toBeUndefined()
    expect(result.hoverDurationMs).toBeUndefined()
  })

  it('classifies an ease-out-expo-shaped motion as ease-out-expo', () => {
    // Sample the ease-out-expo curve at frame-spaced times and feed
    // back as transform positions.
    const [x1, y1, x2, y2] = EASING_BANK['ease-out-expo']!
    const frames: FrameSample[] = []
    const N = 40
    for (let i = 0; i < N; i++) {
      const t = i / (N - 1)
      // Sample y at approximately t on the curve (cheap: treat t as x)
      const u = 1 - t
      const y = 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t
      frames.push({
        timestampMs: i * 20,
        scrollY: 0,
        primaryTransform: `translate3d(0px, ${Math.round(y * 100)}px, 0px)`,
        framePaintedMs: i * 20,
      })
    }

    const result = deriveDescriptors({ durationMs: 800, frames, canvases: [] })

    // Accept any ease-out family curve — the classifier should not
    // pick a generic ease-in or linear for a motion that clearly
    // accelerates early and decelerates late.
    expect(result.primaryEasing).toBeDefined()
    expect(result.primaryEasing).toMatch(/ease-out|material-decelerate/)
  })

  it('computes animation density as the fraction of frames with transform change', () => {
    const frames: FrameSample[] = [
      { timestampMs: 0, scrollY: 0, primaryTransform: 'translate3d(0,0,0)', framePaintedMs: 0 },
      { timestampMs: 100, scrollY: 0, primaryTransform: 'translate3d(0,0,0)', framePaintedMs: 100 },
      {
        timestampMs: 200,
        scrollY: 0,
        primaryTransform: 'translate3d(0,10,0)',
        framePaintedMs: 200,
      },
      {
        timestampMs: 300,
        scrollY: 0,
        primaryTransform: 'translate3d(0,20,0)',
        framePaintedMs: 300,
      },
      {
        timestampMs: 400,
        scrollY: 0,
        primaryTransform: 'translate3d(0,20,0)',
        framePaintedMs: 400,
      },
    ]

    const result = deriveDescriptors({ durationMs: 400, frames, canvases: [] })

    // 2 frames changed out of 4 deltas = 0.5
    expect(result.animationDensity).toBeCloseTo(0.5, 2)
  })
})
