import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PULSE_EDGE_PX, PULSE_SPAN_PX } from './geometry';
import * as css from './StreamHeader.css';

// ─── The approved ring field, byte-for-byte ──────────────────────────
// 10 rings; each lives one 4s ease-out pass: scale 0.05 → 1 of a
// 760²-box circle (r 380, stroke 2 SCALING with the ring — 0.1px when
// tiny, a crisp 2px at full size), opacity 0 → 0.26 (at 10%) → 0.13
// (at 65%) → 0, spawn stagger 0.4s. DO NOT RETUNE — see geometry.ts::
// PULSE_SPAN_PX for why the box size is also load-bearing.
const LOOP_MS = 4000;
const RING_COUNT = 10;
const STAGGER_MS = 400;
const RING_RADIUS_PX = PULSE_SPAN_PX / 2;
const STROKE_PX = 2;
const STROKE_STYLE = 'rgb(248, 80, 160)';
const MIN_SCALE = 0.05;

// Lifetime of a one-shot echo ring — the 4s pulse plus a small buffer
// before its entry is pruned from React state.
const ECHO_LIFETIME_MS = 4100;

// CSS `ease-out` = cubic-bezier(0, 0, 0.58, 1). x(u) is strictly
// monotonic on [0, 1], so a fixed-depth bisection inverts time → u and
// the eased progress is y(u). 20 iterations ≈ 1e-6 — and a frame does
// ~2 solves per ring, noise next to the raster cost of the frame.
function easeOut(t: number): number {
  if (t <= 0) return 0;
  if (t >= 1) return 1;
  let lo = 0;
  let hi = 1;
  for (let i = 0; i < 20; i += 1) {
    const u = (lo + hi) / 2;
    const x = 3 * (1 - u) * u * u * 0.58 + u * u * u;
    if (x < t) lo = u;
    else hi = u;
  }
  const u = (lo + hi) / 2;
  return 3 * (1 - u) * u * u + u * u * u;
}

// The approved opacity keyframes. Per CSS keyframe semantics the
// timing function applies PER SEGMENT (between consecutive keyframes
// that specify the property), so each span below gets its own ease-out
// of the local progress — exactly what the old `ringKeyframes` did.
const OPACITY_STOPS: ReadonlyArray<readonly [number, number]> = [
  [0, 0],
  [0.1, 0.26],
  [0.65, 0.13],
  [1, 0],
];
function ringAlpha(phase: number): number {
  for (let i = 1; i < OPACITY_STOPS.length; i += 1) {
    const [k1, a1] = OPACITY_STOPS[i];
    if (phase <= k1) {
      const [k0, a0] = OPACITY_STOPS[i - 1];
      return a0 + (a1 - a0) * easeOut((phase - k0) / (k1 - k0));
    }
  }
  return 0;
}

// One-shot echo ring spawned by a mascot tap. `startedAt` is a
// performance.now() timestamp — the same timebase as the rAF clock the
// field draws from, so an echo's phase is absolute: a host remount
// mid-flight RESUMES the echo where it was instead of replaying it
// (the old CSS-animation circles restarted from 0 on remount; resuming
// is the closer match to «one pass per tap»).
export type EchoPulse = { id: number; startedAt: number };

// Tap-to-poke echo state. Each `spawnEcho` call appends a one-shot
// ring; a timer prunes it once its pulse has faded. Hosted by the
// component that renders the `RadarPulse` (StreamHeader, or
// PagerRefreshSingleton in pager mode) so the echoes live exactly as
// long as their host's reveal window.
export function useEchoes(): { echoes: EchoPulse[]; spawnEcho: () => void } {
  const [echoes, setEchoes] = useState<EchoPulse[]>([]);
  const echoIdRef = useRef(0);
  const spawnEcho = useCallback(() => {
    const id = echoIdRef.current;
    echoIdRef.current += 1;
    setEchoes((prev) => [...prev, { id, startedAt: performance.now() }]);
    window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x.id !== id)), ECHO_LIFETIME_MS);
  }, []);
  return { echoes, spawnEcho };
}

type RadarPulseProps = {
  // One-shot echo ring ids + spawn timestamps (see `useEchoes`). Each
  // renders a single non-looping pulse identical to an ambient ring.
  echoes: EchoPulse[];
};

// The radar-pulse ring field — pink concentric rings rippling out from
// the mascot centre. ONE transparent 2D <canvas>, fully redrawn every
// rAF (10 stroked arcs + echoes).
//
// WHY A CANVAS (tile-memory postmortem, Jun 2026 — device-measured and
// verified against Chromium sources at tags 120/130/137):
//
// The previous live CSS-animated SVG circles blew the WebView tile
// budget. A running compositor animation is a DIRECT compositing
// reason on the animated element itself — SVG children included
// (`compositing_reason_finder.cc::DirectReasonsForSVGChildPaint
// Properties`, kActiveTransformAnimation; opacity keyframes alike via
// kActiveOpacityAnimation; on by default since M89). So EACH <circle>
// got its own cc layer, rastered at the animation's peak to-screen
// scale (scale 1 × dpr 3 ⇒ 2286² device px each; `picture_layer_impl
// .cc::AdjustRasterScaleForTransformAnimation` — and the viewport-area
// raster clamp sits at max(vw,vh)² = 2340², which 2286² legally fits
// under). cc's solid-colour analysis cannot cull a single tile of such
// a layer: the analyzer bails on ANY DrawOval/DrawPath op it sees
// (`solid_color_analyzer.cc`), and the big ring's op bbox covers the
// whole box — so all on-screen tiles (544×608×4B GPU-raster tiles on
// this device, NOT 256²) held real textures: ≈10–11 MB × 10 rings ≈
// +110 MB against WebView's budget of exactly 20 × 4B × viewport px
// rounded up to 5 MiB ≈ 204.5 MB (`browser_view_renderer.cc`).
// Shuttle-swiping with 1–3 curtains parked at the refresh snap then
// evicted CURTAIN tiles first («tile memory limits exceeded» ×100–600
// per 14s session; curtains flashed backdrop, rings showed through).
//
// The canvas collapses all of that to ONE cc TextureLayer: its buffers
// (~7 MB × 2–3 in flight, copy-on-write SharedImages) are client
// resources that NEVER enter the tile-manager budget, and the GL total
// is ~5–7× below the SVG field. Researched-and-rejected alternatives:
// animating `r`/`stroke-width`/`stroke-opacity` (keeps one layer but
// re-rasters the field's ~20 tiles every frame on the pending tree —
// 37–53 MB while animating); inflating the box to trip cc's raster
// clamp (mathematically can't help — the clamp scales DOWN onto the
// 2340² threshold, ≥ today's raster, and any will-change ancestor
// restores native scale outright); `animation-play-state: paused` and
// pre-baked video (owner-vetoed on device). The svg root's own
// translateZ(0) was always irrelevant — promotion was per-circle.
//
// CONTRACT — every rule below guards a verified failure mode:
//   • context options exactly `{alpha: true, willReadFrequently:
//     false}`; NEVER add `desynchronized: true` — on WebView it swaps
//     the TextureLayer for a SurfaceLayerBridge whose surface may
//     never activate (the historical «opaque white canvas over
//     everything»), and WebView has no low-latency overlay path anyway
//     (kWebViewSurfaceControl off).
//   • never call getImageData/putImageData/toDataURL on this canvas —
//     two readbacks silently demote it to CPU raster permanently.
//   • the backing store is device-pixel exact (css size × dpr), set
//     only from the ResizeObserver — a CSS-px backing would GPU-
//     upscale 3× and visibly blur the 2px strokes.
//   • the rAF loop lives on the main thread: a long route-commit task
//     freezes the field for the task's duration, then it catches up to
//     wall clock (rings advance ~2.5% of their loop per 100ms, so
//     commit-length stalls read as continuous; the multi-second
//     freezes the owner vetoed came from `animation-play-state:
//     paused`, a different class). If stalls ever grow past ~150ms,
//     the escalation path is moving THIS SAME draw loop into a worker
//     via OffscreenCanvas — not back to DOM animations.
//
// Geometry contract lives on `stagePulseCanvas` (StreamHeader.css.ts):
// the canvas spans the host's width from the screen top down to the
// field's bottom edge, and the draw centre is recovered from the box
// itself — cx = width/2, cy = height − (PULSE_SPAN_PX/2 +
// PULSE_EDGE_PX) ⇒ exactly safe-top + PULSE_CENTER_Y_PX, the same
// centre the old svg box had in both hosts.
export function RadarPulse({ echoes }: RadarPulseProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // Latest echoes for the draw loop without restarting it — the loop
  // effect deliberately has no deps.
  const echoesRef = useRef<EchoPulse[]>(echoes);
  echoesRef.current = echoes;

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return undefined;
    const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
    if (!ctx) return undefined;

    const dpr = window.devicePixelRatio || 1;
    let cssWidth = 0;
    let cssHeight = 0;
    const resize = () => {
      const { clientWidth, clientHeight } = canvas;
      if (!clientWidth || !clientHeight) return;
      cssWidth = clientWidth;
      cssHeight = clientHeight;
      canvas.width = Math.round(clientWidth * dpr);
      canvas.height = Math.round(clientHeight * dpr);
      // Resizing resets all context state — restore the css-px
      // coordinate space and the stroke colour.
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.strokeStyle = STROKE_STYLE;
    };
    // Sized once synchronously (the host mounts this visible, never
    // display:none) + re-sized on rotation/viewport changes.
    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(canvas);

    const drawRing = (cx: number, cy: number, phase: number) => {
      const s = MIN_SCALE + (1 - MIN_SCALE) * easeOut(phase);
      ctx.globalAlpha = ringAlpha(phase);
      ctx.lineWidth = STROKE_PX * s;
      ctx.beginPath();
      ctx.arc(cx, cy, RING_RADIUS_PX * s, 0, Math.PI * 2);
      // A full-sweep arc() is an OPEN contour in Blink (two 180° arcs
      // with coincident butt caps) — closePath() joins it so the cap
      // edges can't double-blend into a seam at the start angle.
      ctx.closePath();
      ctx.stroke();
    };

    let raf = 0;
    let mountTs: number | null = null;
    const frame = (now: number) => {
      raf = window.requestAnimationFrame(frame);
      if (!cssWidth || !cssHeight) return;
      // Ambient ring k starts STAGGER_MS·k after the first frame, then
      // loops — the same field the old per-circle animation-delays
      // produced on mount.
      if (mountTs === null) mountTs = now;
      const t = now - mountTs;
      ctx.clearRect(0, 0, cssWidth, cssHeight);
      const cx = cssWidth / 2;
      const cy = cssHeight - (RING_RADIUS_PX + PULSE_EDGE_PX);
      for (let k = 0; k < RING_COUNT; k += 1) {
        const local = t - k * STAGGER_MS;
        if (local >= 0) drawRing(cx, cy, (local % LOOP_MS) / LOOP_MS);
      }
      echoesRef.current.forEach(({ startedAt }) => {
        const local = now - startedAt;
        if (local >= 0 && local < LOOP_MS) drawRing(cx, cy, local / LOOP_MS);
      });
    };
    raf = window.requestAnimationFrame(frame);

    return () => {
      window.cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, []);

  return <canvas ref={canvasRef} className={css.stagePulseCanvas} aria-hidden="true" />;
}
