import * as THREE from 'three';
import type { EffectPathRuntime } from './effect-path';
import { matrixRows, nifToWeb } from './effect-path';
import type { OriginEffectMesh, RawEnum } from './effects-format';

export interface OriginEffectBillboardSource {
  blockIndex: number;
  sourceWorldRows: number[][];
  billboardMode?: RawEnum & { lowBitsRaw?: number; lowBitsName?: string };
  descendantMeshBlockIndices?: number[];
}

export interface OriginEffectBillboardBinding {
  object: THREE.Mesh;
  root: THREE.Group;
  sourceMesh: OriginEffectMesh;
}

export interface OriginEffectBillboardRuntime {
  readonly diagnostics: string[];
  tick(camera: THREE.Camera, elapsed: number): void;
}

type PreparedBillboard = {
  binding: OriginEffectBillboardBinding;
  sourceNode: OriginEffectBillboardSource;
  sourceNodeWorld: THREE.Matrix4;
  sourceMeshWorld: THREE.Matrix4;
  originalMeshWorld: THREE.Matrix4;
  inverseOriginalMeshWorld: THREE.Matrix4;
  decodedMode: number;
};

function finite(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value);
}

function raw(value?: RawEnum): number | undefined {
  return finite(value?.rawValue) ? value.rawValue : undefined;
}

function matrixRowsOrNull(rows?: number[][]): THREE.Matrix4 | null {
  if (!rows || rows.length !== 4 || rows.some(row => row.length !== 4 || row.some(value => !finite(value)))) return null;
  return matrixRows(rows);
}

function hasPositiveZNormalsInBillboardSpace(
  mesh: OriginEffectMesh,
  sourceNodeWorld: THREE.Matrix4,
  sourceMeshWorld: THREE.Matrix4,
): boolean {
  const normals = mesh.normalsNif;
  if (!Array.isArray(normals) || normals.length === 0) return false;
  const relativeTransform = sourceNodeWorld.clone().invert().multiply(sourceMeshWorld);
  if (!finite(relativeTransform.determinant()) || Math.abs(relativeTransform.determinant()) < 1e-12) return false;
  const normalTransform = new THREE.Matrix3().getNormalMatrix(relativeTransform);
  return normals.every(normal => {
    if (normal.length < 3 || !normal.slice(0, 3).every(finite)) return false;
    const inBillboardSpace = new THREE.Vector3(normal[0], normal[1], normal[2])
      .applyMatrix3(normalTransform).normalize();
    return Math.abs(inBillboardSpace.x) <= 1e-5 && Math.abs(inBillboardSpace.y) <= 1e-5 &&
      Math.abs(inBillboardSpace.z - 1) <= 1e-5;
  });
}

function decodeMode(node: OriginEffectBillboardSource, rawMode: number): { decoded: number; diagnostic?: string } {
  const lowBits = finite(node.billboardMode?.lowBitsRaw) ? node.billboardMode.lowBitsRaw : rawMode & 0x07;
  const modeName = node.billboardMode?.lowBitsName ?? (lowBits === 2 ? 'RIGID_FACE_CAMERA' : undefined);
  if (rawMode > 7 && lowBits === 2) {
    return {
      decoded: lowBits,
      diagnostic: `BILLBOARD_LOW_BITS_DECODED:raw=${rawMode}:name=${node.billboardMode?.name ?? 'unknown'}:decoded=${lowBits}:${modeName ?? 'RIGID_FACE_CAMERA'}:engineParity=false`,
    };
  }
  return { decoded: rawMode };
}

/**
 * Apply NIF billboard source mode 2 (RIGID_FACE_CAMERA) only when the mesh
 * normal transformed through its child-to-billboard matrix remains +Z. The
 * extracted vertex arrays remain untouched; only the preview Object3D matrix changes.
 */
export function createOriginEffectBillboardRuntime(
  bindings: readonly OriginEffectBillboardBinding[],
  sourceNodes: readonly OriginEffectBillboardSource[],
  pathRuntime?: Pick<EffectPathRuntime, 'deltaForNode'>,
): OriginEffectBillboardRuntime {
  const diagnostics: string[] = [];
  const prepared: PreparedBillboard[] = [];
  const sourceByBlock = new Map(sourceNodes.map(node => [node.blockIndex, node]));
  const viewFromSource = nifToWeb.clone();

  for (const binding of bindings) {
    const ancestors = (binding.sourceMesh.ancestorNodes ?? []).filter(node => node.type === 'NiBillboardNode');
    if (ancestors.length === 0) continue;
    if (ancestors.length > 1) {
      diagnostics.push(`NESTED_BILLBOARD_NODES_UNSUPPORTED:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    const ancestor = ancestors[0];
    const blockIndex = ancestor.blockIndex;
    if (!finite(blockIndex)) {
      diagnostics.push(`BILLBOARD_BLOCK_INDEX_MISSING:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    const sourceNode = sourceByBlock.get(blockIndex);
    if (!sourceNode) {
      diagnostics.push(`BILLBOARD_SOURCE_FRAME_MISSING:block=${blockIndex}:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    const rawMode = raw(ancestor.billboardMode) ?? raw(sourceNode.billboardMode);
    if (!finite(rawMode)) {
      diagnostics.push(`BILLBOARD_MODE_MISSING:block=${blockIndex}`);
      continue;
    }
    const mode = decodeMode(sourceNode, rawMode);
    if (mode.diagnostic) diagnostics.push(mode.diagnostic);
    if (mode.decoded !== 2) {
      diagnostics.push(`BILLBOARD_MODE_UNSUPPORTED:raw=${rawMode}:decoded=${mode.decoded}:block=${blockIndex}`);
      continue;
    }
    const sourceNodeWorld = matrixRowsOrNull(sourceNode.sourceWorldRows);
    if (!sourceNodeWorld) {
      diagnostics.push(`BILLBOARD_NODE_SOURCE_MATRIX_INVALID:block=${blockIndex}`);
      continue;
    }
    const sourceMeshWorld = matrixRowsOrNull(binding.sourceMesh.sourceMatrixWorldRows);
    if (!sourceMeshWorld) {
      diagnostics.push(`BILLBOARD_MESH_SOURCE_MATRIX_INVALID:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    if (!hasPositiveZNormalsInBillboardSpace(binding.sourceMesh, sourceNodeWorld, sourceMeshWorld)) {
      diagnostics.push(`BILLBOARD_RELATIVE_CHILD_NORMAL_NOT_POSITIVE_Z:block=${blockIndex}:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    const originalMeshWorld = viewFromSource.clone().multiply(sourceMeshWorld);
    const determinant = originalMeshWorld.determinant();
    if (!finite(determinant) || Math.abs(determinant) < 1e-12) {
      diagnostics.push(`BILLBOARD_MESH_SOURCE_MATRIX_SINGULAR:mesh=${binding.sourceMesh.blockIndex ?? '?'}`);
      continue;
    }
    prepared.push({
      binding,
      sourceNode,
      sourceNodeWorld,
      sourceMeshWorld,
      originalMeshWorld,
      inverseOriginalMeshWorld: originalMeshWorld.clone().invert(),
      decodedMode: mode.decoded,
    });
    binding.object.userData.originBillboard = {
      sourceBlockIndex: blockIndex,
      sourceModeRaw: rawMode,
      decodedMode: mode.decoded,
      sourceGeometryChanged: false,
      engineAppearanceParityValidated: false,
    };
  }

  function tick(camera: THREE.Camera, elapsed: number): void {
    if (!finite(elapsed)) return;
    camera.updateWorldMatrix(true, false);
    for (const entry of prepared) {
      const sourceDelta = pathRuntime?.deltaForNode(entry.sourceNode.blockIndex, elapsed) ?? new THREE.Matrix4();
      const currentBillboardWorld = viewFromSource.clone().multiply(sourceDelta).multiply(entry.sourceNodeWorld);
      const currentMeshWorld = viewFromSource.clone().multiply(sourceDelta).multiply(entry.sourceMeshWorld);
      const pivot = new THREE.Vector3();
      const currentQuaternion = new THREE.Quaternion();
      const currentScale = new THREE.Vector3();
      currentBillboardWorld.decompose(pivot, currentQuaternion, currentScale);

      entry.binding.root.updateWorldMatrix(true, false);
      const rootWorldQuaternion = entry.binding.root.getWorldQuaternion(new THREE.Quaternion());
      const cameraWorldQuaternion = camera.getWorldQuaternion(new THREE.Quaternion());
      const cameraInRoot = rootWorldQuaternion.invert().multiply(cameraWorldQuaternion);
      const rotationDelta = cameraInRoot.multiply(currentQuaternion.invert());
      const toPivot = new THREE.Matrix4().makeTranslation(pivot.x, pivot.y, pivot.z);
      const fromPivot = new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z);
      const rotation = new THREE.Matrix4().makeRotationFromQuaternion(rotationDelta);
      const rotateAroundPivot = toPivot.multiply(rotation).multiply(fromPivot);
      const billboardMeshWorld = rotateAroundPivot.multiply(currentMeshWorld);
      entry.binding.object.matrix.copy(billboardMeshWorld.multiply(entry.inverseOriginalMeshWorld));
      entry.binding.object.matrixAutoUpdate = false;
      entry.binding.object.matrixWorldNeedsUpdate = true;
    }
  }

  return { diagnostics, tick };
}
