//! `ckg export` — dump the graph as JSON / DOT / ai-context.
use std::path::PathBuf;

use anyhow::Result;
use clap::Args as ClapArgs;

#[derive(ClapArgs, Debug)]
pub struct Args {
    #[arg(long, default_value = "json")]
    pub format: String,
    #[arg(long, default_value_t = 8000)]
    pub max_tokens: u32,
}

pub fn run(home: Option<PathBuf>, args: Args) -> Result<()> {
    let (_base, storage) = super::repo_lookup::open_for(home, None)?;
    match args.format.as_str() {
        "json" => {
            let symbols = storage.run_immutable("?[id, qname, name, kind, file, line] := *Symbol{id, qname, name, kind, file, line}")?;
            let calls = storage.run_immutable("?[src, dst] := *Calls{src, dst}")?;
            let out = serde_json::json!({
                "symbols": symbols.into_json(),
                "calls": calls.into_json(),
            });
            println!("{}", serde_json::to_string_pretty(&out)?);
        }
        "dot" => {
            println!("digraph G {{");
            let calls = storage.run_immutable("?[src, dst] := *Calls{src, dst} :limit 5000")?;
            for row in calls.rows {
                if let (cozo::DataValue::Str(s), cozo::DataValue::Str(d)) = (&row[0], &row[1]) {
                    // Item #23: escape `"` and `\` in qnames before
                    // emitting DOT. Unescaped values produce invalid
                    // DOT that downstream renderers (graphviz, etc.)
                    // reject.
                    println!("  \"{}\" -> \"{}\";", dot_escape(s), dot_escape(d));
                }
            }
            println!("}}");
        }
        "ai-context" => {
            // Top hotspots by inbound CALLS count, capped to budget.
            //
            // Cozo aggregation must live at the head of a rule, not
            // inline inside another rule's body — the original
            // `deg = count(*Calls{dst: id})` was a parse error. Two
            // rules: first aggregates inbound degree per Symbol id,
            // second joins onto the Symbol projection.
            let r = storage.run_immutable(
                "inbound[dst, count(src)] := *Calls{src, dst} \
                 ?[qname, kind, file, line, deg] := \
                   inbound[id, deg], \
                   *Symbol{id, qname, kind, file, line} \
                 :order -deg \
                 :limit 200",
            )?;
            let out = serde_json::json!({"hotspots": r.into_json()});
            let s = serde_json::to_string(&out)?;
            let max_chars = (args.max_tokens as usize).saturating_mul(4);
            // Item #23: char-boundary-safe truncation. Byte slicing
            // panics if `max_chars` lands inside a multi-byte UTF-8
            // codepoint (rare but real for repos with non-ASCII
            // identifiers / paths).
            println!("{}", char_boundary_truncate(&s, max_chars));
        }
        other => anyhow::bail!("unknown format {other:?}; use json|dot|ai-context"),
    }
    Ok(())
}

/// DOT format requires `"` and `\` inside quoted strings to be
/// backslash-escaped. Common qname characters (`<`, `:`, `(`) are fine
/// inside `"..."`; only the two listed need escaping.
fn dot_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Truncate `s` at the largest char boundary `<= max_bytes`. Returns
/// the unchanged string when shorter than the cap. Byte-slice
/// `&s[..max_bytes]` panics on multi-byte boundary mismatch; this is
/// the safe equivalent.
fn char_boundary_truncate(s: &str, max_bytes: usize) -> &str {
    if s.len() <= max_bytes {
        return s;
    }
    let mut i = max_bytes;
    while i > 0 && !s.is_char_boundary(i) {
        i -= 1;
    }
    &s[..i]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dot_escape_handles_quote_and_backslash() {
        assert_eq!(dot_escape("plain"), "plain");
        assert_eq!(dot_escape("a\"b"), "a\\\"b");
        assert_eq!(dot_escape("a\\b"), "a\\\\b");
        assert_eq!(dot_escape("\\\""), "\\\\\\\"");
    }

    #[test]
    fn char_boundary_truncate_doesnt_panic_on_multibyte() {
        let s = "héllo";
        // `é` is 2 bytes (0xC3 0xA9) starting at byte 1.
        // max_bytes = 2 lands inside `é`; must not panic.
        let t = char_boundary_truncate(s, 2);
        assert_eq!(t, "h", "must truncate at char boundary, got {t:?}");
    }

    #[test]
    fn char_boundary_truncate_preserves_short_strings() {
        assert_eq!(char_boundary_truncate("abc", 100), "abc");
        assert_eq!(char_boundary_truncate("abc", 3), "abc");
    }
}
