/**
 * Playwright-driven capture of a live site.
 *
 * Produces:
 *   - shot.webp      — full-page static screenshot at 1440×
 *   - motion.webm    — 8-second scripted interaction (scroll + hover)
 *   - keyframes/*.webp — 10 stills extracted from motion.webm via ffmpeg
 *   - per-frame samples forwarded to descriptors.ts
 *
 * Playwright is loaded via dynamic import so SANDCODE can ship without
 * forcing the ~300 MB install on every user. If absent, the capturer
 * throws with a user-facing message.
 */

import { join } from 'path'
import { spawn } from 'child_process'
import { ensureSiteDir } from './storage.js'
import type { AwwwardsSite, IngestRunConfig } from './types.js'
import type { CanvasMeasurement, FrameSample, HoverTiming } from './descriptors.js'

const VIEWPORT = { width: 1440, height: 900 }
const CAPTURE_DURATION_MS = 8000
const KEYFRAME_COUNT = 10
const FRAME_SAMPLE_INTERVAL_MS = 100

export interface CaptureResult {
  staticFile: string
  motionFile: string
  keyframeFiles: string[]
  durationMs: number
  frames: FrameSample[]
  hover: HoverTiming | undefined
  canvases: CanvasMeasurement[]
  errors: string[]
}

export async function captureSite(
  site: AwwwardsSite,
  config: IngestRunConfig,
): Promise<CaptureResult> {
  const playwright = await loadPlaywright()
  const errors: string[] = []

  const siteDir = await ensureSiteDir(site.slug)
  const staticFile = join(siteDir, 'shot.webp')
  const motionFile = join(siteDir, 'motion.webm')

  const browser = await playwright.chromium.launch({ headless: true })
  try {
    const context = await browser.newContext({
      viewport: VIEWPORT,
      deviceScaleFactor: 1,
      userAgent: config.userAgent,
      recordVideo: { dir: siteDir, size: VIEWPORT },
    })

    const page = await context.newPage()
    const navStart = Date.now()

    try {
      await page.goto(site.liveUrl, { waitUntil: 'networkidle', timeout: 30_000 })
    } catch (error: unknown) {
      errors.push(`navigation-timeout: ${error instanceof Error ? error.message : String(error)}`)
    }

    // Settle tick for hero animations that trigger on load.
    await page.waitForTimeout(1200)

    const fullPageShotBuffer = await page.screenshot({
      fullPage: true,
      type: 'png',
    })

    await saveAsWebp(fullPageShotBuffer, staticFile, errors)

    const { frames, hover, canvases } = await recordMotion(page, errors)

    const videoHandle = page.video()
    await page.close()
    await context.close()

    let finalMotionFile = motionFile
    if (videoHandle) {
      try {
        await videoHandle.saveAs(motionFile)
      } catch (error: unknown) {
        errors.push(`video-save-failed: ${error instanceof Error ? error.message : String(error)}`)
      }
    } else {
      finalMotionFile = ''
    }

    const keyframeFiles =
      finalMotionFile && (await fileExists(finalMotionFile))
        ? await extractKeyframes(finalMotionFile, siteDir, errors)
        : []

    return {
      staticFile,
      motionFile: finalMotionFile,
      keyframeFiles,
      durationMs: Date.now() - navStart,
      frames,
      hover,
      canvases,
      errors,
    }
  } finally {
    await browser.close()
  }
}

interface PlaywrightModule {
  chromium: {
    launch: (opts: { headless: boolean }) => Promise<{
      newContext: (opts: unknown) => Promise<{
        newPage: () => Promise<PlaywrightPage>
        close: () => Promise<void>
      }>
      close: () => Promise<void>
    }>
  }
}

interface PlaywrightPage {
  goto: (url: string, opts: { waitUntil: string; timeout: number }) => Promise<unknown>
  waitForTimeout: (ms: number) => Promise<void>
  screenshot: (opts: { fullPage: boolean; type: string }) => Promise<Buffer>
  mouse: {
    move: (x: number, y: number) => Promise<void>
    wheel: (dx: number, dy: number) => Promise<void>
  }
  locator: (selector: string) => {
    first: () => { hover: () => Promise<void>; count: () => Promise<number> }
  }
  evaluate: <T>(fn: string | ((arg?: unknown) => T)) => Promise<T>
  video: () => { saveAs: (path: string) => Promise<void> } | null
  close: () => Promise<void>
}

async function loadPlaywright(): Promise<PlaywrightModule> {
  try {
    // Playwright is an optional peer dependency. Installers opting out of
    // the eye ingester don't need the ~300 MB browser bundle. The dynamic
    // import is hidden from TypeScript's module resolver so the build
    // does not hard-require it.
    const specifier = 'playwright'
    const mod = (await import(/* @vite-ignore */ specifier)) as unknown as PlaywrightModule
    return mod
  } catch {
    throw new Error(
      'Eye ingester requires playwright. Install it with: `bun add playwright && bunx playwright install chromium`',
    )
  }
}

async function recordMotion(
  page: PlaywrightPage,
  errors: string[],
): Promise<{
  frames: FrameSample[]
  hover: HoverTiming | undefined
  canvases: CanvasMeasurement[]
}> {
  const startMs = Date.now()
  const frames: FrameSample[] = []
  let hover: HoverTiming | undefined

  // Start a scripted interaction and sample frames on a timer.
  // The interaction script runs concurrently with the sampler.
  const sampler = (async () => {
    while (Date.now() - startMs < CAPTURE_DURATION_MS) {
      const sample = await sampleFrame(page, startMs).catch(() => null)
      if (sample) frames.push(sample)
      await page.waitForTimeout(FRAME_SAMPLE_INTERVAL_MS)
    }
  })()

  const interaction = (async () => {
    try {
      await page.mouse.move(VIEWPORT.width / 2, VIEWPORT.height / 2)
      await page.waitForTimeout(1500)

      await page.mouse.wheel(0, 1500)
      await page.waitForTimeout(2000)

      const target = page
        .locator('a[href]:visible, button:visible, [role="button"]:visible')
        .first()

      const hoverTriggerMs = Date.now() - startMs
      try {
        const count = await target.count()
        if (count > 0) {
          await target.hover()
          hover = { triggerAtMs: hoverTriggerMs }
          await page.waitForTimeout(150)
          hover.firstResponseAtMs = Date.now() - startMs
          await page.waitForTimeout(1350)
          hover.settleAtMs = Date.now() - startMs
        }
      } catch (error: unknown) {
        errors.push(`hover-failed: ${error instanceof Error ? error.message : String(error)}`)
      }

      await page.mouse.wheel(0, 2000)
    } catch (error: unknown) {
      errors.push(`interaction-failed: ${error instanceof Error ? error.message : String(error)}`)
    }
  })()

  await Promise.all([sampler, interaction])

  const canvases = await measureCanvases(page, errors)

  return { frames, hover, canvases }
}

async function sampleFrame(page: PlaywrightPage, startMs: number): Promise<FrameSample | null> {
  try {
    const sample = await page.evaluate<{
      scrollY: number
      primaryTransform?: string
      framePaintedMs: number
    }>(() => {
      const heroSelectors = ['[data-hero]', 'main > section:first-child', 'header', 'h1']
      let primary: Element | null = null
      for (const sel of heroSelectors) {
        primary = document.querySelector(sel)
        if (primary) break
      }
      const primaryTransform = primary ? getComputedStyle(primary).transform : undefined
      return {
        scrollY: window.scrollY,
        primaryTransform: primaryTransform === 'none' ? undefined : primaryTransform,
        framePaintedMs: performance.now(),
      }
    })

    return {
      timestampMs: Date.now() - startMs,
      scrollY: sample.scrollY,
      primaryTransform: sample.primaryTransform,
      framePaintedMs: sample.framePaintedMs,
    }
  } catch {
    return null
  }
}

async function measureCanvases(
  page: PlaywrightPage,
  errors: string[],
): Promise<CanvasMeasurement[]> {
  try {
    return await page.evaluate<CanvasMeasurement[]>(() => {
      const results: CanvasMeasurement[] = []
      const nodes = document.querySelectorAll('canvas, video')
      const vw = window.innerWidth
      const vh = window.innerHeight
      for (const node of Array.from(nodes)) {
        const rect = (node as HTMLElement).getBoundingClientRect()
        if (rect.width > 0 && rect.height > 0) {
          results.push({
            width: rect.width,
            height: rect.height,
            viewportWidth: vw,
            viewportHeight: vh,
          })
        }
      }
      return results
    })
  } catch (error: unknown) {
    errors.push(`canvas-measure-failed: ${error instanceof Error ? error.message : String(error)}`)
    return []
  }
}

async function saveAsWebp(png: Buffer, outPath: string, errors: string[]): Promise<void> {
  try {
    await runFfmpeg(['-y', '-i', 'pipe:0', '-vf', 'scale=iw:ih', '-q:v', '80', outPath], png)
  } catch (error: unknown) {
    errors.push(`webp-convert-failed: ${error instanceof Error ? error.message : String(error)}`)
  }
}

async function extractKeyframes(
  motionFile: string,
  siteDir: string,
  errors: string[],
): Promise<string[]> {
  const dir = join(siteDir, 'keyframes')
  const pattern = join(dir, '%02d.webp')

  try {
    await runFfmpeg([
      '-y',
      '-i',
      motionFile,
      '-vf',
      `select=not(mod(n\\,floor(N/${KEYFRAME_COUNT})))`,
      '-vsync',
      'vfr',
      '-q:v',
      '80',
      pattern,
    ])
  } catch (error: unknown) {
    errors.push(
      `keyframe-extract-failed: ${error instanceof Error ? error.message : String(error)}`,
    )
    return []
  }

  const files: string[] = []
  for (let i = 0; i < KEYFRAME_COUNT; i++) {
    const f = join(dir, `${i.toString().padStart(2, '0')}.webp`)
    if (await fileExists(f)) files.push(f)
  }
  return files
}

async function runFfmpeg(args: readonly string[], stdin?: Buffer): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    const proc = spawn('ffmpeg', args, { stdio: ['pipe', 'ignore', 'pipe'] })
    let stderr = ''
    proc.stderr.on('data', (chunk) => {
      stderr += String(chunk)
    })
    proc.on('error', (err) => {
      reject(new Error(`ffmpeg spawn failed: ${err.message}`))
    })
    proc.on('close', (code) => {
      if (code === 0) resolve()
      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`))
    })

    if (stdin) {
      proc.stdin.end(stdin)
    } else {
      proc.stdin.end()
    }
  })
}

async function fileExists(path: string): Promise<boolean> {
  try {
    const { stat } = await import('fs/promises')
    await stat(path)
    return true
  } catch {
    return false
  }
}
