//! `sui-spec-inventory` — operator-facing substrate introspection.
//!
//! Dumps the typed catalog in either a human-readable table or as
//! JSON, with optional filters by maturity gate or domain name.
//! Lets an operator answer "what does this substrate cover?"
//! without grepping Rust source — and lets tooling consume the
//! catalog mechanically.
//!
//! Usage:
//!
//! ```text
//! sui-spec-inventory                    # human table, every domain
//! sui-spec-inventory --json             # JSON dump
//! sui-spec-inventory --gate Working     # filter by maturity
//! sui-spec-inventory --domain fetcher   # one domain in detail
//! sui-spec-inventory --histogram        # gate-count summary
//! ```

use sui_spec::catalog::{self, MaturityGate, SubstrateDomain};
use sui_spec::cli_coverage::{self, SuiCommand, SuiCommandMaturity};
use sui_spec::hash;
use sui_spec::lock_file::{self, ParsedLockFile};
use sui_spec::narinfo::{self, ParsedNarInfo};
use sui_spec::operator_view::{render as render_view, OperatorView};
use sui_spec::realisation::{self, ParsedRealisation};
use sui_spec::registry::{self, RegistryEntry, RegistryScope};
use sui_spec::store_layout::{self, ParsedStorePath};
use sui_spec::style::{
    self, body, dim_fg, error, glyph_arrow, glyph_gear, glyph_ok, glyph_snowflake,
    header, ident, info, muted, pending, success, warn, LabeledTable, NORD13, NORD15, NORD3, NORD8,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse()?;

    // `--flake-lock <path>` mode short-circuits the catalog
    // listing and emits a Nord-styled flake input summary.
    if let Some(path) = &args.flake_lock {
        emit_flake_lock(path)?;
        return Ok(());
    }

    // `--narinfo <path>` mode parses a single binary-cache narinfo
    // file and emits a Nord-styled record summary.
    if let Some(path) = &args.narinfo_path {
        emit_narinfo(path)?;
        return Ok(());
    }

    // `--registry-resolve <ref>` mode walks the canonical registry
    // precedence chain and shows where each scope would map the ref.
    if let Some(flake_ref) = &args.registry_resolve {
        emit_registry_resolve(flake_ref)?;
        return Ok(());
    }

    // `--realisation <path>` mode parses a CA-derivation realisation
    // JSON file and emits a Nord-styled record.
    if let Some(path) = &args.realisation_path {
        emit_realisation(path)?;
        return Ok(());
    }

    // `--hash-decode <input>` mode classifies a hash string by
    // algorithm + encoding and emits the decoded byte length.
    if let Some(input) = &args.hash_decode {
        emit_hash_decode(input)?;
        return Ok(());
    }

    // `--store-path <path>` decomposes a /nix/store path into
    // its typed components via store_layout::parse_path.
    if let Some(path) = &args.store_path {
        emit_store_path(path)?;
        return Ok(());
    }

    // `--coverage` emits sui's nix-replacement progress matrix
    // (working / partial / stub / missing / sui-native).
    if args.coverage {
        emit_coverage(&args)?;
        return Ok(());
    }

    let cat = if args.topo {
        catalog::topological_order()?
    } else {
        catalog::load_canonical()?
    };

    if args.histogram {
        emit_histogram(&cat);
        return Ok(());
    }

    let filtered: Vec<&SubstrateDomain> = cat
        .iter()
        .filter(|d| match &args.domain_filter {
            Some(name) => d.name == *name,
            None => true,
        })
        .filter(|d| match &args.gate_filter {
            Some(gate) => &gate_name(d.gate) == gate,
            None => true,
        })
        .collect();

    if filtered.is_empty() {
        eprintln!("no domains match the filter");
        std::process::exit(1);
    }

    if args.json {
        let body = serde_json::to_string_pretty(&filtered)?;
        println!("{body}");
    } else {
        emit_table(&filtered);
    }
    Ok(())
}

struct Args {
    json: bool,
    histogram: bool,
    topo: bool,
    gate_filter: Option<String>,
    domain_filter: Option<String>,
    flake_lock: Option<String>,
    narinfo_path: Option<String>,
    registry_resolve: Option<String>,
    realisation_path: Option<String>,
    hash_decode: Option<String>,
    store_path: Option<String>,
    coverage: bool,
    coverage_filter: Option<SuiCommandMaturity>,
}

impl Args {
    fn parse() -> Result<Self, String> {
        let mut args = std::env::args().skip(1);
        let mut out = Args {
            json: false,
            histogram: false,
            topo: false,
            gate_filter: None,
            domain_filter: None,
            flake_lock: None,
            narinfo_path: None,
            registry_resolve: None,
            realisation_path: None,
            hash_decode: None,
            store_path: None,
            coverage: false,
            coverage_filter: None,
        };
        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--json" => out.json = true,
                "--histogram" => out.histogram = true,
                "--topo" => out.topo = true,
                "--gate" => out.gate_filter = Some(args.next()
                    .ok_or("--gate needs value")?),
                "--domain" => out.domain_filter = Some(args.next()
                    .ok_or("--domain needs value")?),
                "--flake-lock" => out.flake_lock = Some(args.next()
                    .ok_or("--flake-lock needs <path>")?),
                "--narinfo" => out.narinfo_path = Some(args.next()
                    .ok_or("--narinfo needs <path>")?),
                "--registry-resolve" => out.registry_resolve = Some(args.next()
                    .ok_or("--registry-resolve needs <ref>")?),
                "--realisation" => out.realisation_path = Some(args.next()
                    .ok_or("--realisation needs <path>")?),
                "--hash-decode" => out.hash_decode = Some(args.next()
                    .ok_or("--hash-decode needs <hash-string>")?),
                "--store-path" => out.store_path = Some(args.next()
                    .ok_or("--store-path needs </nix/store/...>")?),
                "--coverage" => out.coverage = true,
                "--coverage-only" => {
                    out.coverage = true;
                    let v = args.next().ok_or("--coverage-only needs <maturity>")?;
                    out.coverage_filter = Some(match v.as_str() {
                        "working"    => SuiCommandMaturity::Working,
                        "partial"    => SuiCommandMaturity::Partial,
                        "stub"       => SuiCommandMaturity::Stub,
                        "missing"    => SuiCommandMaturity::Missing,
                        "sui-native" => SuiCommandMaturity::SuiNative,
                        other => return Err(format!(
                            "--coverage-only takes working|partial|stub|missing|sui-native, got `{other}`"
                        )),
                    });
                }
                "-h" | "--help" => {
                    print_help();
                    std::process::exit(0);
                }
                other => return Err(format!("unknown argument: {other}")),
            }
        }
        Ok(out)
    }
}

fn print_help() {
    println!(
        "sui-spec-inventory — typed substrate introspection.\n\n\
         Usage:\n  sui-spec-inventory [options]\n\n\
         Options:\n  \
         --json                 Emit JSON instead of human table\n  \
         --histogram            Emit gate-count summary instead\n  \
         --topo                 Sort topologically (leaves first; implementation order)\n  \
         --gate <name>          Filter by maturity gate (Working | M2TypedOnly | \
                                M3TypedOnly | M4TypedOnly | Informational)\n  \
         --domain <name>        Show one domain (e.g. fetcher, derivation)\n  \
         --flake-lock <path>    Parse a flake.lock and emit a Nord-styled input summary\n  \
         --narinfo <path>       Parse a single .narinfo and emit a Nord-styled record\n  \
         --registry-resolve <ref>   Walk registry precedence (flake-local → user → system → global) for <ref>\n  \
         --realisation <path>   Parse a CA-derivation realisation and show its record\n  \
         --hash-decode <hash>   Classify a hash by algorithm + encoding; decode it\n  \
         --store-path <path>    Decompose a /nix/store path into typed components\n  \
         --coverage             Show sui's nix-replacement coverage matrix\n  \
         --coverage-only <m>    --coverage filtered to one maturity gate\n                        \
                                (working | partial | stub | missing | sui-native)\n  \
         -h, --help             This message"
    );
}

fn gate_name(gate: MaturityGate) -> String {
    match gate {
        MaturityGate::Working         => "Working".into(),
        MaturityGate::M2TypedOnly     => "M2TypedOnly".into(),
        MaturityGate::M3TypedOnly     => "M3TypedOnly".into(),
        MaturityGate::M4TypedOnly     => "M4TypedOnly".into(),
        MaturityGate::Informational   => "Informational".into(),
    }
}

fn emit_histogram(cat: &[SubstrateDomain]) {
    let hist = catalog::maturity_histogram().expect("histogram must compute");
    let width = 28;
    println!("{}", style::box_top(width, Some("substrate maturity")));
    println!(
        "{} {:<15} {:>6} {}",
        muted("│"),
        body("Gate"),
        body("Count"),
        muted("│"),
    );
    println!("{}", style::box_mid(width));
    for (gate, count) in &hist {
        let label = match *gate {
            "Working" => success(&format!("{:<15}", gate)),
            "M2TypedOnly" | "M3TypedOnly" | "M4TypedOnly" => {
                style::pending(&format!("{:<15}", gate))
            }
            "Informational" => info(&format!("{:<15}", gate)),
            _ => body(&format!("{:<15}", gate)),
        };
        let count_str = body(&format!("{:>6}", count));
        println!("{} {} {} {}", muted("│"), label, count_str, muted("│"));
    }
    println!("{}", style::box_mid(width));
    let total = body(&format!("{:>6}", cat.len()));
    let total_label = ident(&format!("{:<15}", "Total"));
    println!("{} {} {} {}", muted("│"), total_label, total, muted("│"));
    println!("{}", style::box_bottom(width));
}

fn gate_style(gate: MaturityGate, text: &str) -> String {
    match gate {
        MaturityGate::Working => success(text),
        MaturityGate::M2TypedOnly => style::warn(text),
        MaturityGate::M3TypedOnly | MaturityGate::M4TypedOnly => style::pending(text),
        MaturityGate::Informational => info(text),
    }
}

fn emit_table(domains: &[&SubstrateDomain]) {
    let name_w = domains
        .iter()
        .map(|d| d.name.len())
        .max()
        .unwrap_or(10)
        .max(6);
    let gate_w = 13;
    let kw_w = domains
        .iter()
        .map(|d| d.authoring_keywords.join(", ").len())
        .max()
        .unwrap_or(20)
        .min(40);

    let banner = format!(
        "{}  {}  ({} domains)",
        glyph_snowflake(),
        header("sui-spec substrate"),
        ident(&domains.len().to_string()),
    );
    println!("{banner}");
    println!();

    println!(
        "{}  {}  {}  {}",
        body(&format!("{:<name_w$}", "Domain", name_w = name_w)),
        body(&format!("{:<gate_w$}", "Gate", gate_w = gate_w)),
        body(&format!("{:<kw_w$}", "Keyword(s)", kw_w = kw_w)),
        body("Purpose"),
    );
    println!(
        "{}",
        muted(&"─".repeat(name_w + gate_w + kw_w + 30))
    );
    for d in domains {
        let kws = d.authoring_keywords.join(", ");
        let kw_trunc = if kws.len() > kw_w {
            format!("{}…", &kws[..kw_w.saturating_sub(1)])
        } else {
            kws.clone()
        };
        let glyph = match d.gate {
            MaturityGate::Working => glyph_ok(),
            _ => glyph_gear(),
        };
        let _ = glyph;
        println!(
            "{} {}  {}  {}  {}",
            match d.gate {
                MaturityGate::Working => glyph_ok(),
                _ => glyph_gear(),
            },
            ident(&format!("{:<name_w$}", d.name, name_w = name_w - 2)),
            gate_style(d.gate, &format!("{:<gate_w$}", gate_name(d.gate), gate_w = gate_w)),
            dim_fg(NORD15, &format!("{:<kw_w$}", kw_trunc, kw_w = kw_w)),
            body(&d.purpose),
        );
    }
}

// Suppress unused-import warnings for items kept available to
// future extensions of this binary.
#[allow(dead_code)]
fn _unused() {
    let _ = (error("x"), NORD13, NORD3, glyph_arrow(), warn("x"), pending("x"), NORD8);
}

// ── --flake-lock mode ──────────────────────────────────────────────

fn emit_flake_lock(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("reading {path}: {e}"))?;
    let fmt = lock_file::load_canonical()?
        .into_iter()
        .find(|f| f.name == "cppnix-flake-lock-v7")
        .ok_or("missing cppnix-flake-lock-v7 format")?;
    let parsed = lock_file::parse(&text, &fmt)?;
    let view = FlakeLockView { lock: parsed, path: path.to_string() };
    render_view(&view);
    Ok(())
}

struct FlakeLockView {
    lock: ParsedLockFile,
    path: String,
}

impl OperatorView for FlakeLockView {
    fn subject(&self) -> &str { &self.path }
    fn header_label(&self) -> &str { "flake.lock" }
    fn render_body(&self) {
        // Version chip on its own line under the banner.
        println!(
            "  {}  {}",
            body("version:"),
            ident(&format!("v{}", self.lock.version)),
        );
        println!();

        let root_inputs = match lock_file::root_inputs(&self.lock) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("{}", error(&format!("error walking root: {e:?}")));
                return;
            }
        };
        println!(
            "{}  {}  {}",
            glyph_arrow(),
            body("root inputs:"),
            ident(&root_inputs.len().to_string()),
        );
        for input_name in &root_inputs {
            println!(
                "  {}  {}  {}",
                success(input_name),
                muted("→"),
                describe_input(&self.lock, input_name),
            );
        }
    }
    fn render_summary(&self) {
        let total = self.lock.nodes.len();
        let direct = lock_file::root_inputs(&self.lock).map(|v| v.len()).unwrap_or(0);
        let transitives = total.saturating_sub(1 + direct);
        println!(
            "{}  {} nodes total  ({} direct, {} transitive)",
            muted("∑"),
            body(&total.to_string()),
            ident(&direct.to_string()),
            info(&transitives.to_string()),
        );
    }
}

/// Produce a one-line description of an input node from its lock entry.
/// Pulls `locked.type` + `locked.owner/repo/rev` when available.
fn describe_input(lock: &ParsedLockFile, name: &str) -> String {
    let Some(node) = lock.nodes.get(name) else {
        return muted("(missing in nodes)").to_string();
    };
    let Some(locked) = node.get("locked").and_then(|v| v.as_object()) else {
        return muted("(no locked metadata)").to_string();
    };
    let kind = locked.get("type").and_then(|v| v.as_str()).unwrap_or("?");
    let rev = locked.get("rev").and_then(|v| v.as_str());
    let owner = locked.get("owner").and_then(|v| v.as_str());
    let repo = locked.get("repo").and_then(|v| v.as_str());
    let url = locked.get("url").and_then(|v| v.as_str());
    let nar_hash = locked.get("narHash").and_then(|v| v.as_str());

    match (kind, owner, repo, url) {
        ("github", Some(o), Some(r), _) => format!(
            "{} {}{}{}",
            info("github:"),
            body(&format!("{o}/{r}")),
            rev.map(|h| format!(" @{}", &h[..8.min(h.len())])).unwrap_or_default(),
            nar_hash.map(|h| format!(" {}", muted(&truncate(h, 28))))
                .unwrap_or_default(),
        ),
        ("git", _, _, Some(u)) => format!(
            "{} {}{}",
            info("git:"),
            body(u),
            rev.map(|h| format!(" @{}", &h[..8.min(h.len())])).unwrap_or_default(),
        ),
        ("path", _, _, _) => format!(
            "{} {}",
            info("path:"),
            nar_hash.map(|h| muted(&truncate(h, 32)).to_string())
                .unwrap_or_else(|| muted("(no narHash)").to_string()),
        ),
        ("tarball", _, _, Some(u)) => format!(
            "{} {}",
            info("tarball:"),
            body(u),
        ),
        _ => format!(
            "{} {}",
            info(&format!("{kind}:")),
            nar_hash.map(|h| muted(&truncate(h, 32)).to_string())
                .unwrap_or_else(|| muted("(no metadata)").to_string()),
        ),
    }
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        format!("{}…", &s[..n.saturating_sub(1)])
    }
}

// ── --narinfo mode ─────────────────────────────────────────────────

fn emit_narinfo(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("reading {path}: {e}"))?;
    let fmt = narinfo::load_canonical()?
        .into_iter()
        .find(|f| f.name == "cppnix-narinfo-v1")
        .ok_or("missing cppnix-narinfo-v1 format")?;
    let parsed = narinfo::parse(&text, &fmt)?;
    let view = NarinfoView { rec: parsed, path: path.to_string() };
    render_view(&view);
    Ok(())
}

struct NarinfoView {
    rec: ParsedNarInfo,
    path: String,
}

impl OperatorView for NarinfoView {
    fn subject(&self) -> &str { &self.path }
    fn header_label(&self) -> &str { "narinfo" }
    fn render_body(&self) {
        let file_size = self.rec.file_size
            .map(|n| format!("{n} bytes"));

        LabeledTable::new(14)
            .kv("StorePath", &self.rec.store_path)
            .kv("URL", &self.rec.url)
            .kv("Compression", &self.rec.compression)
            .kv("NarHash", &self.rec.nar_hash)
            .kv("NarSize", &format!("{} bytes", self.rec.nar_size))
            .opt("FileHash", self.rec.file_hash.as_deref())
            .opt("FileSize", file_size.as_deref())
            .opt("Deriver", self.rec.deriver.as_deref())
            .opt("System", self.rec.system.as_deref())
            .opt("CA", self.rec.ca.as_deref())
            .blank()
            .section("References", Some(self.rec.references.len()))
            .list_items("→", &self.rec.references)
            .blank()
            .section("Signatures", Some(self.rec.signatures.len()))
            .render();

        // Signatures render with custom key-coloring, so we do
        // them outside the table (the LabeledTable.list_items
        // is for plain-string items).
        for sig in &self.rec.signatures {
            match sig.split_once(':') {
                Some((key, val)) => println!(
                    "    {}  {}{}{}",
                    muted("⎷"),
                    info(key),
                    muted(":"),
                    muted(&truncate(val, 32)),
                ),
                None => println!("    {}  {}", muted("⎷"), muted(sig)),
            }
        }
    }
}

// ── --registry-resolve mode ───────────────────────────────────────

fn emit_registry_resolve(flake_ref: &str) -> Result<(), Box<dyn std::error::Error>> {
    // Try real disk entries first via the M3.1 disk loader.  Fall
    // back to the demo set when no on-disk registry exists (fresh
    // operator workstation, CI sandbox, etc.) — this keeps the
    // tool useful at every maturity level.
    let real = registry::discover_disk_registries()?;
    let any_entries = real.iter().any(|(_, e)| !e.is_empty());
    let registries: registry::Registries = if any_entries {
        real
    } else {
        demonstration_entries(flake_ref)
    };

    let view = RegistryResolveView {
        flake_ref: flake_ref.to_string(),
        registries,
        from_disk: any_entries,
    };
    render_view(&view);
    Ok(())
}

struct RegistryResolveView {
    flake_ref: String,
    registries: registry::Registries,
    from_disk: bool,
}

impl OperatorView for RegistryResolveView {
    fn subject(&self) -> &str { &self.flake_ref }
    fn header_label(&self) -> &str { "registry resolve" }
    fn render_body(&self) {
        if !self.from_disk {
            println!(
                "  {} {}",
                muted("·"),
                muted("(no on-disk registries; showing demo entries)"),
            );
            println!();
        }
        let mut sorted: Vec<&(RegistryScope, Vec<RegistryEntry>)> =
            self.registries.iter().collect();
        sorted.sort_by_key(|(scope, _)| scope_precedence(*scope));

        let winning_scope = sorted.iter().find_map(|(scope, entries)| {
            if entries.iter().any(|e| e.from == self.flake_ref) {
                Some(*scope)
            } else {
                None
            }
        });

        let scope_w = 14;
        println!(
            "  {}  {}",
            body(&format!("{:<scope_w$}", "Scope", scope_w = scope_w)),
            body("Entry"),
        );
        println!("  {}", muted(&"─".repeat(60)));
        for (scope, entries) in &sorted {
            let name = scope_name(*scope);
            let match_entry = entries.iter().find(|e| e.from == self.flake_ref);
            match match_entry {
                Some(entry) => {
                    let wins = Some(*scope) == winning_scope;
                    let marker = if wins { glyph_ok() } else { "·".to_string() };
                    println!(
                        "  {} {}  {} {} {}{}",
                        marker,
                        success(&format!("{:<scope_w$}", name, scope_w = scope_w - 2)),
                        info(&entry.from),
                        muted("→"),
                        ident(&entry.to),
                        if entry.exact { format!("  {}", muted("[exact]")) } else { String::new() },
                    );
                }
                None => {
                    println!(
                        "  {} {}  {}",
                        muted("·"),
                        muted(&format!("{:<scope_w$}", name, scope_w = scope_w - 2)),
                        muted("(no entry)"),
                    );
                }
            }
        }
    }
    fn render_summary(&self) {
        let mut sorted: Vec<&(RegistryScope, Vec<RegistryEntry>)> =
            self.registries.iter().collect();
        sorted.sort_by_key(|(scope, _)| scope_precedence(*scope));
        let resolved = sorted.iter().find_map(|(scope, entries)| {
            entries.iter()
                .find(|e| e.from == self.flake_ref)
                .map(|e| (*scope, e))
        });
        match resolved {
            Some((scope, entry)) => println!(
                "  {} resolves to {} via {}",
                glyph_arrow(),
                success(&entry.to),
                ident(scope_name(scope)),
            ),
            None => println!(
                "  {} {} {} {}",
                error("✘"),
                muted("no scope maps"),
                ident(&self.flake_ref),
                muted("— would error: registry-unresolved"),
            ),
        }
    }
}

/// Built-in demo entries so the precedence walk is visible to the
/// operator even before `registry::load_entries_from_disk` exists.
/// Walks the same algorithm `sui_spec::registry::resolve` will use
/// once the disk-loader lands.
fn demonstration_entries(flake_ref: &str) -> registry::Registries {
    vec![
        (
            RegistryScope::FlakeLocal,
            if flake_ref == "self" || flake_ref == "nixpkgs-overlay" {
                vec![RegistryEntry {
                    from: flake_ref.into(),
                    to: "github:pleme-io/substrate".into(),
                    exact: true,
                }]
            } else {
                vec![]
            },
        ),
        (
            RegistryScope::User,
            if flake_ref == "nixpkgs" {
                vec![RegistryEntry {
                    from: "nixpkgs".into(),
                    to: "github:NixOS/nixpkgs/nixos-unstable".into(),
                    exact: false,
                }]
            } else {
                vec![]
            },
        ),
        (
            RegistryScope::System,
            vec![],
        ),
        (
            RegistryScope::Global,
            match flake_ref {
                "nixpkgs" => vec![RegistryEntry {
                    from: "nixpkgs".into(),
                    to: "github:NixOS/nixpkgs".into(),
                    exact: false,
                }],
                "home-manager" => vec![RegistryEntry {
                    from: "home-manager".into(),
                    to: "github:nix-community/home-manager".into(),
                    exact: false,
                }],
                "flake-utils" => vec![RegistryEntry {
                    from: "flake-utils".into(),
                    to: "github:numtide/flake-utils".into(),
                    exact: false,
                }],
                _ => vec![],
            },
        ),
    ]
}

#[allow(dead_code)]
fn emit_registry_table(
    flake_ref: &str,
    registries: &registry::Registries,
    formats: &[registry::RegistryFormat],
) {
    let _ = formats; // Kept for upcoming default_path display.
    let banner = format!(
        "{}  {}  {}",
        glyph_snowflake(),
        header("registry resolve"),
        ident(flake_ref),
    );
    println!("{banner}");
    println!();

    // Sort scopes by precedence (flake-local first, global last).
    let mut sorted: Vec<&(RegistryScope, Vec<RegistryEntry>)> = registries.iter().collect();
    sorted.sort_by_key(|(scope, _)| scope_precedence(*scope));

    // Find the winning scope per cppnix precedence (lowest first).
    let mut winning_scope: Option<RegistryScope> = None;
    let mut winning_entry: Option<&RegistryEntry> = None;
    for (scope, entries) in &sorted {
        for entry in entries.iter() {
            if entry.from == flake_ref {
                winning_scope = Some(*scope);
                winning_entry = Some(entry);
                break;
            }
        }
        if winning_entry.is_some() {
            break;
        }
    }

    let scope_w = 14;
    println!(
        "  {}  {}",
        body(&format!("{:<scope_w$}", "Scope", scope_w = scope_w)),
        body("Entry"),
    );
    println!("  {}", muted(&"─".repeat(60)));
    for (scope, entries) in &sorted {
        let name = scope_name(*scope);
        let match_entry = entries.iter().find(|e| e.from == flake_ref);
        match match_entry {
            Some(entry) => {
                let wins = Some(*scope) == winning_scope;
                let marker = if wins { glyph_ok() } else { "·".to_string() };
                println!(
                    "  {} {}  {} {} {}{}",
                    marker,
                    success(&format!("{:<scope_w$}", name, scope_w = scope_w - 2)),
                    info(&entry.from),
                    muted("→"),
                    ident(&entry.to),
                    if entry.exact { format!("  {}", muted("[exact]")) } else { String::new() },
                );
            }
            None => {
                println!(
                    "  {} {}  {}",
                    muted("·"),
                    muted(&format!("{:<scope_w$}", name, scope_w = scope_w - 2)),
                    muted("(no entry)"),
                );
            }
        }
    }

    println!();
    match (winning_scope, winning_entry) {
        (Some(scope), Some(entry)) => {
            println!(
                "  {} resolves to {} via {}",
                glyph_arrow(),
                success(&entry.to),
                ident(scope_name(scope)),
            );
        }
        _ => {
            println!(
                "  {} {} {} {}",
                error("✘"),
                muted("no scope maps"),
                ident(flake_ref),
                muted("— would error: registry-unresolved"),
            );
        }
    }
}

fn scope_precedence(scope: RegistryScope) -> u32 {
    match scope {
        RegistryScope::FlakeLocal => 0,
        RegistryScope::User       => 1,
        RegistryScope::System     => 2,
        RegistryScope::Global     => 3,
    }
}

fn scope_name(scope: RegistryScope) -> &'static str {
    match scope {
        RegistryScope::FlakeLocal => "flake-local",
        RegistryScope::User       => "user",
        RegistryScope::System     => "system",
        RegistryScope::Global     => "global",
    }
}

// ── --realisation mode ─────────────────────────────────────────────

fn emit_realisation(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("reading {path}: {e}"))?;
    let fmt = realisation::load_canonical()?
        .into_iter()
        .find(|f| f.name == "cppnix-realisation-v1")
        .ok_or("missing cppnix-realisation-v1 format")?;
    let parsed = realisation::parse(&text, &fmt)?;
    let view = RealisationView { rec: parsed, path: path.to_string() };
    render_view(&view);
    Ok(())
}

struct RealisationView {
    rec: ParsedRealisation,
    path: String,
}

impl OperatorView for RealisationView {
    fn subject(&self) -> &str { &self.path }
    fn header_label(&self) -> &str { "realisation" }
    fn render_body(&self) {
        // Block 1: id + outPath
        LabeledTable::new(14)
            .kv("Id", &self.rec.id)
            .kv("OutPath", &self.rec.out_path)
            .render();

        // Custom row: `[drv!out]` decomposition, outside the
        // table's kv layout because it needs three colors.
        if let Some((drv, out)) = self.rec.id.split_once('!') {
            println!(
                "  {}  {}{}{}",
                body(&format!("{:>14}", "[drv!out]")),
                info(drv),
                muted("!"),
                success(out),
            );
        }

        // Block 2: signatures section + nested sig rows
        LabeledTable::new(14)
            .blank()
            .section("Signatures", Some(self.rec.signatures.len()))
            .render();
        for sig in &self.rec.signatures {
            match sig.split_once(':') {
                Some((key, val)) => println!(
                    "    {}  {}{}{}",
                    muted("⎷"),
                    info(key),
                    muted(":"),
                    muted(&truncate(val, 40)),
                ),
                None => println!("    {}  {}", muted("⎷"), muted(sig)),
            }
        }

        // Block 3: dependent realisations
        LabeledTable::new(14)
            .blank()
            .section("DependentRels", Some(self.rec.dependent_realisations.len()))
            .list_items("→", &self.rec.dependent_realisations)
            .render();
    }
}

// ── --hash-decode mode ─────────────────────────────────────────────

fn emit_hash_decode(input: &str) -> Result<(), Box<dyn std::error::Error>> {
    let (algorithm, bytes) = hash::decode_hash(input)?;
    let view = HashDecodeView {
        input: input.to_string(),
        algorithm,
        bytes,
    };
    render_view(&view);
    Ok(())
}

struct HashDecodeView {
    input: String,
    algorithm: String,
    bytes: Vec<u8>,
}

impl OperatorView for HashDecodeView {
    fn subject(&self) -> &str { &self.input }
    fn header_label(&self) -> &str { "hash decode" }
    fn render_body(&self) {
        let byte_len = self.bytes.len().to_string();
        let bit_len = (self.bytes.len() * 8).to_string();
        let encoding = detect_encoding(&self.input).unwrap_or("?");

        LabeledTable::new(14)
            .kv("Algorithm", &self.algorithm)
            .kv("ByteLength", &byte_len)
            .kv("BitLength", &bit_len)
            .opt("Encoding", Some(encoding))
            .blank()
            .section("equivalent encodings:", None)
            .render();

        // Round-trip preview — special-formatted so it stays
        // outside LabeledTable's standard layout.
        for target in ["base16", "sri"] {
            match hash::encode_hash(&self.algorithm, target, &self.bytes) {
                Ok(s) => println!(
                    "    {}  {}  {}",
                    info(&format!("{target:>6}")),
                    muted("→"),
                    ident(&truncate(&s, 72)),
                ),
                Err(_) => println!(
                    "    {}  {}",
                    info(&format!("{target:>6}")),
                    muted("(re-encode unsupported)"),
                ),
            }
        }
    }
    fn render_summary(&self) {
        println!(
            "  {} {} {} {} bits via {}",
            glyph_arrow(),
            success("decoded"),
            ident(&self.bytes.len().to_string()),
            muted("×8 ="),
            info(&self.algorithm),
        );
    }
}

fn detect_encoding(input: &str) -> Option<&'static str> {
    // SRI: starts with `<alg>-` + base64-ish suffix.
    if input.contains('-') && input.split_once('-').is_some_and(|(a, _)| {
        matches!(a, "sha256" | "sha512" | "sha1" | "md5")
    }) {
        return Some("sri");
    }
    // colon-prefixed: <alg>:<hex|base32>
    if let Some((alg, suffix)) = input.split_once(':') {
        if matches!(alg, "sha256" | "sha512" | "sha1" | "md5") {
            return Some(if suffix.chars().all(|c| c.is_ascii_hexdigit()) {
                "hex"
            } else {
                "nix-base32"
            });
        }
    }
    // bare-hex fallback.
    if input.chars().all(|c| c.is_ascii_hexdigit()) {
        return Some("hex");
    }
    None
}

// ── --store-path mode ──────────────────────────────────────────────

fn emit_store_path(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    // Try each canonical store layout; the first match wins.
    let layouts = store_layout::load_canonical()?;
    let mut last_err = None;
    for layout in &layouts {
        match store_layout::parse_path(layout, path) {
            Ok(parsed) => {
                let view = StorePathView {
                    path: path.to_string(),
                    layout_name: layout.name.clone(),
                    parsed,
                };
                render_view(&view);
                return Ok(());
            }
            Err(e) => last_err = Some(e),
        }
    }
    Err(format!(
        "no canonical store layout parsed `{path}`: {:?}",
        last_err,
    ).into())
}

struct StorePathView {
    path: String,
    layout_name: String,
    parsed: ParsedStorePath,
}

impl OperatorView for StorePathView {
    fn subject(&self) -> &str { &self.path }
    fn header_label(&self) -> &str { "store path" }
    fn render_body(&self) {
        LabeledTable::new(14)
            .kv("Layout", &self.layout_name)
            .opt("Algorithm", self.parsed.algorithm.as_deref())
            .kv("Hash", &self.parsed.hash)
            .kv("Name", &self.parsed.name)
            .opt("SubPath", self.parsed.sub_path.as_deref())
            .render();

        // Highlight the hash length — operators want to see at a
        // glance whether this is a 32-char nix-base32 (cppnix)
        // or something else.
        let hash_len = self.parsed.hash.len();
        let len_note = if hash_len == 32 {
            success("32-char nix-base32 (cppnix)")
        } else {
            warn(&format!("{hash_len}-char (non-standard)"))
        };
        println!(
            "  {}  {}",
            body(&format!("{:>14}", "(hash check)")),
            len_note,
        );
    }
    fn render_summary(&self) {
        let parts = match (
            self.parsed.algorithm.as_deref(),
            self.parsed.sub_path.as_deref(),
        ) {
            (Some(a), Some(s)) => format!(
                "{}{}{} {} {} {} {}",
                info(a), muted(":"), success(&self.parsed.hash),
                muted("+"), ident(&self.parsed.name),
                muted("/"), ident(s),
            ),
            (Some(a), None) => format!(
                "{}{}{} {} {}",
                info(a), muted(":"), success(&self.parsed.hash),
                muted("+"), ident(&self.parsed.name),
            ),
            (None, Some(s)) => format!(
                "{} {} {} {} {}",
                success(&self.parsed.hash),
                muted("+"), ident(&self.parsed.name),
                muted("/"), ident(s),
            ),
            (None, None) => format!(
                "{} {} {}",
                success(&self.parsed.hash),
                muted("+"), ident(&self.parsed.name),
            ),
        };
        println!("  {} {}", glyph_arrow(), parts);
    }
}

// ── --coverage mode — sui's nix-replacement progress matrix ────────

fn emit_coverage(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
    let cat = cli_coverage::load_canonical()?;
    let hist = cli_coverage::maturity_histogram()?;
    let pct = cli_coverage::replacement_percentage()?;

    // Banner + summary chip
    println!(
        "{}  {}  ({} commands)",
        glyph_snowflake(),
        header("sui nix-replacement coverage"),
        ident(&cat.len().to_string()),
    );
    println!();

    // Headline percentage bar
    let bar_w = 40;
    let filled = ((pct * bar_w as f64) as usize).min(bar_w);
    let bar: String = (0..bar_w)
        .map(|i| if i < filled { '█' } else { '░' })
        .collect();
    let bar_color = if pct >= 0.7 {
        success(&bar)
    } else if pct >= 0.4 {
        style::pending(&bar)
    } else {
        warn(&bar)
    };
    println!(
        "  {}  {}  {}",
        body("nix replacement:"),
        bar_color,
        ident(&format!("{:.1}%", pct * 100.0)),
    );
    println!();

    // Histogram
    println!("  {}", body("maturity histogram:"));
    for (m, count) in &hist {
        let row = format!("{:<12}", m.name());
        let styled = maturity_style(*m, &row);
        println!(
            "    {} {}  {}",
            maturity_glyph(*m),
            styled,
            ident(&count.to_string()),
        );
    }
    println!();

    // Per-command table (filtered if --coverage-only was supplied)
    let filtered: Vec<&SuiCommand> = cat
        .iter()
        .filter(|c| match args.coverage_filter {
            Some(m) => c.maturity == m,
            None => true,
        })
        .collect();

    if filtered.is_empty() {
        eprintln!("no commands match the filter");
        return Ok(());
    }

    let name_w = filtered.iter().map(|c| c.name.len()).max().unwrap_or(10);
    let nix_w = filtered.iter().map(|c| c.nix_equivalent.len()).max()
        .unwrap_or(20).min(40);

    println!(
        "  {}  {}  {}  {}",
        body(&format!("{:<name_w$}", "sui command", name_w = name_w)),
        body(&format!("{:<width$}", "nix equivalent", width = nix_w)),
        body("maturity"),
        body("substrate"),
    );
    println!("  {}", muted(&"─".repeat(name_w + nix_w + 32)));

    for c in &filtered {
        let nix = if c.nix_equivalent.is_empty() {
            muted(&format!("{:<width$}", "(sui-native)", width = nix_w))
        } else {
            ident(&format!("{:<width$}", &c.nix_equivalent, width = nix_w))
        };
        let m_label = maturity_style(c.maturity, c.maturity.name());
        let m_glyph = maturity_glyph(c.maturity);
        let subs = if c.substrate.is_empty() {
            muted("—").to_string()
        } else {
            success(&c.substrate.join(", "))
        };
        println!(
            "  {} {}  {}  {}  {}",
            m_glyph,
            ident(&format!("{:<name_w$}", c.name, name_w = name_w.saturating_sub(2))),
            nix,
            m_label,
            subs,
        );
    }
    Ok(())
}

fn maturity_glyph(m: SuiCommandMaturity) -> String {
    match m {
        SuiCommandMaturity::Working   => glyph_ok(),
        SuiCommandMaturity::Partial   => style::pending("◐"),
        SuiCommandMaturity::Stub      => style::warn("○"),
        SuiCommandMaturity::Missing   => error("✘"),
        SuiCommandMaturity::SuiNative => info("◆"),
    }
}

fn maturity_style(m: SuiCommandMaturity, text: &str) -> String {
    match m {
        SuiCommandMaturity::Working   => success(text),
        SuiCommandMaturity::Partial   => style::pending(text),
        SuiCommandMaturity::Stub      => style::warn(text),
        SuiCommandMaturity::Missing   => error(text),
        SuiCommandMaturity::SuiNative => info(text),
    }
}
