import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { analyzeReference, type ReferenceAnalysis } from "./image/analyze.js";
import { loadRaster } from "./image/Raster.js";
import { denoise, segmentForeground } from "./image/segment.js";
import { buildHumanoidMesh } from "./mesh/humanoid.js";
import { buildSkeleton, type Skeleton } from "./rig/skeleton.js";
import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "./rig/skin.js";
import { buildDefaultClips, type AnimationClip } from "./anim/procedural.js";
import { exportGlb, meshStatistics } from "./export/glb.js";
import { GrokBridge, type ImageResult } from "./grok/GrokBridge.js";
import { characterReferencePrompt } from "./grok/prompts.js";
import type { MeshData } from "./mesh/MeshBuilder.js";

/**
 * Photo to rigged, animated GLB — Milestone 3 end to end.
 *
 *   reference image → silhouette → measurements + palette
 *                   → mesh drawn in code
 *                   → canonical skeleton + automatic skin weights
 *                   → procedural clips
 *                   → GLB
 *
 * The reference can be generated through the Grok bridge or supplied as a file;
 * everything downstream is deterministic, so the same image always yields the
 * same character.
 */

export interface PipelineOptions {
  /** Existing reference image. Takes precedence over `describe`. */
  imagePath?: string;
  /** Text description, generated into a reference via the Grok bridge. */
  describe?: string;
  name?: string;
  outputPath: string;
  /** Target world height in metres. */
  height?: number;
  /** Also write the analysis as JSON beside the GLB. */
  writeReport?: boolean;
  clips?: AnimationClip[];
  grok?: GrokBridge;
}

export interface PipelineResult {
  glbPath: string;
  reportPath: string | null;
  image: { path: string; generated: boolean; costUsd: number };
  analysis: ReferenceAnalysis;
  skeleton: Skeleton;
  mesh: MeshData;
  stats: {
    vertices: number;
    triangles: number;
    bones: number;
    clips: number;
    maxInfluences: number;
    averageInfluences: number;
    unweightedVertices: number;
    glbBytes: number;
  };
}

export async function runPipeline(options: PipelineOptions): Promise<PipelineResult> {
  const { imagePath, generated, costUsd } = await resolveReference(options);

  // --- analyse -------------------------------------------------------------
  const raster = await loadRaster(imagePath);
  const mask = denoise(segmentForeground(raster));
  const analysis = analyzeReference(raster, mask);

  // --- rig and mesh --------------------------------------------------------
  // The skeleton is built first: the mesh is drawn *between* its joints, so the
  // two can never disagree about where a knee is.
  const skeleton = buildSkeleton(analysis.measurements, { height: options.height ?? 1.7 });
  const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette);

  computeSkinWeights(mesh, skeleton);
  const inverseBindMatrices = computeInverseBindMatrices(skeleton);

  // --- animate and export --------------------------------------------------
  const clips = options.clips ?? buildDefaultClips();
  const name = options.name ?? "Character";
  const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name, clips });

  await mkdir(dirname(options.outputPath), { recursive: true });
  await writeFile(options.outputPath, glb);

  let reportPath: string | null = null;
  if (options.writeReport !== false) {
    reportPath = options.outputPath.replace(/\.glb$/i, "") + ".analysis.json";
    await writeFile(
      reportPath,
      JSON.stringify(
        {
          name,
          sourceImage: imagePath,
          generated,
          measurements: analysis.measurements,
          palette: analysis.palette,
          notes: analysis.notes,
        },
        null,
        2,
      ),
    );
  }

  const meshStats = meshStatistics(mesh);
  const skinStats = skinStatistics(mesh);

  return {
    glbPath: options.outputPath,
    reportPath,
    image: { path: imagePath, generated, costUsd },
    analysis,
    skeleton,
    mesh,
    stats: {
      vertices: meshStats.vertices,
      triangles: meshStats.triangles,
      bones: skeleton.joints.length,
      clips: clips.length,
      maxInfluences: skinStats.maxInfluences,
      averageInfluences: skinStats.averageInfluences,
      unweightedVertices: skinStats.unweighted,
      glbBytes: glb.byteLength,
    },
  };
}

async function resolveReference(
  options: PipelineOptions,
): Promise<{ imagePath: string; generated: boolean; costUsd: number }> {
  if (options.imagePath) {
    return { imagePath: options.imagePath, generated: false, costUsd: 0 };
  }

  if (!options.describe) {
    throw new Error("provide either imagePath or describe");
  }

  const bridge = options.grok ?? new GrokBridge();
  const request = characterReferencePrompt({ description: options.describe });
  const result: ImageResult = await bridge.generate(request);

  return { imagePath: result.path, generated: !result.cached, costUsd: result.costUsd };
}

/** Human-readable summary for the CLI. */
export function formatReport(result: PipelineResult): string {
  const { stats, analysis } = result;
  const lines: string[] = [];

  lines.push(`  source     ${result.image.path}${result.image.generated ? " (generated)" : ""}`);
  lines.push(`  output     ${result.glbPath}  (${(stats.glbBytes / 1024).toFixed(1)} KB)`);
  lines.push(`  mesh       ${stats.vertices} vertices, ${stats.triangles} triangles`);
  lines.push(`  rig        ${stats.bones} canonical bones, ${stats.clips} clips`);
  lines.push(
    `  skinning   max ${stats.maxInfluences} influences, ` +
      `avg ${stats.averageInfluences.toFixed(2)}, ${stats.unweightedVertices} unweighted`,
  );

  const { landmarks } = analysis.measurements;
  const derived = Object.entries(landmarks)
    .filter(([, landmark]) => landmark.source === "derived")
    .map(([name]) => name);
  lines.push(`  measured   ${Object.keys(landmarks).length - derived.length}/${Object.keys(landmarks).length} landmarks`);
  if (derived.length > 0) lines.push(`  derived    ${derived.join(", ")}`);
  for (const note of analysis.notes) lines.push(`  note       ${note}`);

  return lines.join("\n");
}

/** Default output location for a named character. */
export const defaultOutputPath = (name: string): string =>
  join("assets", "characters", `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.glb`);
