//! sylpheed-cli — command line tools for Project Sylpheed asset work.
//!
//! ## Commands
//!
//! ### Extract an ISO
//! ```bash
//! sylpheed-cli extract game.iso ./assets/
//! ```
//!
//! ### List files inside an ISO
//! ```bash
//! sylpheed-cli list game.iso
//! sylpheed-cli list game.iso --filter .xpr
//! ```
//!
//! ### Sniff the format of unknown files
//! ```bash
//! sylpheed-cli sniff ./assets/DATA/
//! ```
//! Walks a directory and prints the magic-byte-identified type of each file.
//! Invaluable for the first pass of reverse engineering.
//!
//! ### Inspect a texture
//! ```bash
//! sylpheed-cli texture info ./assets/DATA/TEXTURES/SHIP01.XPR
//! sylpheed-cli texture export ./assets/DATA/TEXTURES/SHIP01.XPR ship01.png
//! ```
//!
//! ### Inspect IPFB archives and IDXD definitions
//! ```bash
//! sylpheed-cli pak list ./assets/dat/GP_MAIN_GAME_E.pak            # inventory entries
//! sylpheed-cli pak dump ./assets/dat/GP_MAIN_GAME_E.pak 0x7c96296c # one object's stat sheet
//! ```

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use tracing::info;

use sylpheed_formats::vfs::{identify_format, GameAssets};
use sylpheed_formats::{IdxdObject, PakArchive};

// ── CLI definition ─────────────────────────────────────────────────────────

#[derive(Parser)]
#[command(
    name = "sylpheed-cli",
    about = "Project Sylpheed: Arc of Deception — asset tools",
    version,
    long_about = None,
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Extract all files from an XISO disc image
    Extract {
        /// Path to the .iso file
        iso: PathBuf,
        /// Output directory (will be created if it doesn't exist)
        output: PathBuf,
    },

    /// List files inside an XISO disc image
    List {
        /// Path to the .iso file
        iso: PathBuf,
        /// Only show files matching this substring
        #[arg(long)]
        filter: Option<String>,
    },

    /// Walk a directory and identify file formats by magic bytes.
    /// Essential for the first pass of reverse engineering.
    Sniff {
        /// Directory to walk (use your extraction output)
        dir: PathBuf,
        /// Only show unrecognized files (focus RE effort)
        #[arg(long)]
        unknown_only: bool,
    },

    /// Texture tools
    Texture {
        #[command(subcommand)]
        cmd: TextureCommands,
    },

    /// IPFB archive (`*.pak`) and IDXD definition tools
    Pak {
        #[command(subcommand)]
        cmd: PakCommands,
    },

    /// XBG7 mesh tools (inspect / headless render to PNG)
    Mesh {
        #[command(subcommand)]
        cmd: MeshCommands,
    },
}

#[derive(Subcommand)]
enum PakCommands {
    /// List the entries of an IPFB archive (hash, size, inner format, identity)
    List {
        /// Path to the `.pak` index (sibling `.p00`/`.pNN` segments are loaded automatically)
        pak: PathBuf,
        /// Only show IDXD-object entries
        #[arg(long)]
        idxd_only: bool,
    },
    /// Dump one entry: its IDXD schema and every explicitly-valued field
    Dump {
        /// Path to the `.pak` index
        pak: PathBuf,
        /// Entry name-hash, e.g. `0x7c96296c`
        hash: String,
    },
    /// Decode every T8aD texture in the pak (direct, RATC-nested, and LSTA
    /// frames) to PNG — our decoder's output, for A/B against the running game.
    Textures {
        /// Path to the `.pak` index
        pak: PathBuf,
        /// Output directory for the PNGs (created if missing)
        output: PathBuf,
        /// Print per-texture dimensions + tile count.
        #[arg(long)]
        verbose: bool,
    },
}

#[derive(Subcommand)]
enum MeshCommands {
    /// Print the decoded sub-models of an XBG7 container (`.xpr`)
    Info {
        /// Path to the `.xpr` model / stage container
        file: PathBuf,
    },
    /// Headless-render the decoded mesh(es) to a shaded PNG (software rasterizer)
    Render {
        /// Path to the `.xpr` model / stage container
        file: PathBuf,
        /// Output PNG path
        output: PathBuf,
        /// Image size in pixels (square)
        #[arg(long, default_value_t = 900)]
        size: u32,
        /// Camera yaw in degrees
        #[arg(long, default_value_t = 35.0)]
        yaw: f32,
        /// Camera pitch in degrees
        #[arg(long, default_value_t = 22.0)]
        pitch: f32,
        /// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out)
        #[arg(long, default_value_t = 1.0)]
        dist: f32,
        /// Force the stage grid layout even for single models
        #[arg(long)]
        row: bool,
        /// Only render sub-models whose name contains this substring
        #[arg(long)]
        only: Option<String>,
    },
}

#[derive(Subcommand)]
enum TextureCommands {
    /// Print information about a texture file
    Info {
        /// Path to the texture file
        file: PathBuf,
    },
    /// Export a texture to PNG
    Export {
        /// Path to the texture file
        file: PathBuf,
        /// Output PNG path
        output: PathBuf,
    },
}

// ── Entry point ────────────────────────────────────────────────────────────

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("sylpheed=info".parse().unwrap())
        )
        .init();

    let cli = Cli::parse();

    match cli.command {
        Commands::Extract { iso, output } => cmd_extract(&iso, &output).await,
        Commands::List { iso, filter }    => cmd_list(&iso, filter).await,
        Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only),
        Commands::Texture { cmd } => match cmd {
            TextureCommands::Info { file }           => cmd_texture_info(&file),
            TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
        },
        Commands::Mesh { cmd } => match cmd {
            MeshCommands::Info { file } => cmd_mesh_info(&file),
            MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
                cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
            }
        },
        Commands::Pak { cmd } => match cmd {
            PakCommands::List { pak, idxd_only }  => cmd_pak_list(&pak, idxd_only),
            PakCommands::Dump { pak, hash }       => cmd_pak_dump(&pak, &hash),
            PakCommands::Textures { pak, output, verbose } => {
                cmd_pak_textures(&pak, &output, verbose)
            }
        },
    }
}

// ── extract ────────────────────────────────────────────────────────────────

async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
    println!(
        "{} {} → {}",
        "Extracting".green().bold(),
        iso_path.display().to_string().cyan(),
        output_dir.display().to_string().cyan()
    );

    let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;

    // Count files first for a meaningful progress bar
    println!("{} Scanning ISO contents...", "  ·".dimmed());
    let all_files = reader.list_all_files().await?;
    let total = all_files.len();
    println!("  Found {} files", total.to_string().yellow());

    let pb = ProgressBar::new(total as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
            .unwrap()
            .progress_chars("█▉▊▋▌▍▎▏  ")
    );

    let stats = reader.extract_all(output_dir).await?;
    pb.finish_and_clear();

    println!(
        "{} Extracted {} files ({:.2} MB)",
        "✓".green().bold(),
        stats.files_extracted.to_string().yellow(),
        (stats.bytes_extracted as f64) / (1024.0 * 1024.0)
    );
    println!(
        "  Assets ready at: {}",
        output_dir.display().to_string().cyan()
    );
    println!();
    println!(
        "  {} Run the viewer: {}",
        "→".cyan(),
        "cargo run --bin sylpheed-viewer".bold()
    );

    Ok(())
}

// ── list ───────────────────────────────────────────────────────────────────

async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> {
    println!("{} {}", "Listing".green().bold(), iso_path.display().to_string().cyan());

    let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
    let files = reader.list_all_files().await?;

    let filter_lower = filter.as_deref().unwrap_or("").to_lowercase();

    let mut shown = 0;
    for file in &files {
        if filter_lower.is_empty() || file.to_lowercase().contains(&filter_lower) {
            println!("  {}", file);
            shown += 1;
        }
    }

    println!(
        "\n  {} files{}",
        shown.to_string().yellow(),
        if !filter_lower.is_empty() {
            format!(" (filtered from {})", files.len())
        } else {
            String::new()
        }
    );

    Ok(())
}

// ── sniff ──────────────────────────────────────────────────────────────────

fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
    println!(
        "{} {}",
        "Sniffing formats in".green().bold(),
        dir.display().to_string().cyan()
    );
    println!("{}", "  (reading magic bytes of each file)".dimmed());
    println!();

    let assets = GameAssets::from_directory(dir);
    let files = assets.list("").context("Failed to read directory")?;

    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();

    for file in &files {
        let Ok(bytes) = assets.read(file) else { continue; };
        let fmt = identify_format(&bytes);
        let label = fmt.extension_hint();
        *counts.entry(label).or_insert(0) += 1;

        if unknown_only && label != "bin" {
            continue;
        }

        let color_label = match label {
            "bin" => label.red().to_string(),
            "xpr" => label.green().to_string(),
            "dds" => label.green().to_string(),
            "txt" => label.cyan().to_string(),
            _ => label.yellow().to_string(),
        };

        // Show first 8 bytes as hex for unknown files
        let hex_preview = if label == "bin" && bytes.len() >= 8 {
            format!(
                "  {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
                bytes[0], bytes[1], bytes[2], bytes[3],
                bytes[4], bytes[5], bytes[6], bytes[7]
            ).dimmed().to_string()
        } else {
            String::new()
        };

        println!("  [{color_label}] {file}{hex_preview}");
    }

    // Summary
    println!();
    println!("{}", "Format Summary:".bold());
    let mut summary: Vec<_> = counts.into_iter().collect();
    summary.sort_by(|a, b| b.1.cmp(&a.1));
    for (fmt, count) in summary {
        println!(
            "  {:>6}  .{}",
            count.to_string().yellow(),
            fmt
        );
    }

    Ok(())
}

// ── texture info ──────────────────────────────────────────────────────────

fn cmd_texture_info(file: &Path) -> Result<()> {
    let bytes = std::fs::read(file)
        .with_context(|| format!("Cannot read {}", file.display()))?;

    use sylpheed_formats::texture::X360Texture;
    let tex = X360Texture::from_xpr2(&bytes)
        .with_context(|| format!("Failed to parse texture: {}", file.display()))?;

    println!("{} {}", "Texture:".green().bold(), file.display());
    println!("  Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
    println!("  Format     : {:?}", tex.format);
    println!("  Mip levels : {}", tex.mip_levels);
    println!("  Data size  : {} bytes", tex.data.len().to_string().yellow());

    Ok(())
}

// ── texture export ────────────────────────────────────────────────────────

fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
    let bytes = std::fs::read(file)
        .with_context(|| format!("Cannot read {}", file.display()))?;

    use sylpheed_formats::texture::X360Texture;
    let tex = X360Texture::from_xpr2(&bytes)?;
    let rgba = decode_to_rgba8(&tex)
        .with_context(|| format!("decoding {:?} texture", tex.format))?;

    image::save_buffer(
        output,
        &rgba,
        tex.width,
        tex.height,
        image::ExtendedColorType::Rgba8,
    )
    .with_context(|| format!("writing PNG {}", output.display()))?;

    println!(
        "{} {}×{} {:?}{} → {}",
        "Exported".green().bold(),
        tex.width,
        tex.height,
        tex.format,
        if tex.is_cubemap { " (cubemap face 0)" } else { "" },
        output.display().to_string().cyan(),
    );
    Ok(())
}

// ── mesh info / render ──────────────────────────────────────────────────────

/// Decode a container and return its sub-models the same way the viewer routes:
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
/// yields more geometry.
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
    use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
    // Route by container kind, NOT by whichever decoder yields more verts (that
    // old heuristic let `stage_models`' content-anchoring win on single-model
    // files, fabricating phantom / duplicate / mis-anchored blocks). A file with
    // one XBG7 resource is a single model (weapon / prop) → use only the
    // validated records-based list decode; content-anchoring a single-model file
    // invents geometry. Many XBG7 resources → a Stage_* collection → anchor them.
    if count_xbg7(bytes) > 1 {
        // Multi-resource Stage_* collection → content-anchor every resource
        // (each anchor now gated by stored-normal agreement).
        Xbg7Model::stage_models(bytes)
    } else if let Some(m) = Xbg7Model::from_xpr2(bytes)
        .ok()
        .filter(|m| !m.meshes.is_empty())
    {
        // Single model the records-based list decode carved (authoritative).
        vec![m]
    } else {
        // Single model from_xpr2 couldn't locate (its sequential carve missed
        // the block) — fall back to content-anchoring, which finds it by shape.
        // Use a STRICT winding-consistency gate (0.85): on a single-model file a
        // mis-anchor is an obvious phantom / spike-mess and must be declined,
        // unlike the large stage corpus which keeps the ungated path.
        Xbg7Model::anchor_models(bytes, 0.85)
    }
}

fn cmd_mesh_info(file: &Path) -> Result<()> {
    let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
    let models = decode_models(&bytes);
    if models.is_empty() {
        println!("{} no decodable XBG7 geometry", "Mesh:".yellow().bold());
        return Ok(());
    }
    let (mut tv, mut tt) = (0usize, 0usize);
    println!("{} {}", "Mesh:".green().bold(), file.display());
    for m in &models {
        let (v, t) = m.totals();
        tv += v;
        tt += t;
        let mut lo = [f32::MAX; 3];
        let mut hi = [f32::MIN; 3];
        for sub in &m.meshes {
            for p in &sub.positions {
                for a in 0..3 {
                    lo[a] = lo[a].min(p[a]);
                    hi[a] = hi[a].max(p[a]);
                }
            }
        }
        println!(
            "  {:16} {:>6} v {:>6} t  bbox [{:.1} {:.1} {:.1}]",
            m.name,
            v,
            t,
            hi[0] - lo[0],
            hi[1] - lo[1],
            hi[2] - lo[2]
        );
        // Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area
        // "hole"), vertices referenced by no triangle (dropped geometry), and the
        // referenced index range vs the vertex count (short/over reads).
        for (si, sub) in m.meshes.iter().enumerate() {
            let nv = sub.positions.len();
            let mut referenced = vec![false; nv];
            let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
            for tri in sub.indices.chunks_exact(3) {
                let (a, b, c) = (tri[0], tri[1], tri[2]);
                imax = imax.max(a).max(b).max(c);
                if a == b || b == c || a == c {
                    degen += 1;
                }
                for &i in tri {
                    if (i as usize) < nv {
                        referenced[i as usize] = true;
                    } else {
                        oob += 1;
                    }
                }
            }
            let unref = referenced.iter().filter(|&&r| !r).count();
            // Spanning triangles: longest edge ≫ the median (strip-junction spikes).
            let edge = |a: u32, b: u32| {
                let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
                ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
            };
            let mut maxedges: Vec<f32> = sub
                .indices
                .chunks_exact(3)
                .map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
                .collect();
            maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
            let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
            let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
            println!(
                "      sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
                sub.indices.len() / 3,
                nv.saturating_sub(1),
                if oob > 0 { format!(", OOB {oob}") } else { String::new() },
            );
            // XDUMPVERT=1 → print the first few vertex positions per sub-mesh, for
            // content-matching a decoded sub-mesh against the GPU draw log.
            if std::env::var("XDUMPVERT").is_ok() {
                let mut lo = [f32::MAX; 3];
                let mut hi = [f32::MIN; 3];
                for p in &sub.positions {
                    for a in 0..3 {
                        lo[a] = lo[a].min(p[a]);
                        hi[a] = hi[a].max(p[a]);
                    }
                }
                println!(
                    "           bbox X[{:.2}..{:.2}] Y[{:.2}..{:.2}] Z[{:.2}..{:.2}]  ctr({:.2},{:.2},{:.2})",
                    lo[0], hi[0], lo[1], hi[1], lo[2], hi[2],
                    (lo[0]+hi[0])/2.0, (lo[1]+hi[1])/2.0, (lo[2]+hi[2])/2.0
                );
            }
        }
    }
    println!(
        "  {} {} sub-models · {} verts · {} tris",
        "TOTAL".bold(),
        models.len(),
        tv,
        tt
    );
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn cmd_mesh_render(
    file: &Path,
    output: &Path,
    size: u32,
    yaw: f32,
    pitch: f32,
    dist: f32,
    force_row: bool,
    only: Option<String>,
) -> Result<()> {
    let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
    let mut models = decode_models(&bytes);
    if let Some(sub) = &only {
        // Prefer an exact name match (e.g. `f001` for the neutral ship pose,
        // excluding the `_rou_f001_mnv*` animation poses that also *contain*
        // "f001"); fall back to substring when nothing matches exactly.
        if models.iter().any(|m| m.name == *sub) {
            models.retain(|m| m.name == *sub);
        } else {
            models.retain(|m| m.name.contains(sub.as_str()));
        }
    }
    if models.is_empty() {
        anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
    }

    // ── Build a triangle soup. ──
    // Single models render centred; multi-model containers (stages) get the
    // viewer's normalised **thumbnail grid**: each sub-model recentred and
    // uniformly scaled to a fixed cell, so all are equally visible regardless of
    // native scale (mirrors `spawn_stage_models`).
    let multi = models.len() > 1 || force_row;
    // XMIRROR=x|y|z → negate that axis, to test an Xbox(LH)→Bevy(RH) handedness
    // flip against reference screenshots.
    let mirror: [f32; 3] = match std::env::var("XMIRROR").ok().as_deref() {
        Some("x") => [-1.0, 1.0, 1.0],
        Some("y") => [1.0, -1.0, 1.0],
        Some("z") => [1.0, 1.0, -1.0],
        _ => [1.0, 1.0, 1.0],
    };
    let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
    // XCOLORSUB=1 tints each sub-mesh a distinct colour (to see which sub is
    // which part / where the "extra fin" comes from). Parallel to `tris`.
    let color_sub = std::env::var("XCOLORSUB").is_ok();
    // XONLYSUB=N renders only the N-th global sub-mesh (to isolate one part).
    let only_sub: Option<usize> = std::env::var("XONLYSUB").ok().and_then(|s| s.parse().ok());
    let mut tints: Vec<[f32; 3]> = Vec::new();
    const PALETTE: [[f32; 3]; 8] = [
        [1.0, 1.0, 1.0],   // sub0 body = white
        [1.0, 0.35, 0.35], // sub1 red
        [0.35, 1.0, 0.35], // sub2 green
        [0.4, 0.55, 1.0],  // sub3 blue
        [1.0, 0.9, 0.3],   // sub4 yellow
        [1.0, 0.5, 1.0],   // sub5 magenta
        [0.3, 1.0, 1.0],   // sub6 cyan
        [1.0, 0.6, 0.2],   // sub7 orange
    ];
    let mut sub_gi = 0usize;
    const CELL: f32 = 10.0;
    const GAP: f32 = 4.0;
    let grid_pitch = CELL + GAP;
    let cols = (models.len() as f32).sqrt().ceil().max(1.0) as usize;
    for (i, m) in models.iter().enumerate() {
        let mut lo = [f32::MAX; 3];
        let mut hi = [f32::MIN; 3];
        for sub in &m.meshes {
            for p in &sub.positions {
                for a in 0..3 {
                    lo[a] = lo[a].min(p[a]);
                    hi[a] = hi[a].max(p[a]);
                }
            }
        }
        if lo[0] > hi[0] {
            continue;
        }
        let center = [
            (lo[0] + hi[0]) * 0.5,
            (lo[1] + hi[1]) * 0.5,
            (lo[2] + hi[2]) * 0.5,
        ];
        let (scale, cell) = if multi {
            let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
            let col = i % cols;
            let row = i / cols;
            (CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
        } else {
            (1.0, [0.0, 0.0, 0.0])
        };
        // XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to
        // the tail) — to verify the transforms recovered from the graph.
        let placements = if std::env::var("XNODEXFORM").is_ok() {
            sylpheed_formats::mesh::node_transforms(&bytes, &m.name)
        } else {
            Vec::new()
        };
        for (sub_local, sub) in m.meshes.iter().enumerate() {
            // Every scene-graph instance that draws this sub-mesh (mirrored fin
            // pair, L/R winglets…); `None` = no graph placement → identity.
            let mine: Vec<Option<&sylpheed_formats::mesh::NodePlacement>> = {
                let v: Vec<_> = placements
                    .iter()
                    .filter(|p| p.sub_index == sub_local)
                    .map(Some)
                    .collect();
                if v.is_empty() {
                    vec![None]
                } else {
                    v
                }
            };
            // Sub-mesh indices are a triangle list (the decoder has already
            // expanded the file's triangle strips).
            let n = sub.positions.len();
            // XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1
            // renders everything EXCEPT them — to see whether the flagged spanning
            // triangles are real geometry or decode artifacts (phantom sheets).
            let span_only = std::env::var("XSPANONLY").is_ok();
            let span_hide = std::env::var("XSPANHIDE").is_ok();
            let med = {
                let mut e: Vec<f32> = sub
                    .indices
                    .chunks_exact(3)
                    .filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
                    .map(|t| {
                        let d = |a: u32, b: u32| {
                            let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
                            ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2))
                                .sqrt()
                        };
                        d(t[0], t[1]).max(d(t[1], t[2])).max(d(t[0], t[2]))
                    })
                    .collect();
                e.sort_by(|a, b| a.partial_cmp(b).unwrap());
                e.get(e.len() / 2).copied().unwrap_or(1.0).max(1e-6)
            };
            if let Some(want) = only_sub {
                if sub_gi != want {
                    sub_gi += 1;
                    continue;
                }
            }
            let tint = if color_sub {
                PALETTE[sub_gi % PALETTE.len()]
            } else {
                [1.0, 1.0, 1.0]
            };
            for place in &mine {
                let f = |i: usize| {
                    let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]);
                    [
                        (p[0] - center[0]) * scale * mirror[0] + cell[0],
                        (p[1] - center[1]) * scale * mirror[1] + cell[1],
                        (p[2] - center[2]) * scale * mirror[2] + cell[2],
                    ]
                };
                for tri in sub.indices.chunks_exact(3) {
                    let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
                    if a < n && b < n && c < n {
                        if span_only || span_hide {
                            let d = |i: usize, j: usize| {
                                let (p, q) = (sub.positions[i], sub.positions[j]);
                                ((p[0] - q[0]).powi(2)
                                    + (p[1] - q[1]).powi(2)
                                    + (p[2] - q[2]).powi(2))
                                .sqrt()
                            };
                            let spanning = d(a, b).max(d(b, c)).max(d(a, c)) > 6.0 * med;
                            if span_only && !spanning {
                                continue;
                            }
                            if span_hide && spanning {
                                continue;
                            }
                        }
                        tris.push([f(a), f(b), f(c)]);
                        tints.push(tint);
                    }
                }
            }
            sub_gi += 1;
        }
    }
    if tris.is_empty() {
        anyhow::bail!("no triangles to render");
    }

    let rgba = rasterize(&tris, &tints, size, yaw, pitch, dist);
    image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
        .with_context(|| format!("writing PNG {}", output.display()))?;
    println!(
        "{} {} tris → {} ({}×{}, yaw {:.0}° pitch {:.0}°)",
        "Rendered".green().bold(),
        tris.len(),
        output.display().to_string().cyan(),
        size,
        size,
        yaw,
        pitch,
    );
    Ok(())
}

/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
/// headlight shading over a flat grey material on a dark background. Enough to
/// judge whether recovered geometry is coherent.
fn rasterize(
    tris: &[[[f32; 3]; 3]],
    tints: &[[f32; 3]],
    size: u32,
    yaw_deg: f32,
    pitch_deg: f32,
    dist: f32,
) -> Vec<u8> {
    let n = size as usize;
    let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
    let (cy, sy) = (yaw.cos(), yaw.sin());
    let (cp, sp) = (pitch.cos(), pitch.sin());
    // Rotate a world point into view space (yaw about Y, then pitch about X).
    let view = |p: [f32; 3]| -> [f32; 3] {
        let x = p[0] * cy + p[2] * sy;
        let z0 = -p[0] * sy + p[2] * cy;
        let y = p[1] * cp - z0 * sp;
        let z = p[1] * sp + z0 * cp;
        [x, y, z]
    };

    // View-space bbox → orthographic fit.
    let mut lo = [f32::MAX; 3];
    let mut hi = [f32::MIN; 3];
    for t in tris {
        for v in t {
            let q = view(*v);
            for a in 0..3 {
                lo[a] = lo[a].min(q[a]);
                hi[a] = hi[a].max(q[a]);
            }
        }
    }
    let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
    let scale = (n as f32) * 0.9 / (span * dist.max(1e-3));
    let cx = (lo[0] + hi[0]) * 0.5;
    let cyv = (lo[1] + hi[1]) * 0.5;
    let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
        let sx = (q[0] - cx) * scale + n as f32 * 0.5;
        let sy = n as f32 * 0.5 - (q[1] - cyv) * scale;
        (sx, sy, q[2])
    };

    let mut color = vec![18u8; n * n * 4];
    for i in 0..n * n {
        color[i * 4 + 3] = 255;
    }
    let mut depth = vec![f32::MAX; n * n];
    // Light in view space (upper-left-front).
    let light = {
        let l = [-0.4f32, 0.6, 0.7];
        let m = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
        [l[0] / m, l[1] / m, l[2] / m]
    };

    for (ti, t) in tris.iter().enumerate() {
        let tint = tints.get(ti).copied().unwrap_or([1.0, 1.0, 1.0]);
        let v0 = view(t[0]);
        let v1 = view(t[1]);
        let v2 = view(t[2]);
        // Face normal in view space.
        let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
        let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
        let mut nrm = [
            e1[1] * e2[2] - e1[2] * e2[1],
            e1[2] * e2[0] - e1[0] * e2[2],
            e1[0] * e2[1] - e1[1] * e2[0],
        ];
        let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt();
        if nl < 1e-12 {
            continue;
        }
        nrm = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];
        // Two-sided: diffuse from |n·L|, plus a headlight term from |n.z|.
        let diff = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).abs();
        let head = nrm[2].abs();
        let inten = (0.18 + 0.55 * diff + 0.3 * head).min(1.0);
        let shade = (inten * 210.0) as u8;

        let (ax, ay, az) = to_screen(v0);
        let (bx, by, bz) = to_screen(v1);
        let (ccx, ccy, ccz) = to_screen(v2);
        let minx = ax.min(bx).min(ccx).floor().max(0.0) as usize;
        let maxx = ax.max(bx).max(ccx).ceil().min(n as f32 - 1.0) as usize;
        let miny = ay.min(by).min(ccy).floor().max(0.0) as usize;
        let maxy = ay.max(by).max(ccy).ceil().min(n as f32 - 1.0) as usize;
        let area = (bx - ax) * (ccy - ay) - (by - ay) * (ccx - ax);
        if area.abs() < 1e-6 {
            continue;
        }
        for py in miny..=maxy {
            for px in minx..=maxx {
                let fx = px as f32 + 0.5;
                let fy = py as f32 + 0.5;
                let w0 = ((bx - fx) * (ccy - fy) - (by - fy) * (ccx - fx)) / area;
                let w1 = ((ccx - fx) * (ay - fy) - (ccy - fy) * (ax - fx)) / area;
                let w2 = 1.0 - w0 - w1;
                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
                    continue;
                }
                let z = w0 * az + w1 * bz + w2 * ccz;
                let idx = py * n + px;
                if z < depth[idx] {
                    depth[idx] = z;
                    color[idx * 4] = (shade as f32 * tint[0]).min(255.0) as u8;
                    color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8;
                    color[idx * 4 + 2] = (shade as f32 * tint[2] * 1.02).min(255.0) as u8;
                }
            }
        }
    }
    color
}

/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
///
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
/// is byte-swizzled from the Xenos in-memory BGRA order.
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
    use sylpheed_formats::texture::X360TextureFormat as F;
    let (w, h) = (tex.width as usize, tex.height as usize);
    let mut rgba = vec![0u8; w * h * 4];

    let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
        fmt.decompress(&tex.data, w, h, rgba);
    };

    match tex.format {
        F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
        F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
        F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
        F::A8R8G8B8 | F::X8R8G8B8 => {
            // After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
            // [A,R,G,B] byte order (verified against the retail Acheron backdrop).
            // Emit RGBA. X8 has no meaningful alpha.
            let opaque = matches!(tex.format, F::X8R8G8B8);
            for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
                out[0] = px[1]; // R
                out[1] = px[2]; // G
                out[2] = px[3]; // B
                out[3] = if opaque { 0xFF } else { px[0] };
            }
        }
        other => {
            anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
        }
    }
    Ok(rgba)
}

// ── pak list ────────────────────────────────────────────────────────────────

use sylpheed_formats::pak::inner_format_label as inner_label;

/// Try to recover an IDXD entry's original TOC path from its identity tokens.
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
/// candidates for [`sylpheed_formats::hash::recover_toc_name`].
fn idxd_toc_name(obj: &IdxdObject, name_hash: u32) -> Option<String> {
    let mut cands: Vec<&str> = Vec::new();
    for key in ["ID", "Name", "Model"] {
        if let Some(v) = obj.get_raw(key) {
            cands.push(v);
        }
    }
    for t in obj.tokens() {
        if t.contains('_') || t.len() >= 5 {
            cands.push(t.as_str());
        }
    }
    sylpheed_formats::hash::recover_toc_name(name_hash, &cands)
}

fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
    let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
    println!(
        "{} {}  ({} entries, block 0x{:x})",
        "Archive".green().bold(),
        pak.display().to_string().cyan(),
        arc.len().to_string().yellow(),
        arc.block_size,
    );

    let mut shown = 0usize;
    let mut named = 0usize;
    for e in arc.entries() {
        let payload = match arc.read(e) {
            Ok(p) => p,
            Err(err) => {
                eprintln!("  {:08x}  <read error: {err}>", e.name_hash);
                continue;
            }
        };
        let label = inner_label(&payload);
        let is_idxd = label == "IDXD";
        if idxd_only && !is_idxd {
            continue;
        }
        let (detail, name) = if is_idxd {
            match IdxdObject::parse(&payload) {
                Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)),
                Err(_) => (String::new(), None),
            }
        } else {
            (String::new(), None)
        };
        let name_col = match &name {
            Some(p) => p.clone(),
            None => "?".into(),
        };
        println!(
            "  {:08x}  {:<28}  {:>9} B  {:<6}  {}",
            e.name_hash,
            name_col.green(),
            payload.len(),
            label.yellow(),
            detail.dimmed(),
        );
        if name.is_some() {
            named += 1;
        }
        shown += 1;
    }
    println!(
        "\n  {} entries shown ({} name-resolved)",
        shown.to_string().yellow(),
        named.to_string().green(),
    );
    Ok(())
}

// ── pak dump ────────────────────────────────────────────────────────────────

fn parse_hash(s: &str) -> Result<u32> {
    let t = s.trim_start_matches("0x").trim_start_matches("0X");
    u32::from_str_radix(t, 16).with_context(|| format!("invalid hex hash: {s:?}"))
}

fn cmd_pak_dump(pak: &Path, hash_str: &str) -> Result<()> {
    let hash = parse_hash(hash_str)?;
    let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
    let entry = arc
        .find(hash)
        .with_context(|| format!("no entry with hash 0x{hash:08x} in {}", pak.display()))?;
    let payload = arc.read(entry)?;

    if !IdxdObject::is_idxd(&payload) {
        println!(
            "{} entry 0x{hash:08x} is {} ({} bytes) — not an IDXD object",
            "Note:".yellow(),
            inner_label(&payload),
            payload.len(),
        );
        return Ok(());
    }

    let obj = IdxdObject::parse(&payload)?;
    println!(
        "{} 0x{hash:08x}  schema 0x{:08x}  count {}",
        "IDXD".green().bold(),
        obj.schema_hash,
        obj.count,
    );
    // Identity fields — the head-of-object fields that are reliably identifier-valued.
    for key in ["ID", "Name", "Type", "Model"] {
        if let Some(v) = obj.get_raw(key) {
            println!("  {:<10} {}", format!("{key}:").dimmed(), v.cyan());
        }
    }

    let fields = obj.resolved_fields();
    println!(
        "\n  {} ({} explicit-value fields; defaulted fields omitted):",
        "Fields".bold(),
        fields.len().to_string().yellow(),
    );
    for (key, val) in &fields {
        println!("    {:<28} = {}", key, val.yellow());
    }
    Ok(())
}

// ── pak textures ─────────────────────────────────────────────────────────────

/// Turn a child name into a filesystem-safe fragment.
fn safe_name(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect()
}

#[derive(Default)]
struct TexStats {
    written: usize,
    skipped: usize,
}

fn be32_at(b: &[u8], off: usize) -> u32 {
    u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}

/// Decode one T8aD slice (whose first bytes are the magic) to PNG. With
/// `verbose`, prints its dimensions + tile count; the `XDUMPHDR` env var dumps
/// the raw header (base + offset table) for format RE.
fn emit_t8ad(
    slice: &[u8],
    hash: u32,
    stem: &str,
    output: &Path,
    verbose: bool,
    stats: &mut TexStats,
) -> Result<()> {
    use sylpheed_formats::t8ad;

    if !t8ad::is_t8ad(slice) || slice.len() < 0x40 {
        return Ok(());
    }
    let (w, h, tiles) = (
        be32_at(slice, 0x14),
        be32_at(slice, 0x18),
        be32_at(slice, 0x1c),
    );

    // Debug: dump the header — 44-byte base + the `tiles`-entry u32 offset table.
    if std::env::var("XDUMPHDR").is_ok() {
        println!("\n{stem}  {w}x{h}  tiles={tiles}");
        print!("  base[0x00..0x2c]:");
        for i in (0..44).step_by(4) {
            print!(" {:08x}", be32_at(slice, i));
        }
        print!("\n  offsets:");
        for t in 0..(tiles as usize).min(64) {
            if 0x2c + t * 4 + 4 <= slice.len() {
                print!(" {}", be32_at(slice, 0x2c + t * 4));
            }
        }
        println!();
        return Ok(());
    }

    if verbose {
        println!("  {hash:08x} {stem:<34} {w:>4}x{h:<4}  tiles {tiles}");
    }
    match t8ad::parse(slice) {
        Some(img) => {
            let out =
                output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height));
            image::save_buffer(
                &out,
                &img.rgba,
                img.width,
                img.height,
                image::ExtendedColorType::Rgba8,
            )
            .with_context(|| format!("writing PNG {}", out.display()))?;
            stats.written += 1;
        }
        None => stats.skipped += 1,
    }
    Ok(())
}

/// Decode every T8aD in a pak (direct entries, RATC-nested children, LSTA
/// frames) to PNG — our decoder's exact output — for A/B against the game.
fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
    use sylpheed_formats::{lsta, ratc, t8ad};

    let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
    std::fs::create_dir_all(output)
        .with_context(|| format!("creating {}", output.display()))?;

    println!(
        "{} {} → {}",
        "Textures".green().bold(),
        pak.display().to_string().cyan(),
        output.display().to_string().cyan(),
    );

    let mut stats = TexStats::default();

    for e in arc.entries() {
        let payload = match arc.read(e) {
            Ok(p) => p,
            Err(_) => continue,
        };
        let hash = e.name_hash;

        // Direct T8aD entry.
        if t8ad::is_t8ad(&payload) {
            emit_t8ad(&payload, hash, "direct", output, verbose, &mut stats)?;
            continue;
        }

        // LSTA sprite list = N inline T8aD frames (walk by magic, emit each).
        if lsta::is_lsta(&payload) {
            let mut off = 0usize;
            let mut idx = 0usize;
            while let Some(pos) = payload[off..]
                .windows(4)
                .position(|w| w == &t8ad::T8AD_MAGIC)
            {
                let start = off + pos;
                let next = payload[start + 4..]
                    .windows(4)
                    .position(|w| w == &t8ad::T8AD_MAGIC)
                    .map(|p| start + 4 + p)
                    .unwrap_or(payload.len());
                emit_t8ad(
                    &payload[start..next],
                    hash,
                    &format!("lsta{idx:03}"),
                    output,
                    verbose,
                    &mut stats,
                )?;
                idx += 1;
                off = next;
            }
            continue;
        }

        // RATC bundle: decode its T8aD children (named, e.g. `foo.t32`).
        if ratc::is_ratc(&payload) {
            if let Some(children) = ratc::parse(&payload) {
                for (i, child) in children.iter().enumerate() {
                    if child.kind != "T8aD" {
                        continue;
                    }
                    let end = (child.offset + child.size).min(payload.len());
                    if child.offset >= end {
                        continue;
                    }
                    let stem = if child.name.is_empty() {
                        format!("child{i:03}")
                    } else {
                        safe_name(&child.name)
                    };
                    emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?;
                }
            }
            continue;
        }
    }

    println!(
        "\n  {} PNG(s) written, {} undecodable (non-tilecount variants — likely DXT)",
        stats.written.to_string().green(),
        stats.skipped.to_string().yellow(),
    );
    Ok(())
}
