import * as THREE from 'three';

type Enum = { rawValue?: number; name?: string };
export type RuntimeKey = { time: number; valueRaw: number | number[]; forwardRaw?: number | number[]; backwardRaw?: number | number[] };
export type RuntimeKeyData = { interpolationType: Enum; keys: RuntimeKey[] };
export type RuntimeController = {
  blockIndex: number; targetBlockIndex?: number; frequencyRaw: number; phaseRaw: number;
  startTimeRaw: number; stopTimeRaw: number;
  flags: { active?: boolean; cycleType?: Enum; playBackwards?: boolean };
};
export type RuntimePath = {
  controller: RuntimeController;
  interpolator: { pathFlagsRaw: number; constantVelocity: boolean };
  pathData: RuntimeKeyData; percentData: RuntimeKeyData;
  targetParentSourceWorldRows: number[][]; targetSourceLocalRows: number[][]; targetSourceWorldRows: number[][];
  affectedNodes?: { blockIndex: number }[];
};
export interface ReferenceRuntimeSource {
  sourcePath: string; sha256: string;
  pathControllers: RuntimePath[];
  affectedNodes?: { blockIndex: number; pathControllerBlockIndex?: number }[];
  billboards?: unknown[]; rawParticleSystems?: unknown[];
}
export interface ReferenceRuntimeCatalog { schemaVersion: string; sources: ReferenceRuntimeSource[] }

export const nifToWeb = new THREE.Matrix4().makeRotationX(-Math.PI / 2)
  .multiply(new THREE.Matrix4().makeScale(0.1, 0.1, 0.1));
const webToNif = nifToWeb.clone().invert();
export function matrixRows(rows: number[][]): THREE.Matrix4 {
  if (rows.length !== 4 || rows.some(row => row.length !== 4 || row.some(v => !Number.isFinite(v)))) {
    throw new Error('INVALID_RUNTIME_SOURCE_MATRIX');
  }
  return new THREE.Matrix4().set(...rows.flat() as [number, number, number, number, number, number, number, number,
    number, number, number, number, number, number, number, number]);
}
export function webDelta(sourceDelta: THREE.Matrix4): THREE.Matrix4 {
  return nifToWeb.clone().multiply(sourceDelta).multiply(webToNif);
}
export function runtimeControllerTime(controller: RuntimeController, elapsed: number): number | null {
  if (controller.flags.active === false) return null;
  const { startTimeRaw: start, stopTimeRaw: stop } = controller;
  if (![start, stop, controller.frequencyRaw, controller.phaseRaw, elapsed].every(Number.isFinite)) return null;
  let time = elapsed * controller.frequencyRaw + controller.phaseRaw;
  const length = stop - start;
  if (length <= 0) return start;
  const cycle = controller.flags.cycleType?.rawValue ?? 0;
  if (time < start || time > stop) {
    if (cycle === 0) time = start + ((time - start) % length + length) % length;
    else if (cycle === 1) {
      const phase = ((time - start) % (length * 2) + length * 2) % (length * 2);
      time = start + (phase <= length ? phase : length * 2 - phase);
    } else if (cycle === 2) time = THREE.MathUtils.clamp(time, start, stop);
    else return null;
  }
  return controller.flags.playBackwards ? stop - (time - start) : time;
}
function components(value: number | number[] | undefined): number[] | null {
  const result = typeof value === 'number' ? [value] : value;
  return result?.length && result.every(Number.isFinite) ? result : null;
}
export function sampleRuntimeKeys(data: RuntimeKeyData, time: number): number[] | null {
  const keys = data.keys;
  if (!keys.length || !Number.isFinite(time)) return null;
  if (time <= keys[0].time) return components(keys[0].valueRaw);
  if (time >= keys[keys.length - 1].time) return components(keys[keys.length - 1].valueRaw);
  let index = 0;
  while (index + 1 < keys.length && keys[index + 1].time < time) index++;
  const a = keys[index], b = keys[index + 1];
  const av = components(a.valueRaw), bv = components(b.valueRaw);
  if (!av || !bv || av.length !== bv.length || b.time <= a.time) return null;
  const u = (time - a.time) / (b.time - a.time);
  const mode = data.interpolationType.rawValue;
  if (mode === 5) return [...av];
  if (mode === 1) return av.map((v, i) => THREE.MathUtils.lerp(v, bv[i], u));
  if (mode !== 2) return null;
  const at = components(a.backwardRaw), bt = components(b.forwardRaw);
  if (!at || !bt || at.length !== av.length || bt.length !== av.length) return null;
  const u2 = u * u, u3 = u2 * u;
  // NIF QUADRATIC tangents use a.Backward / b.Forward, in normalized segment units.
  return av.map((v, i) => (2 * u3 - 3 * u2 + 1) * v + (u3 - 2 * u2 + u) * at[i]
    + (-2 * u3 + 3 * u2) * bv[i] + (u3 - u2) * bt[i]);
}
type PathRuntime = {
  source: RuntimePath; originalWorldInverse: THREE.Matrix4; originalLocal: THREE.Matrix4;
  parentWorld: THREE.Matrix4; affected: Set<number>;
  arc: { percent: number; length: number }[]; totalLength: number;
};
export function createEffectPathRuntime(source?: ReferenceRuntimeSource) {
  const diagnostics: string[] = [];
  const paths: PathRuntime[] = [];
  for (const path of source?.pathControllers ?? []) {
    try {
      // Bank/follow/flip require rotation reconstruction; translation-only paths are explicit here.
      if (path.interpolator.pathFlagsRaw & (8 | 32 | 64)) {
        diagnostics.push(`UNSUPPORTED_PATH_ROTATION_FLAGS:${path.controller.blockIndex}`); continue;
      }
      if (![1, 2, 5].includes(path.pathData.interpolationType.rawValue ?? -1)
          || ![1, 2, 5].includes(path.percentData.interpolationType.rawValue ?? -1)) {
        diagnostics.push(`UNSUPPORTED_PATH_INTERPOLATION:${path.controller.blockIndex}`); continue;
      }
      const runtime: PathRuntime = {
        source: path, originalWorldInverse: matrixRows(path.targetSourceWorldRows).invert(),
        originalLocal: matrixRows(path.targetSourceLocalRows), parentWorld: matrixRows(path.targetParentSourceWorldRows),
        affected: new Set((path.affectedNodes ?? source?.affectedNodes ?? [])
          .filter(node => !('pathControllerBlockIndex' in node) || node.pathControllerBlockIndex === undefined
            || node.pathControllerBlockIndex === path.controller.blockIndex).map(node => node.blockIndex)), arc: [], totalLength: 0,
      };
      runtime.affected.add(path.controller.targetBlockIndex ?? -1);
      let previous: THREE.Vector3 | undefined;
      // Numerically invert arc length for CONSTANT_VELOCITY without changing authored spline geometry.
      for (let i = 0; i <= 1024; i++) {
        const percent = i / 1024;
        const sample = sampleRuntimeKeys(path.pathData, percent);
        if (!sample || sample.length !== 3) throw new Error('INVALID_PATH_VECTOR_KEYS');
        const point = new THREE.Vector3(...sample as [number, number, number]);
        if (previous) runtime.totalLength += point.distanceTo(previous);
        runtime.arc.push({ percent, length: runtime.totalLength }); previous = point;
      }
      if (!(runtime.totalLength > 0)) throw new Error('ZERO_LENGTH_PATH');
      paths.push(runtime);
    } catch (error) { diagnostics.push(String(error)); }
  }
  function positionAt(runtime: PathRuntime, elapsed: number): THREE.Vector3 | null {
    const time = runtimeControllerTime(runtime.source.controller, elapsed);
    if (time === null) return null;
    const percentValue = sampleRuntimeKeys(runtime.source.percentData, time)?.[0];
    if (percentValue === undefined || !Number.isFinite(percentValue)) return null;
    let percent = runtime.source.interpolator.pathFlagsRaw & 2
      ? THREE.MathUtils.clamp(percentValue, 0, 1) : ((percentValue % 1) + 1) % 1;
    if (runtime.source.interpolator.constantVelocity) {
      const distance = percent * runtime.totalLength;
      let low = 0, high = runtime.arc.length - 1;
      while (high - low > 1) {
        const middle = Math.floor((low + high) / 2);
        if (runtime.arc[middle].length < distance) low = middle; else high = middle;
      }
      const a = runtime.arc[low], b = runtime.arc[high];
      percent = THREE.MathUtils.lerp(a.percent, b.percent, (distance - a.length) / (b.length - a.length));
    }
    const value = sampleRuntimeKeys(runtime.source.pathData, percent);
    return value?.length === 3 ? new THREE.Vector3(...value as [number, number, number]) : null;
  }
  function deltaForNode(blockIndex: number, elapsed: number): THREE.Matrix4 {
    const delta = new THREE.Matrix4();
    for (const runtime of paths) {
      if (!runtime.affected.has(blockIndex)) continue;
      const position = positionAt(runtime, elapsed);
      if (!position) continue;
      const local = runtime.originalLocal.clone().setPosition(position);
      delta.multiply(runtime.parentWorld.clone().multiply(local).multiply(runtime.originalWorldInverse));
    }
    return delta;
  }
  return { diagnostics, controllerBlocks: paths.map(path => path.source.controller.blockIndex), deltaForNode,
    arcLengthResolution: 1024, gameParityValidated: false as const };
}
export type EffectPathRuntime = ReturnType<typeof createEffectPathRuntime>;
