/**
 * Scroll-linked keyframe animation (T212): normalisation of the `_scroll_kf` setting, interpolation between
 * keyframes, easing, 3D rotation composition and the optional CSS `animation-timeline: view()` fast path.
 *
 * Setting value (control type `scrollKeyframes`):
 *   {enabled, anchor: self|parent|page, start: 0-100, end: 0-100, devices: Device[],
 *    keyframes: [{_id, at: 0-100, x?, y?, scale?, rotate?, tiltX?, tiltY?, opacity?, blur?, easing?}]}
 *
 * It travels inside `data-zx-motion` (key `kf`, see motion-config.ts / MotionAttrs.php) and is driven by the
 * motion runtime's rAF loop (src/frontend/motion.ts), composed with the other scrolling / mouse effects.
 */
import type { Device } from './motion-config';

const DEVICES: Device[] = ['desktop', 'tablet', 'mobile'];

export const KF_PROPS = ['x', 'y', 'scale', 'rotate', 'tiltX', 'tiltY', 'opacity', 'blur'] as const;
export type KfProp = (typeof KF_PROPS)[number];
export const KF_EASINGS = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'] as const;
export type KfEasing = (typeof KF_EASINGS)[number];
export const KF_ANCHORS = ['self', 'parent', 'page'] as const;
export type KfAnchor = (typeof KF_ANCHORS)[number];
export const MAX_KEYFRAMES = 20;

/** [min, max, default] of every property. */
export const KF_RANGE: Record<KfProp, [number, number, number]> = {
  x: [-2000, 2000, 0],
  y: [-2000, 2000, 0],
  scale: [0, 10, 1],
  rotate: [-3600, 3600, 0],
  tiltX: [-180, 180, 0],
  tiltY: [-180, 180, 0],
  opacity: [0, 1, 1],
  blur: [0, 100, 0],
};

export type Keyframe = Partial<Record<KfProp, number>> & { at: number; easing?: KfEasing };

export interface KeyframesConfig {
  /** viewport range (%) mapped onto keyframes 0-100 */
  start: number;
  end: number;
  anchor?: KfAnchor;
  devices?: Device[];
  frames: Keyframe[];
}

const on = (v: unknown): boolean => v === true || v === 'true' || v === 1 || v === '1';
function num(v: unknown, min: number, max: number, digits = 2): number | null {
  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
  if (!Number.isFinite(n)) return null;
  const f = 10 ** digits;
  const r = Math.round(Math.min(max, Math.max(min, n)) * f) / f;
  return r === 0 ? 0 : r;
}

/** Normalises the setting; null when disabled or with fewer than two keyframes. Mirrors MotionAttrs::keyframes(). */
export function keyframesConfig(v: unknown): KeyframesConfig | null {
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
  const s = v as Record<string, unknown>;
  if (!on(s.enabled)) return null;
  const byAt = new Map<number, Keyframe>();
  for (const raw of (Array.isArray(s.keyframes) ? s.keyframes : []).slice(0, MAX_KEYFRAMES * 2)) {
    if (!raw || typeof raw !== 'object') continue;
    const k = raw as Record<string, unknown>;
    const at = num(k.at, 0, 100, 1);
    if (at === null) continue;
    const frame: Keyframe = { at };
    for (const p of KF_PROPS) {
      const [min, max] = KF_RANGE[p];
      const n = num(k[p], min, max, p === 'opacity' || p === 'scale' ? 3 : 2);
      if (n !== null) frame[p] = n;
    }
    if (typeof k.easing === 'string' && (KF_EASINGS as readonly string[]).includes(k.easing) && k.easing !== 'linear') frame.easing = k.easing as KfEasing;
    byAt.set(at, frame); // same position: the last one wins
  }
  const frames = [...byAt.values()].sort((a, b) => a.at - b.at).slice(0, MAX_KEYFRAMES);
  if (frames.length < 2) return null;
  const out: KeyframesConfig = { start: num(s.start, 0, 100) ?? 0, end: num(s.end, 0, 100) ?? 100, frames };
  const anchor = typeof s.anchor === 'string' && (KF_ANCHORS as readonly string[]).includes(s.anchor) ? (s.anchor as KfAnchor) : 'self';
  if (anchor !== 'self') out.anchor = anchor;
  if (Array.isArray(s.devices)) {
    const devices = DEVICES.filter((d) => (s.devices as unknown[]).includes(d));
    if (devices.length < DEVICES.length) out.devices = devices;
  }
  // key order: start, end, anchor?, devices?, frames (same as PHP)
  return { start: out.start, end: out.end, ...(out.anchor ? { anchor: out.anchor } : {}), ...(out.devices ? { devices: out.devices } : {}), frames: out.frames };
}

/* ---- easing ---- */

const BEZIER: Record<Exclude<KfEasing, 'linear'>, [number, number, number, number]> = {
  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],
};

/** cubic-bezier(x1, y1, x2, y2) evaluated at x (Newton + bisection, same curve as CSS). */
export function cubicBezier(x1: number, y1: number, x2: number, y2: number, x: number): number {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  const cx = 3 * x1;
  const bx = 3 * (x2 - x1) - cx;
  const ax = 1 - cx - bx;
  const cy = 3 * y1;
  const by = 3 * (y2 - y1) - cy;
  const ay = 1 - cy - by;
  const sx = (t: number): number => ((ax * t + bx) * t + cx) * t;
  const sy = (t: number): number => ((ay * t + by) * t + cy) * t;
  const dx = (t: number): number => (3 * ax * t + 2 * bx) * t + cx;
  let t = x;
  for (let i = 0; i < 8; i++) {
    const err = sx(t) - x;
    if (Math.abs(err) < 1e-6) return sy(t);
    const d = dx(t);
    if (Math.abs(d) < 1e-6) break;
    t -= err / d;
  }
  let lo = 0;
  let hi = 1;
  t = x;
  for (let i = 0; i < 30; i++) {
    const v = sx(t);
    if (Math.abs(v - x) < 1e-6) break;
    if (v < x) lo = t;
    else hi = t;
    t = (lo + hi) / 2;
  }
  return sy(t);
}

export function ease(easing: KfEasing | undefined, t: number): number {
  if (!easing || easing === 'linear') return Math.min(1, Math.max(0, t));
  const [a, b, c, d] = BEZIER[easing];
  return cubicBezier(a, b, c, d, t);
}

/* ---- interpolation ---- */

const round = (v: number, digits = 3): number => {
  const f = 10 ** digits;
  const r = Math.round(v * f) / f;
  return r === 0 ? 0 : r;
};

/**
 * Values of every property at local progress `p` (0-100). Each property is interpolated between the keyframes
 * that define it (like CSS keyframes); the easing of the earlier keyframe shapes the segment. Before the first /
 * after the last defining keyframe the value holds. Properties no keyframe defines are absent.
 */
export function sampleKeyframes(frames: Keyframe[], p: number): Partial<Record<KfProp, number>> {
  const out: Partial<Record<KfProp, number>> = {};
  for (const prop of KF_PROPS) {
    const track = frames.filter((f) => f[prop] !== undefined);
    if (!track.length) continue;
    if (p <= track[0].at) {
      out[prop] = track[0][prop]!;
      continue;
    }
    const last = track[track.length - 1];
    if (p >= last.at) {
      out[prop] = last[prop]!;
      continue;
    }
    for (let i = 0; i < track.length - 1; i++) {
      const a = track[i];
      const b = track[i + 1];
      if (p >= a.at && p <= b.at) {
        const span = b.at - a.at;
        const t = span > 0 ? ease(a.easing, (p - a.at) / span) : 1;
        out[prop] = round(a[prop]! + (b[prop]! - a[prop]!) * t);
        break;
      }
    }
  }
  return out;
}

/* ---- 3D rotation ---- */

type Quat = [number, number, number, number]; // x y z w
const axisQuat = (x: number, y: number, z: number, deg: number): Quat => {
  const h = (deg * Math.PI) / 360;
  const s = Math.sin(h);
  return [x * s, y * s, z * s, Math.cos(h)];
};
const mul = (a: Quat, b: Quat): Quat => [
  a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
  a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
  a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
  a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];

/**
 * `rotateX(rx) rotateY(ry) rotateZ(rz)` as one CSS `rotate` value (axis + angle), so the individual `rotate`
 * property can carry a 3D tilt without touching the element's `transform`. '' for no rotation.
 */
export function composeRotation(rx: number, ry: number, rz: number): string {
  if (!rx && !ry) return rz ? `${round(rz, 2)}deg` : '';
  const q = mul(mul(axisQuat(1, 0, 0, rx), axisQuat(0, 1, 0, ry)), axisQuat(0, 0, 1, rz));
  const w = Math.min(1, Math.max(-1, q[3]));
  const angle = (2 * Math.acos(w) * 180) / Math.PI;
  const s = Math.sqrt(1 - w * w);
  if (s < 1e-6 || angle < 1e-4) return '';
  return `${round(q[0] / s, 4)} ${round(q[1] / s, 4)} ${round(q[2] / s, 4)} ${round(angle, 2)}deg`;
}

/* ---- progress ---- */

/** Page scroll progress in % (0 at the top, 100 at the bottom). */
export function pageProgress(scrollY: number, docHeight: number, viewportHeight: number): number {
  const max = docHeight - viewportHeight;
  if (max <= 0) return 0;
  return Math.min(100, Math.max(0, (scrollY / max) * 100));
}

/** Whether a config uses the 3D tilt (the element's parent then needs a perspective). */
export function usesTilt(cfg: KeyframesConfig): boolean {
  return cfg.frames.some((f) => (f.tiltX ?? 0) !== 0 || (f.tiltY ?? 0) !== 0);
}

/* ---- CSS scroll-driven animation fast path ---- */

export interface StyleValues {
  translate: string;
  rotate: string;
  scale: string;
  opacity: string;
  filter: string;
}

/** Style of a sample on its own (used by the CSS keyframes; the rAF path composes through motion-math). */
export function sampleStyle(s: Partial<Record<KfProp, number>>): StyleValues {
  const x = s.x ?? 0;
  const y = s.y ?? 0;
  return {
    translate: x || y ? `${round(x, 2)}px ${round(y, 2)}px` : s.x !== undefined || s.y !== undefined ? '0px 0px' : '',
    rotate: composeRotation(s.tiltX ?? 0, s.tiltY ?? 0, s.rotate ?? 0) || (s.rotate !== undefined || s.tiltX !== undefined || s.tiltY !== undefined ? '0deg' : ''),
    scale: s.scale !== undefined ? String(s.scale) : '',
    opacity: s.opacity !== undefined ? String(s.opacity) : '',
    filter: s.blur !== undefined ? `blur(${s.blur}px)` : '',
  };
}

/**
 * `@keyframes` + rule for `animation-timeline: view()` (browsers with scroll-driven animations). Every keyframe
 * carries the fully sampled values, so it renders the same as the JS path at the keyframes themselves.
 */
export function keyframesCss(name: string, selector: string, cfg: KeyframesConfig): string {
  const blocks = cfg.frames.map((f) => {
    const st = sampleStyle(sampleKeyframes(cfg.frames, f.at));
    const decl = (Object.entries(st) as Array<[string, string]>).filter(([, v]) => v !== '').map(([k, v]) => `${k}:${v}`);
    if (f.easing) decl.push(`animation-timing-function:${f.easing}`);
    return `${f.at}%{${decl.join(';')}}`;
  });
  const first = cfg.frames[0].at;
  const last = cfg.frames[cfg.frames.length - 1].at;
  if (first > 0) blocks.unshift(`0%{${blockOf(cfg, first)}}`);
  if (last < 100) blocks.push(`100%{${blockOf(cfg, last)}}`);
  const range = `cover ${cfg.start}% cover ${Math.max(cfg.start, cfg.end)}%`;
  return `@keyframes ${name}{${blocks.join('')}}${selector}{animation:${name} linear both;animation-timeline:view();animation-range:${range}}`;
}
function blockOf(cfg: KeyframesConfig, at: number): string {
  const st = sampleStyle(sampleKeyframes(cfg.frames, at));
  return (Object.entries(st) as Array<[string, string]>)
    .filter(([, v]) => v !== '')
    .map(([k, v]) => `${k}:${v}`)
    .join(';');
}
