use clap::{Parser, Subcommand};
use serde_json;
use std::collections::{HashMap, HashSet};
use std::io::Read;

use theseus_core::*;

mod traffic;
use traffic::TrafficAction;

const DEFAULT_SERVER: &str = "http://127.0.0.1:3080";

#[derive(Parser)]
#[command(name = "theseus", about = "Attack path graph for penetration testing")]
struct Cli {
    #[command(subcommand)]
    command: TopLevel,
}

#[derive(Subcommand)]
enum TopLevel {
    /// Start the Theseus server
    Serve {
        /// Port to listen on
        #[arg(short, long, default_value = "3080")]
        port: u16,

        /// DeepSeek API key for AI features (or set THESEUS_DEEPSEEK_API_KEY env var, or theseus.toml)
        #[arg(long)]
        api_key: Option<String>,
    },
    /// Project CRUD (talks to a running server)
    Project {
        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,

        #[command(subcommand)]
        action: ProjectAction,
    },
    /// Node CRUD (talks to a running server)
    Node {
        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,

        #[command(subcommand)]
        action: NodeAction,
    },
    /// Edge CRUD (talks to a running server)
    Edge {
        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,

        #[command(subcommand)]
        action: EdgeAction,
    },
    /// Credential CRUD (talks to a running server)
    Credential {
        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,

        #[command(subcommand)]
        action: CredentialAction,
    },
    /// Bulk import nodes and edges from JSON file
    Import {
        project_id: String,

        /// JSON file to import
        #[arg(short, long)]
        file: String,

        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,
    },
    /// Search across all projects (matches node label + content)
    Search {
        /// Search query
        query: String,

        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,
    },
    /// Traffic import — parse Yakit Excel and import as nodes/edges
    Traffic {
        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,

        #[command(subcommand)]
        action: TrafficAction,
    },
    /// Natural language — describe findings, AI creates nodes and edges
    Tell {
        project_id: String,

        /// Natural language description of findings
        message: Option<String>,

        /// Read message from stdin
        #[arg(long, conflicts_with = "message")]
        stdin: bool,

        /// Read message from file
        #[arg(long, conflicts_with_all = ["message", "stdin"])]
        file: Option<String>,

        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,
    },
    /// Natural language — ask questions about the current attack graph
    Ask {
        project_id: String,

        /// Question about the attack graph
        question: String,

        /// Output raw JSON
        #[arg(long)]
        json: bool,

        #[arg(short, long, default_value = DEFAULT_SERVER)]
        server: String,
    },
}

#[derive(Subcommand)]
enum ProjectAction {
    /// Create a new project
    Create {
        name: String,

        /// Output raw JSON instead of short format
        #[arg(long)]
        json: bool,
    },
    /// List all projects
    List {
        /// Output raw JSON instead of table
        #[arg(long)]
        json: bool,
    },
    /// Delete a project
    Delete { project_id: String },
    /// Analyze project: attack paths, unexploited vulns, dead ends, next steps
    Analysis {
        project_id: String,

        /// Include AI-generated next-step suggestions
        #[arg(long)]
        ai: bool,

        /// Output raw JSON instead of dashboard
        #[arg(long)]
        json: bool,
    },
    /// Show project status overview (node/edge summary, verdicts, last activity)
    Status {
        project_id: String,

        /// Output raw JSON instead of dashboard
        #[arg(long)]
        json: bool,
    },
    /// Show project timeline (chronological node view with connections)
    Timeline {
        project_id: String,

        /// Output raw JSON instead of formatted timeline
        #[arg(long)]
        json: bool,
    },
    /// Show report-focused statistics by type
    Stats {
        project_id: String,

        /// Output raw JSON instead of dashboard
        #[arg(long)]
        json: bool,
    },
    /// Generate AI penetration test report in Markdown
    Report {
        project_id: String,

        /// Save Markdown to file instead of printing to stdout
        #[arg(short, long)]
        output: Option<String>,
    },
    /// Graph health check — deterministic quality checks (no AI needed)
    Check {
        project_id: String,

        /// Output raw JSON instead of formatted report
        #[arg(long)]
        json: bool,
    },
}

#[derive(Subcommand)]
enum NodeAction {
    /// Add a node to a project
    Add {
        project_id: String,
        label: String,

        #[arg(short = 't', long, default_value = "note")]
        node_type: String,

        #[arg(short = 'p', long, help = "JSON properties string")]
        props: Option<String>,

        /// Read JSON properties from file (--props-file /path/to/props.json)
        #[arg(long)]
        props_file: Option<String>,

        #[arg(
            short = 'c',
            long,
            help = "Markdown content. Wrap in single quotes if it contains backticks."
        )]
        content: Option<String>,

        /// Read Markdown content from stdin. Heredoc-friendly: cat <<'EOF' | theseus node add ... --content-stdin
        #[arg(long, conflicts_with_all = ["content", "content_file"])]
        content_stdin: bool,

        /// Read Markdown content from file
        #[arg(long, conflicts_with_all = ["content", "content_stdin"])]
        content_file: Option<String>,

        /// After which node (auto-creates edge). Supports UUID prefix matching.
        #[arg(long)]
        after: Option<String>,

        /// Label for the auto-created edge (only used with --after)
        #[arg(long)]
        edge_label: Option<String>,

        /// Set verdict (pending, success, failed, in_progress)
        #[arg(long)]
        verdict: Option<String>,

        /// Set evidence level (confirmed, unverified, hypothesis)
        #[arg(long)]
        evidence: Option<String>,

        /// Output raw JSON instead of short format
        #[arg(long)]
        json: bool,

        /// After creation, ask AI to warn about semantically duplicate nodes
        #[arg(long)]
        check_duplicate: bool,
    },
    /// List nodes in a project (default: table with short IDs. Use --json for full output)
    List {
        project_id: String,

        /// Filter by node type (supports abbreviations: vuln, svc, host, etc.)
        #[arg(short = 't', long)]
        r#type: Option<String>,

        /// Filter by verdict (pending, success, failed, in_progress)
        #[arg(long)]
        verdict: Option<String>,

        /// Filter by evidence level (confirmed, unverified, hypothesis)
        #[arg(long)]
        evidence: Option<String>,

        /// Show only leaf nodes (have incoming edges but no outgoing, not dead ends)
        #[arg(long)]
        leaves: bool,

        /// Output raw JSON instead of summary table
        #[arg(long)]
        json: bool,
    },
    /// Get a single node by ID (supports UUID prefix matching)
    Get { project_id: String, node_id: String },
    /// Update a node — supports partial updates. Only specified fields are changed.
    Update {
        project_id: String,
        node_id: String,

        #[arg(short = 'l', long)]
        label: Option<String>,

        #[arg(short = 't', long)]
        node_type: Option<String>,

        #[arg(long, help = "JSON properties to merge")]
        props: Option<String>,

        #[arg(short = 'c', long, help = "Replace Markdown content")]
        content: Option<String>,

        /// Read new Markdown content from stdin (heredoc-friendly)
        #[arg(long, conflicts_with_all = ["content", "content_file"])]
        content_stdin: bool,

        /// Read new Markdown content from file
        #[arg(long, conflicts_with_all = ["content", "content_stdin"])]
        content_file: Option<String>,

        /// Append text to existing content
        #[arg(long)]
        append_content: Option<String>,

        /// Set verdict in properties (e.g. success, failed, in_progress)
        #[arg(long)]
        set_verdict: Option<String>,

        /// Set evidence level (confirmed, unverified, hypothesis)
        #[arg(long)]
        set_evidence: Option<String>,
    },
    /// Delete a node (supports UUID prefix matching)
    Delete { project_id: String, node_id: String },
    /// Search nodes by keyword (matches label + content)
    Search { project_id: String, query: String },
}

#[derive(Subcommand)]
enum EdgeAction {
    /// Add an edge between nodes. Use node IDs or --from-label/--to-label for label matching.
    Add {
        project_id: String,

        /// Source node ID (omit when using --from-label)
        from: Option<String>,

        /// Target node ID (omit when using --to-label)
        to: Option<String>,

        #[arg(short, long)]
        label: Option<String>,

        /// Find source node by label (exact match, or prefix if unique)
        #[arg(long, conflicts_with = "from")]
        from_label: Option<String>,

        /// Find target node by label (exact match, or prefix if unique)
        #[arg(long, conflicts_with = "to")]
        to_label: Option<String>,

        /// Output raw JSON instead of short format
        #[arg(long)]
        json: bool,
    },
    /// List edges in a project
    List {
        project_id: String,

        /// Output raw JSON instead of summary table
        #[arg(long)]
        json: bool,
    },
    /// Update an edge label
    Update {
        project_id: String,
        edge_id: String,

        #[arg(short, long)]
        label: String,
    },
    /// Delete an edge
    Delete { project_id: String, edge_id: String },
}

#[derive(Subcommand)]
enum CredentialAction {
    /// Add a credential to a project
    Add {
        project_id: String,
        label: String,

        /// Credential type: password, ssh_key, aksk, token
        #[arg(short = 't', long, default_value = "password")]
        credential_type: String,

        /// Secret value for the credential
        #[arg(short, long)]
        secret: String,

        /// Username or access key id
        #[arg(short, long)]
        username: Option<String>,

        /// Source node ID. Supports UUID prefix matching.
        #[arg(long)]
        source_node: Option<String>,

        /// Credential status: unverified, verified, expired
        #[arg(long, default_value = "unverified")]
        status: String,

        /// Notes about where/how this credential was found or validated
        #[arg(long)]
        notes: Option<String>,

        /// Output raw JSON instead of short format
        #[arg(long)]
        json: bool,
    },
    /// List credentials in a project
    List {
        project_id: String,

        /// Show secret values in table output
        #[arg(long)]
        show_secrets: bool,

        /// Output raw JSON instead of summary table
        #[arg(long)]
        json: bool,
    },
    /// Update a credential. Only specified fields are changed.
    Update {
        project_id: String,
        credential_id: String,

        #[arg(short = 'l', long)]
        label: Option<String>,

        /// Credential type: password, ssh_key, aksk, token
        #[arg(short = 't', long)]
        credential_type: Option<String>,

        /// Replace secret value
        #[arg(short, long)]
        secret: Option<String>,

        /// Replace username or access key id
        #[arg(short, long)]
        username: Option<String>,

        /// Replace source node ID. Supports UUID prefix matching.
        #[arg(long, conflicts_with = "clear_source_node")]
        source_node: Option<String>,

        /// Clear the source node association
        #[arg(long)]
        clear_source_node: bool,

        /// Credential status: unverified, verified, expired
        #[arg(long)]
        status: Option<String>,

        /// Replace notes
        #[arg(long, conflicts_with = "clear_notes")]
        notes: Option<String>,

        /// Clear notes
        #[arg(long)]
        clear_notes: bool,
    },
    /// Delete a credential. Supports UUID prefix matching.
    Delete {
        project_id: String,
        credential_id: String,
    },
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    match cli.command {
        TopLevel::Serve { port, api_key } => {
            theseus_server::serve(port, api_key).await;
        }
        TopLevel::Project { server, action } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = handle_project(&client, base, action).await {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Node { server, action } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = handle_node(&client, base, action).await {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Edge { server, action } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = handle_edge(&client, base, action).await {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Credential { server, action } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = handle_credential(&client, base, action).await {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Import {
            project_id,
            file,
            server,
        } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = async {
                let project_id = resolve_project_id(&client, base, &project_id).await?;
                let content = std::fs::read_to_string(&file)?;
                let data: serde_json::Value = serde_json::from_str(&content)?;
                let resp = client
                    .post(format!("{base}/api/projects/{project_id}/import"))
                    .json(&data)
                    .send()
                    .await?;
                print_json(resp).await?;
                Ok::<(), BoxErr>(())
            }
            .await
            {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Search { query, server } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = async {
                if query.trim().is_empty() {
                    eprintln!("Error: search query cannot be empty");
                    std::process::exit(1);
                }
                let resp = client
                    .get(format!("{base}/api/search?q={}", url_encode_query(&query)))
                    .send()
                    .await?;
                let body: ApiResponse<Vec<SearchResult>> = resp.json().await?;
                let results = body.data.unwrap_or_default();
                if results.is_empty() {
                    println!("no results found for '{}'", query);
                } else {
                    for r in &results {
                        let short_id: String = r.node_id.to_string().chars().take(8).collect();
                        let ntype = sanitize_cell(&r.node_type, 20);
                        let label = sanitize_cell(&r.node_label, 40);
                        let ctx = sanitize_cell(&r.match_context, 60);
                        println!("[{}] {label} ({ntype})", r.project_name);
                        println!("  {short_id}  {ctx}");
                    }
                    eprintln!("— {} result(s) found —", results.len());
                }
                Ok::<(), BoxErr>(())
            }
            .await
            {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Traffic { server, action } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = traffic::handle_traffic(&client, base, action).await {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Tell {
            project_id,
            message,
            stdin,
            file,
            server,
        } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = async {
                let message = read_message(message, stdin, file).await?;
                if message.trim().is_empty() {
                    return Err("message cannot be empty".into());
                }
                let project_id = resolve_project_id(&client, base, &project_id).await?;
                let resp = client
                    .post(format!("{base}/api/projects/{project_id}/tell"))
                    .json(&serde_json::json!({ "message": message }))
                    .send()
                    .await?;
                print_tell_result(&client, base, &project_id, resp).await?;
                Ok::<(), BoxErr>(())
            }
            .await
            {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
        TopLevel::Ask {
            project_id,
            question,
            json,
            server,
        } => {
            let client = reqwest::Client::new();
            let base = server.trim_end_matches('/');
            if let Err(e) = async {
                if question.trim().is_empty() {
                    return Err("question cannot be empty".into());
                }
                let project_id = resolve_project_id(&client, base, &project_id).await?;
                let resp = client
                    .post(format!("{base}/api/projects/{project_id}/ask"))
                    .json(&serde_json::json!({ "message": question }))
                    .send()
                    .await?;
                if json {
                    print_result(resp).await?;
                } else {
                    print_ask_result(resp).await?;
                }
                Ok::<(), BoxErr>(())
            }
            .await
            {
                eprintln!("Error: {e}");
                std::process::exit(1);
            }
        }
    }
}

type BoxErr = Box<dyn std::error::Error>;

// ── Helpers ──

/// Resolve a (possibly partial) UUID string to a full UUID by listing nodes
/// and matching prefix. Returns the full UUID string if found, or an error.
async fn resolve_node_id(
    client: &reqwest::Client,
    base: &str,
    project_id: &str,
    partial: &str,
) -> Result<String, BoxErr> {
    // Try exact parse first
    if uuid::Uuid::parse_str(partial).is_ok() {
        return Ok(partial.to_string());
    }
    // Prefix matching: list nodes and find match
    if partial.len() < 6 {
        return Err(format!(
            "UUID prefix too short: need at least 6 characters, got {}",
            partial.len()
        )
        .into());
    }
    let resp = client
        .get(format!("{base}/api/projects/{project_id}/nodes"))
        .send()
        .await?;
    let body: ApiResponse<Vec<NodeResponse>> = resp.json().await?;
    let nodes = body.data.ok_or("no data in response")?;
    let matches: Vec<_> = nodes
        .iter()
        .filter(|n| n.id.to_string().starts_with(partial))
        .collect();
    match matches.len() {
        0 => return Err(format!("no node found with prefix '{}'", partial).into()),
        1 => Ok(matches[0].id.to_string()),
        _ => {
            return Err(format!(
                "ambiguous prefix '{}': {} nodes match",
                partial,
                matches.len()
            )
            .into())
        }
    }
}

async fn resolve_credential_id(
    client: &reqwest::Client,
    base: &str,
    project_id: &str,
    partial: &str,
) -> Result<String, BoxErr> {
    if uuid::Uuid::parse_str(partial).is_ok() {
        return Ok(partial.to_string());
    }
    if partial.len() < 6 {
        return Err(format!(
            "UUID prefix too short: need at least 6 characters, got {}",
            partial.len()
        )
        .into());
    }
    let resp = client
        .get(format!("{base}/api/projects/{project_id}/credentials"))
        .send()
        .await?;
    let body: ApiResponse<Vec<Credential>> = resp.json().await?;
    let credentials = body.data.ok_or("no data in response")?;
    let matches: Vec<_> = credentials
        .iter()
        .filter(|credential| credential.id.to_string().starts_with(partial))
        .collect();
    match matches.len() {
        0 => Err(format!("no credential found with prefix '{}'", partial).into()),
        1 => Ok(matches[0].id.to_string()),
        _ => Err(format!(
            "ambiguous prefix '{}': {} credentials match",
            partial,
            matches.len()
        )
        .into()),
    }
}

/// Resolve a node by label: exact match first, then prefix match if unique.
async fn resolve_node_by_label(
    client: &reqwest::Client,
    base: &str,
    project_id: &str,
    label_query: &str,
) -> Result<String, BoxErr> {
    let resp = client
        .get(format!("{base}/api/projects/{project_id}/nodes"))
        .send()
        .await?;
    let body: ApiResponse<Vec<NodeResponse>> = resp.json().await?;
    let nodes = body.data.ok_or("no data in response")?;

    // 1. Exact match
    let exact: Vec<_> = nodes.iter().filter(|n| n.label == label_query).collect();
    if exact.len() == 1 {
        return Ok(exact[0].id.to_string());
    }
    if exact.len() > 1 {
        let labels: Vec<&str> = exact.iter().map(|n| n.label.as_str()).collect();
        return Err(format!(
            "multiple nodes with exact label '{}': [{}]",
            label_query,
            labels.join(", ")
        )
        .into());
    }

    // 2. Prefix match (label starts with query)
    let prefix: Vec<_> = nodes
        .iter()
        .filter(|n| n.label.starts_with(label_query))
        .collect();
    if prefix.len() == 1 {
        return Ok(prefix[0].id.to_string());
    }
    if prefix.len() > 1 {
        let labels: Vec<&str> = prefix.iter().map(|n| n.label.as_str()).collect();
        return Err(format!(
            "ambiguous label '{}': matches [{}]",
            label_query,
            labels.join(", ")
        )
        .into());
    }

    // 3. Contains match
    let contains: Vec<_> = nodes
        .iter()
        .filter(|n| n.label.contains(label_query))
        .collect();
    if contains.len() == 1 {
        return Ok(contains[0].id.to_string());
    }
    if contains.len() > 1 {
        let labels: Vec<&str> = contains.iter().map(|n| n.label.as_str()).collect();
        return Err(format!(
            "ambiguous label '{}': matches [{}]",
            label_query,
            labels.join(", ")
        )
        .into());
    }

    Err(format!("no node found matching label '{}'", label_query).into())
}

/// Resolve a (possibly partial) project UUID by listing projects and matching prefix.
async fn resolve_project_id(
    client: &reqwest::Client,
    base: &str,
    partial: &str,
) -> Result<String, BoxErr> {
    if uuid::Uuid::parse_str(partial).is_ok() {
        return Ok(partial.to_string());
    }
    if partial.len() < 6 {
        return Err(format!(
            "UUID prefix too short: need at least 6 characters, got {}",
            partial.len()
        )
        .into());
    }
    let resp = client.get(format!("{base}/api/projects")).send().await?;
    let body: ApiResponse<Vec<ProjectResponse>> = resp.json().await?;
    let projects = body.data.ok_or("no data in response")?;
    let matches: Vec<_> = projects
        .iter()
        .filter(|p| p.id.to_string().starts_with(partial))
        .collect();
    match matches.len() {
        0 => Err(format!("no project found with prefix '{}'", partial).into()),
        1 => Ok(matches[0].id.to_string()),
        _ => Err(format!(
            "ambiguous prefix '{}': {} projects match",
            partial,
            matches.len()
        )
        .into()),
    }
}

/// Read content from --content, --content-stdin, or --content-file.
/// Returns (content, has_content) where has_content is false if none specified.
async fn read_content(
    content: Option<String>,
    content_stdin: bool,
    content_file: Option<String>,
) -> Result<(Option<String>, bool), BoxErr> {
    if content_stdin {
        let mut buf = String::new();
        std::io::stdin().read_to_string(&mut buf)?;
        return Ok((Some(buf), true));
    }
    if let Some(path) = content_file {
        let buf = std::fs::read_to_string(&path)?;
        return Ok((Some(buf), true));
    }
    if let Some(c) = content {
        return Ok((Some(c), true));
    }
    Ok((None, false))
}

async fn read_message(
    message: Option<String>,
    stdin: bool,
    file: Option<String>,
) -> Result<String, BoxErr> {
    if stdin {
        let mut buf = String::new();
        std::io::stdin().read_to_string(&mut buf)?;
        return Ok(buf);
    }
    if let Some(path) = file {
        return Ok(std::fs::read_to_string(&path)?);
    }
    message.ok_or_else(|| "message required: provide MESSAGE, --stdin, or --file".into())
}

/// Read props from --props or --props-file.
async fn read_props(
    props: Option<String>,
    props_file: Option<String>,
) -> Result<Option<serde_json::Value>, BoxErr> {
    if let Some(path) = props_file {
        let buf = std::fs::read_to_string(&path)?;
        return Ok(Some(serde_json::from_str(&buf)?));
    }
    if let Some(s) = props {
        return Ok(Some(serde_json::from_str(&s)?));
    }
    Ok(None)
}

fn parse_node_type(s: &str) -> NodeType {
    serde_json::from_value(serde_json::Value::String(s.to_string())).unwrap_or(NodeType::Note)
}

fn parse_credential_type(s: &str) -> Result<CredentialType, BoxErr> {
    serde_json::from_value(serde_json::Value::String(s.to_string())).map_err(|_| {
        format!("invalid credential type '{s}': expected password, ssh_key, aksk, or token").into()
    })
}

fn parse_credential_status(s: &str) -> Result<CredentialStatus, BoxErr> {
    serde_json::from_value(serde_json::Value::String(s.to_string())).map_err(|_| {
        format!("invalid credential status '{s}': expected unverified, verified, or expired").into()
    })
}

fn resolve_type_abbrev(s: &str) -> String {
    match s {
        "entry" | "ep" => "entry_point".to_string(),
        "obj" => "objective".to_string(),
        "recon" => "reconnaissance".to_string(),
        "privesc" => "privilege_escalation".to_string(),
        "vuln" => "vulnerability".to_string(),
        "cred" => "credential".to_string(),
        "svc" => "service".to_string(),
        "net" => "network".to_string(),
        "exfil" => "exfiltration".to_string(),
        "exploit" => "exploitation".to_string(),
        _ => s.to_string(),
    }
}

// ── Project handlers ──

async fn handle_project(
    client: &reqwest::Client,
    base: &str,
    action: ProjectAction,
) -> Result<(), BoxErr> {
    match action {
        ProjectAction::Create { name, json } => {
            let resp = client
                .post(format!("{base}/api/projects"))
                .json(&CreateProjectRequest { name })
                .send()
                .await?;
            if json {
                print_json(resp).await?;
            } else {
                print_created(resp).await?;
            }
        }
        ProjectAction::List { json } => {
            let resp = client.get(format!("{base}/api/projects")).send().await?;
            if json {
                print_result(resp).await?;
            } else {
                let body: serde_json::Value = resp.json().await?;
                if let Some(projects) = body["data"].as_array() {
                    for (i, p) in projects.iter().enumerate() {
                        let short_id: String =
                            p["id"].as_str().unwrap_or("?").chars().take(8).collect();
                        let name = sanitize_cell(p["name"].as_str().unwrap_or("?"), 50);
                        println!("#{:<3} {}  {}", i + 1, short_id, name);
                    }
                }
            }
        }
        ProjectAction::Delete { project_id } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resp = client
                .delete(format!("{base}/api/projects/{project_id}"))
                .send()
                .await?;
            _print_created(resp, None).await?;
        }
        ProjectAction::Analysis {
            project_id,
            ai,
            json,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let url = if ai {
                format!("{base}/api/projects/{project_id}/analysis?ai=true")
            } else {
                format!("{base}/api/projects/{project_id}/analysis")
            };
            let resp = client.get(&url).send().await?;
            if json {
                print_result(resp).await?;
            } else {
                let body: serde_json::Value = resp.json().await?;
                let data = &body["data"];
                println!("═══ 项目分析 ═══\n");

                // Attack paths
                let paths = data["attack_paths"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                println!("▎攻击路径 ({})", paths);
                if let Some(arr) = data["attack_paths"].as_array() {
                    for (i, path) in arr.iter().enumerate() {
                        let labels: Vec<&str> = path["nodes"]
                            .as_array()
                            .map(|ns| ns.iter().filter_map(|n| n["label"].as_str()).collect())
                            .unwrap_or_default();
                        println!("  路径{}: {}", i + 1, labels.join(" → "));
                    }
                }

                // Unexploited vulns
                println!();
                let vulns = data["unexploited_vulns"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                println!("▎未利用漏洞 ({})", vulns);
                if let Some(arr) = data["unexploited_vulns"].as_array() {
                    for v in arr {
                        let label = v["label"].as_str().unwrap_or("?");
                        let nt = v["node_type"].as_str().unwrap_or("?");
                        println!("  - {} ({})", label, nt);
                    }
                }

                // Dead ends
                println!();
                let dead = data["dead_ends"].as_array().map(|a| a.len()).unwrap_or(0);
                println!("▎死路 ({})", dead);
                if let Some(arr) = data["dead_ends"].as_array() {
                    for d in arr {
                        let label = d["label"].as_str().unwrap_or("?");
                        let nt = d["node_type"].as_str().unwrap_or("?");
                        println!("  - {} ({})", label, nt);
                    }
                }

                // Next steps
                println!();
                let steps = data["next_steps"].as_array().map(|a| a.len()).unwrap_or(0);
                println!("▎下一步建议 ({})", steps);
                if let Some(arr) = data["next_steps"].as_array() {
                    for (i, s) in arr.iter().enumerate() {
                        let suggestion = s["suggestion"].as_str().unwrap_or("?");
                        println!("  {}. {}", i + 1, suggestion);
                    }
                }

                // Cross connections
                println!();
                let cross = data["cross_connections"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                println!("▎跨连接 ({})", cross);
                if let Some(arr) = data["cross_connections"].as_array() {
                    for c in arr {
                        let from = c["from_label"].as_str().unwrap_or("?");
                        let to = c["to_label"].as_str().unwrap_or("?");
                        let reason = c["reason"].as_str().unwrap_or("?");
                        println!("  {} → {}  ({})", from, to, reason);
                    }
                }
                if cross == 0 {
                    println!("  (无)");
                }

                // Health checks
                println!();
                let health = data["health_checks"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                println!("▎图谱健康检查 ({})", health);
                if let Some(arr) = data["health_checks"].as_array() {
                    for issue in arr {
                        let severity = issue["severity"].as_str().unwrap_or("warning");
                        let check_type = issue["check_type"].as_str().unwrap_or("?");
                        let description = issue["description"].as_str().unwrap_or("?");
                        let marker = if severity == "error" { "✗" } else { "⚠" };
                        println!("  {} {}: {}", marker, check_type, description);
                    }
                }
                if health == 0 {
                    println!("  (无 — 未发现确定性图谱问题)");
                }

                // Check summary (from graph check)
                if let Some(check) = data["check_summary"].as_object() {
                    println!();
                    let check_issues = check["issues"].as_array().map(|a| a.len()).unwrap_or(0);
                    println!("▎图谱检查 ({})", check_issues);
                    if let Some(arr) = check["issues"].as_array() {
                        for issue in arr {
                            let rule = issue["rule"].as_str().unwrap_or("?");
                            let label = issue["node_label"].as_str().unwrap_or("?");
                            let desc = issue["description"].as_str().unwrap_or("?");
                            println!("  • {} [{}] {}", label, rule, desc);
                        }
                    }
                    if check_issues == 0 {
                        println!("  (无)");
                    }
                }

                // Isolated nodes
                println!();
                let isolated = data["isolated_nodes"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                println!("▎孤立节点 ({})", isolated);
                if let Some(arr) = data["isolated_nodes"].as_array() {
                    for n in arr {
                        let label = n["label"].as_str().unwrap_or("?");
                        let nt = n["node_type"].as_str().unwrap_or("?");
                        println!("  - {} ({})", label, nt);
                    }
                }
                if isolated == 0 {
                    println!("  (无 — 所有节点都已连线)");
                }

                // AI hint (when --ai requested but no key configured)
                if let Some(hint) = data["ai_hint"].as_str() {
                    println!("\n💡 {}", hint);
                }
            }
        }
        ProjectAction::Status { project_id, json } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;

            // Fetch project name
            let proj_resp = client
                .get(format!("{base}/api/projects/{project_id}"))
                .send()
                .await?;
            let proj_body: ApiResponse<serde_json::Value> = proj_resp.json().await?;
            let project_name = proj_body
                .data
                .and_then(|v| v["name"].as_str().map(String::from))
                .unwrap_or_else(|| project_id.clone());

            // Fetch nodes + edges + analysis (for attack paths count)
            let nodes_resp = client
                .get(format!("{base}/api/projects/{project_id}/nodes"))
                .send()
                .await?;
            let nodes_body: ApiResponse<Vec<NodeResponse>> = nodes_resp.json().await?;
            let nodes = nodes_body.data.ok_or("no data in response")?;

            let edges_resp = client
                .get(format!("{base}/api/projects/{project_id}/edges"))
                .send()
                .await?;
            let edges_body: ApiResponse<Vec<EdgeResponse>> = edges_resp.json().await?;
            let edges = edges_body.data.ok_or("no data in response")?;

            let analysis_resp = client
                .get(format!("{base}/api/projects/{project_id}/analysis"))
                .send()
                .await?;
            let analysis_body: ApiResponse<serde_json::Value> = analysis_resp.json().await?;
            let paths_count = analysis_body
                .data
                .as_ref()
                .and_then(|d| d["attack_paths"].as_array())
                .map(|a| a.len())
                .unwrap_or(0);

            let total_nodes = nodes.len();
            let total_edges = edges.len();

            let last_activity = nodes
                .iter()
                .map(|n| n.created_at)
                .max()
                .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
                .unwrap_or_else(|| "N/A".to_string());

            // Helper: get verdict/evidence from properties
            let verdict = |n: &NodeResponse| -> Option<String> {
                n.properties
                    .get("verdict")
                    .and_then(|v| v.as_str().map(String::from))
            };
            let evidence = |n: &NodeResponse| -> Option<String> {
                n.properties
                    .get("evidence")
                    .and_then(|v| v.as_str().map(String::from))
            };

            // Build adjacency maps
            let mut node_map: HashMap<String, &NodeResponse> = HashMap::new();
            for n in &nodes {
                node_map.insert(n.id.to_string(), n);
            }

            let mut out_targets: HashMap<String, Vec<String>> = HashMap::new(); // source → [target_ids]
            let mut source_ids: HashSet<String> = HashSet::new();
            let mut target_ids: HashSet<String> = HashSet::new();
            let mut all_edge_ids: HashSet<String> = HashSet::new();
            for e in &edges {
                let s = e.source_node_id.to_string();
                let t = e.target_node_id.to_string();
                out_targets.entry(s.clone()).or_default().push(t.clone());
                source_ids.insert(s.clone());
                target_ids.insert(t.clone());
                all_edge_ids.insert(s);
                all_edge_ids.insert(t);
            }

            // ── Sections ──

            // 高价值目标: objective AND verdict != success
            let mut high_value: Vec<(&NodeResponse, String)> = Vec::new();
            for n in &nodes {
                if n.node_type == "objective" && verdict(n).as_deref() != Some("success") {
                    let v = verdict(n).unwrap_or_else(|| "unknown".to_string());
                    high_value.push((n, v));
                }
            }

            // 未利用漏洞: vulnerability|privilege_escalation, verdict != failed, no outgoing edge to a success node
            let mut unexploited: Vec<(&NodeResponse, String)> = Vec::new();
            for n in &nodes {
                let nt = &n.node_type;
                if (nt == "vulnerability" || nt == "privilege_escalation")
                    && verdict(n).as_deref() != Some("failed")
                {
                    let exploited = out_targets
                        .get(&n.id.to_string())
                        .map(|targets| {
                            targets.iter().any(|t| {
                                node_map
                                    .get(t)
                                    .map_or(false, |tn| verdict(tn).as_deref() == Some("success"))
                            })
                        })
                        .unwrap_or(false);
                    if !exploited {
                        let ev = evidence(n).unwrap_or_else(|| "unknown".to_string());
                        unexploited.push((n, ev));
                    }
                }
            }

            // 活跃方向: leaf (out_degree==0, in_degree>0), not vuln/priv_esc/objective, not failed
            let vuln_types: HashSet<&str> = ["vulnerability", "privilege_escalation"]
                .iter()
                .copied()
                .collect();
            let mut active: Vec<(&NodeResponse, String)> = Vec::new();
            for n in &nodes {
                let nid = n.id.to_string();
                let has_out = source_ids.contains(&nid);
                let has_in = target_ids.contains(&nid);
                if !has_out && has_in {
                    let nt = &n.node_type;
                    if nt != "objective"
                        && !vuln_types.contains(nt.as_str())
                        && verdict(n).as_deref() != Some("failed")
                    {
                        let v = verdict(n).unwrap_or_else(|| "unknown".to_string());
                        active.push((n, v));
                    }
                }
            }

            // 死路: verdict == failed
            let mut dead: Vec<&NodeResponse> = Vec::new();
            for n in &nodes {
                if verdict(n).as_deref() == Some("failed") {
                    dead.push(n);
                }
            }

            // 孤立节点: 0 edges
            let mut isolated: Vec<&NodeResponse> = Vec::new();
            for n in &nodes {
                if !all_edge_ids.contains(&n.id.to_string()) {
                    isolated.push(n);
                }
            }

            if json {
                let mut nodes_by_type: serde_json::Map<String, serde_json::Value> =
                    serde_json::Map::new();
                for n in &nodes {
                    *nodes_by_type
                        .entry(n.node_type.clone())
                        .or_insert(serde_json::json!(0)) = serde_json::json!(
                        nodes_by_type
                            .get(&n.node_type)
                            .unwrap()
                            .as_u64()
                            .unwrap_or(0)
                            + 1
                    );
                }

                let mut verdict_dist: serde_json::Map<String, serde_json::Value> =
                    serde_json::Map::new();
                for n in &nodes {
                    if let Some(v) = verdict(n) {
                        *verdict_dist
                            .entry(v.to_string())
                            .or_insert(serde_json::json!(0)) = serde_json::json!(
                            verdict_dist.get(&v).unwrap().as_u64().unwrap_or(0) + 1
                        );
                    }
                }

                let mut evidence_dist: serde_json::Map<String, serde_json::Value> =
                    serde_json::Map::new();
                for n in &nodes {
                    if let Some(ev) = evidence(n) {
                        *evidence_dist
                            .entry(ev.to_string())
                            .or_insert(serde_json::json!(0)) = serde_json::json!(
                            evidence_dist.get(&ev).unwrap().as_u64().unwrap_or(0) + 1
                        );
                    }
                }

                let output = serde_json::json!({
                    "project": project_name,
                    "total_nodes": total_nodes,
                    "total_edges": total_edges,
                    "attack_paths": paths_count,
                    "last_activity": nodes.iter().map(|n| n.created_at).max().map(|t| t.to_rfc3339()),
                    "nodes_by_type": nodes_by_type,
                    "verdict_distribution": verdict_dist,
                    "evidence_distribution": evidence_dist,
                    "sections": {
                        "high_value_targets": high_value.iter().map(|(n, v)| serde_json::json!({
                            "label": n.label, "verdict": v
                        })).collect::<Vec<_>>(),
                        "unexploited_vulns": unexploited.iter().map(|(n, ev)| serde_json::json!({
                            "label": n.label, "evidence": ev
                        })).collect::<Vec<_>>(),
                        "active_directions": active.iter().map(|(n, v)| serde_json::json!({
                            "label": n.label, "verdict": v
                        })).collect::<Vec<_>>(),
                        "dead_ends": dead.iter().map(|n| serde_json::json!({
                            "label": n.label
                        })).collect::<Vec<_>>(),
                        "isolated_nodes": isolated.iter().map(|n| serde_json::json!({
                            "label": n.label
                        })).collect::<Vec<_>>(),
                    },
                });
                println!("{}", serde_json::to_string_pretty(&output)?);
            } else {
                println!(
                    "╭─ Project: {} ──────────────────────────────────────",
                    sanitize_cell(&project_name, 45),
                );
                println!(
                    "│ Nodes: {}  Edges: {}  Paths: {}  Last: {}",
                    total_nodes, total_edges, paths_count, last_activity
                );

                if !high_value.is_empty() {
                    println!("│");
                    println!("│ ▎高价值目标 ({}):", high_value.len());
                    for (n, v) in &high_value {
                        println!("│   ◆ {:<40} {}", sanitize_cell(&n.label, 40), v);
                    }
                }

                if !unexploited.is_empty() {
                    println!("│");
                    println!("│ ▎未利用漏洞 ({}):", unexploited.len());
                    for (n, ev) in &unexploited {
                        println!("│   🚩 {:<40} {}", sanitize_cell(&n.label, 40), ev);
                    }
                }

                if !active.is_empty() {
                    println!("│");
                    println!("│ ▎活跃方向 ({}):", active.len());
                    for (n, v) in &active {
                        println!("│   ● {:<40} {}", sanitize_cell(&n.label, 40), v);
                    }
                }

                if !dead.is_empty() {
                    println!("│");
                    println!("│ ▎死路 ({}):", dead.len());
                    for n in &dead {
                        println!("│   ✗ {:<40}", sanitize_cell(&n.label, 40));
                    }
                }

                if !isolated.is_empty() {
                    println!("│");
                    println!("│ ▎孤立节点 ({}):", isolated.len());
                    for n in &isolated {
                        println!("│   ● {:<40}", sanitize_cell(&n.label, 40));
                    }
                }

                println!("╰──────────────────────────────────────────────────────");
            }
        }
        ProjectAction::Timeline { project_id, json } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resp = client
                .get(format!("{base}/api/projects/{project_id}/timeline"))
                .send()
                .await?;

            if json {
                print_result(resp).await?;
            } else {
                let body: ApiResponse<Vec<serde_json::Value>> = resp.json().await?;
                let items = body.data.ok_or("no data in response")?;

                if items.is_empty() {
                    println!("No timeline entries.");
                } else {
                    // Items are already sorted by created_at from backend
                    for item in &items {
                        let ts = item["created_at"]
                            .as_str()
                            .unwrap_or("?")
                            .chars()
                            .take(16)
                            .collect::<String>(); // "2024-01-15T14:30"
                        let short_id = item["node_id"]
                            .as_str()
                            .unwrap_or("?")
                            .chars()
                            .take(8)
                            .collect::<String>();
                        let ntype = sanitize_cell(item["node_type"].as_str().unwrap_or("-"), 16);
                        let label = sanitize_cell(item["node_label"].as_str().unwrap_or("?"), 40);

                        println!("{}  {}  [{ntype}]  {label}", ts, short_id);

                        if let Some(incoming) = item["incoming"].as_array() {
                            for edge in incoming {
                                let elabel =
                                    sanitize_cell(edge["label"].as_str().unwrap_or(""), 20);
                                let other = sanitize_cell(
                                    edge["other_node_label"].as_str().unwrap_or("?"),
                                    30,
                                );
                                println!("  ← {elabel} from {other}");
                            }
                        }
                        if let Some(outgoing) = item["outgoing"].as_array() {
                            for edge in outgoing {
                                let elabel =
                                    sanitize_cell(edge["label"].as_str().unwrap_or(""), 20);
                                let other = sanitize_cell(
                                    edge["other_node_label"].as_str().unwrap_or("?"),
                                    30,
                                );
                                println!("  → {elabel} to {other}");
                            }
                        }
                        println!();
                    }
                }
            }
        }
        ProjectAction::Stats { project_id, json } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;

            let nodes_resp = client
                .get(format!("{base}/api/projects/{project_id}/nodes"))
                .send()
                .await?;
            let nodes_body: ApiResponse<Vec<NodeResponse>> = nodes_resp.json().await?;
            let nodes = nodes_body.data.ok_or("no data in response")?;

            let edges_resp = client
                .get(format!("{base}/api/projects/{project_id}/edges"))
                .send()
                .await?;
            let edges_body: ApiResponse<Vec<EdgeResponse>> = edges_resp.json().await?;
            let edges = edges_body.data.ok_or("no data in response")?;

            let analysis_resp = client
                .get(format!("{base}/api/projects/{project_id}/analysis"))
                .send()
                .await?;
            let analysis_body: ApiResponse<serde_json::Value> = analysis_resp.json().await?;
            let paths_count = analysis_body
                .data
                .as_ref()
                .and_then(|d| d["attack_paths"].as_array())
                .map(|a| a.len())
                .unwrap_or(0);

            let total_nodes = nodes.len();
            let total_edges = edges.len();

            // Helper closures
            let verdict = |n: &NodeResponse| -> Option<String> {
                n.properties
                    .get("verdict")
                    .and_then(|v| v.as_str().map(String::from))
            };
            let evidence = |n: &NodeResponse| -> Option<String> {
                n.properties
                    .get("evidence")
                    .and_then(|v| v.as_str().map(String::from))
            };

            // Nodes by type with verdict sub-counts
            let mut nodes_by_type: HashMap<String, (usize, HashMap<String, usize>)> =
                HashMap::new();
            for n in &nodes {
                let entry = nodes_by_type.entry(n.node_type.clone()).or_default();
                entry.0 += 1;
                if let Some(v) = verdict(n) {
                    *entry.1.entry(v.to_string()).or_default() += 1;
                }
            }
            let mut type_sorted: Vec<_> = nodes_by_type.into_iter().collect();
            type_sorted.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));

            // Verdict distribution
            let mut verdict_counts: HashMap<String, usize> = HashMap::new();
            for n in &nodes {
                if let Some(v) = verdict(n) {
                    *verdict_counts.entry(v.to_string()).or_default() += 1;
                }
            }
            let mut verdict_sorted: Vec<_> = verdict_counts.into_iter().collect();
            verdict_sorted.sort_by(|a, b| b.1.cmp(&a.1));

            // Evidence distribution
            let mut evidence_counts: HashMap<String, usize> = HashMap::new();
            for n in &nodes {
                if let Some(ev) = evidence(n) {
                    *evidence_counts.entry(ev.to_string()).or_default() += 1;
                }
            }
            let mut evidence_sorted: Vec<_> = evidence_counts.into_iter().collect();
            evidence_sorted.sort_by(|a, b| b.1.cmp(&a.1));

            // Edge stats: dead ends, leaf nodes, isolated
            let mut source_ids: HashSet<String> = HashSet::new();
            let mut target_ids: HashSet<String> = HashSet::new();
            let mut all_edge_ids: HashSet<String> = HashSet::new();
            for e in &edges {
                let s = e.source_node_id.to_string();
                let t = e.target_node_id.to_string();
                source_ids.insert(s.clone());
                target_ids.insert(t.clone());
                all_edge_ids.insert(s);
                all_edge_ids.insert(t);
            }

            let leaves = nodes
                .iter()
                .filter(|n| {
                    let nid = n.id.to_string();
                    target_ids.contains(&nid)
                        && !source_ids.contains(&nid)
                        && verdict(n).as_deref() != Some("failed")
                })
                .count();

            let isolated_count = nodes
                .iter()
                .filter(|n| {
                    let nid = n.id.to_string();
                    !source_ids.contains(&nid) && !target_ids.contains(&nid)
                })
                .count();

            if json {
                let mut nt_json = serde_json::Map::new();
                for (t, (count, verdicts)) in &type_sorted {
                    let mut v_map = serde_json::Map::new();
                    for (v, c) in verdicts {
                        v_map.insert(v.clone(), serde_json::json!(c));
                    }
                    nt_json.insert(
                        t.clone(),
                        serde_json::json!({
                            "count": count,
                            "verdicts": v_map,
                        }),
                    );
                }
                let mut vd_json = serde_json::Map::new();
                for (v, c) in &verdict_sorted {
                    vd_json.insert(v.clone(), serde_json::json!(c));
                }
                let mut ed_json = serde_json::Map::new();
                for (e, c) in &evidence_sorted {
                    ed_json.insert(e.clone(), serde_json::json!(c));
                }
                let output = serde_json::json!({
                    "nodes_by_type": nt_json,
                    "verdict_distribution": vd_json,
                    "evidence_distribution": ed_json,
                    "totals": {
                        "nodes": total_nodes,
                        "edges": total_edges,
                        "attack_paths": paths_count,
                        "leaves": leaves,
                        "isolated": isolated_count,
                    },
                });
                println!("{}", serde_json::to_string_pretty(&output)?);
            } else {
                let bar = |part: usize, total: usize| -> String {
                    if total == 0 {
                        return "░".repeat(8);
                    }
                    let filled = (part * 8) / total;
                    format!("{}{}", "█".repeat(filled), "░".repeat(8 - filled))
                };

                println!("╭─ Project Stats ────────────────────────────────────────");
                println!("│");
                println!("│ Nodes by type:");
                for (t, (count, verdicts)) in &type_sorted {
                    if verdicts.is_empty() {
                        println!("│   {:<20} {}", format!("{}", t), count);
                    } else {
                        let v_parts: Vec<_> = verdicts
                            .iter()
                            .map(|(v, c)| format!("{}: {}", v, c))
                            .collect();
                        println!("│   {:<20} {}  ({})", t, count, v_parts.join(", "));
                    }
                }

                println!("│");
                println!("│ Verdict:");
                let verdict_max = verdict_sorted.iter().map(|(_, c)| *c).max().unwrap_or(1);
                for (v, c) in &verdict_sorted {
                    println!("│   {:<12} {} {:<8} {:<10}", v, bar(*c, verdict_max), c, "");
                }

                println!("│");
                println!("│ Evidence:");
                let evidence_max = evidence_sorted.iter().map(|(_, c)| *c).max().unwrap_or(1);
                for (ev, c) in &evidence_sorted {
                    println!("│   {:<12} {} {:<8}", ev, bar(*c, evidence_max), c);
                }

                println!("│");
                println!("│ Totals:");
                println!("│   Nodes: {}  Edges: {}", total_nodes, total_edges);
                println!("│   Attack paths: {}", paths_count);
                println!("│   Leaves: {}  Isolated: {}", leaves, isolated_count);
                println!("╰──────────────────────────────────────────────────────");
            }
        }
        ProjectAction::Report { project_id, output } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resp = client
                .get(format!("{base}/api/projects/{project_id}/report"))
                .send()
                .await?;
            let status = resp.status();
            let body: ApiResponse<serde_json::Value> = resp.json().await?;
            if !status.is_success() || !body.success {
                return Err(body
                    .error
                    .unwrap_or_else(|| format!("HTTP {status}"))
                    .into());
            }
            let data = body.data.ok_or("no data in response")?;
            let markdown = data["markdown"]
                .as_str()
                .ok_or("report response missing markdown")?;
            if let Some(path) = output {
                std::fs::write(&path, markdown)?;
                println!("saved {path}");
            } else {
                println!("{markdown}");
            }
        }
        ProjectAction::Check { project_id, json } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let url = format!("{base}/api/projects/{project_id}/check");
            let resp = client.get(&url).send().await?;
            if json {
                print_result(resp).await?;
            } else {
                let body: serde_json::Value = resp.json().await?;
                let data = &body["data"];
                let total_nodes = data["total_nodes"].as_u64().unwrap_or(0);
                let total_edges = data["total_edges"].as_u64().unwrap_or(0);
                println!("图谱健康检查: {} 节点, {} 边\n", total_nodes, total_edges);

                let issues = data["issues"].as_array().cloned().unwrap_or_default();

                // Group by rule
                let mut grouped: HashMap<&str, Vec<&serde_json::Value>> = HashMap::new();
                for issue in &issues {
                    let rule = issue["rule"].as_str().unwrap_or("unknown");
                    grouped.entry(rule).or_default().push(issue);
                }

                // Output in fixed order
                let rule_order = [
                    "missing_provenance",
                    "unverified_hypothesis",
                    "unused_vulnerability",
                    "dangling_exploit",
                ];
                let rule_icons: HashMap<&str, &str> = HashMap::from([
                    ("missing_provenance", "⚠️"),
                    ("unverified_hypothesis", "💡"),
                    ("unused_vulnerability", "💡"),
                    ("dangling_exploit", "✅"),
                ]);

                for rule in &rule_order {
                    let items = grouped.get(rule).map(|v| v.as_slice()).unwrap_or(&[]);
                    let icon = rule_icons.get(rule).unwrap_or(&"⚠️");
                    println!("{}  {} ({})", icon, rule, items.len());
                    for issue in items {
                        let label = issue["node_label"].as_str().unwrap_or("?");
                        let desc = issue["description"].as_str().unwrap_or("?");
                        println!("  • {} — {}", label, desc);
                    }
                    println!();
                }

                if issues.is_empty() {
                    println!("✅ 图谱质量良好 — 未发现确定性问题");
                }
            }
        }
    }
    Ok(())
}

// ── Node handlers ──

async fn handle_node(
    client: &reqwest::Client,
    base: &str,
    action: NodeAction,
) -> Result<(), BoxErr> {
    match action {
        NodeAction::Add {
            project_id,
            label,
            node_type,
            props,
            props_file,
            content,
            content_stdin,
            content_file,
            after,
            edge_label,
            verdict,
            evidence,
            json,
            check_duplicate,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let mut props_val = read_props(props, props_file)
                .await?
                .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));

            if let Some(ref v) = verdict {
                if let serde_json::Value::Object(ref mut map) = props_val {
                    map.insert("verdict".into(), serde_json::Value::String(v.clone()));
                }
            }
            if let Some(ref e) = evidence {
                if let serde_json::Value::Object(ref mut map) = props_val {
                    map.insert("evidence".into(), serde_json::Value::String(e.clone()));
                }
            }

            let (content_text, has_content) =
                read_content(content, content_stdin, content_file).await?;
            if has_content {
                if let (Some(ref c), serde_json::Value::Object(ref mut map)) =
                    (&content_text, &mut props_val)
                {
                    map.insert("content".into(), serde_json::Value::String(c.clone()));
                }
            }

            let body = CreateNodeRequest {
                label,
                node_type: parse_node_type(&resolve_type_abbrev(&node_type)),
                position: None,
                properties: props_val,
                after,
                edge_label,
            };
            let resp = client
                .post(format!("{base}/api/projects/{project_id}/nodes"))
                .json(&body)
                .send()
                .await?;
            if json {
                print_json(resp).await?;
            } else {
                print_created(resp).await?;
            }
            if check_duplicate {
                let content_for_check = content_text.unwrap_or_default();
                let dup_resp = client
                    .post(format!(
                        "{base}/api/projects/{project_id}/nodes/check-duplicate"
                    ))
                    .json(&serde_json::json!({
                        "label": &body.label,
                        "content": content_for_check,
                        "node_type": body.node_type.to_string()
                    }))
                    .send()
                    .await?;
                let status = dup_resp.status();
                let dup_body: ApiResponse<Vec<serde_json::Value>> = dup_resp.json().await?;
                if !status.is_success() || !dup_body.success {
                    eprintln!(
                        "warning: duplicate check failed: {}",
                        dup_body.error.unwrap_or_else(|| format!("HTTP {status}"))
                    );
                } else if let Some(candidates) = dup_body.data {
                    for candidate in candidates {
                        let id = candidate["node_id"]
                            .as_str()
                            .unwrap_or("?")
                            .chars()
                            .take(8)
                            .collect::<String>();
                        let label = candidate["label"].as_str().unwrap_or("?");
                        let similarity = candidate["similarity"].as_str().unwrap_or("medium");
                        let reason = candidate["reason"].as_str().unwrap_or("");
                        eprintln!(
                            "warning: possible duplicate [{similarity}] {id} {label}: {reason}"
                        );
                    }
                }
            }
        }
        NodeAction::List {
            project_id,
            r#type,
            verdict,
            evidence,
            leaves,
            json,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;

            // Build query params for server-side filtering
            let mut query_parts: Vec<String> = Vec::new();
            if let Some(ref t) = r#type {
                let resolved = resolve_type_abbrev(t);
                query_parts.push(format!("node_type={}", url_encode_query(&resolved)));
            }
            if let Some(ref v) = verdict {
                query_parts.push(format!("verdict={}", url_encode_query(v)));
            }
            if let Some(ref e) = evidence {
                query_parts.push(format!("evidence={}", url_encode_query(e)));
            }
            let query = if query_parts.is_empty() {
                String::new()
            } else {
                format!("?{}", query_parts.join("&"))
            };

            // Fetch edges to compute in/out degree
            let edges_resp = client
                .get(format!("{base}/api/projects/{project_id}/edges"))
                .send()
                .await?;
            let edges_body: ApiResponse<Vec<serde_json::Value>> = edges_resp.json().await?;
            let edges_data = edges_body.data.unwrap_or_default();
            let mut out_degree: HashMap<String, usize> = HashMap::new();
            let mut in_degree: HashMap<String, usize> = HashMap::new();
            for e in &edges_data {
                let src = e["source_node_id"].as_str().unwrap_or("");
                let tgt = e["target_node_id"].as_str().unwrap_or("");
                *out_degree.entry(src.to_string()).or_default() += 1;
                *in_degree.entry(tgt.to_string()).or_default() += 1;
            }

            if json {
                let resp = client
                    .get(format!("{base}/api/projects/{project_id}/nodes{query}"))
                    .send()
                    .await?;
                print_result(resp).await?;
            } else {
                let resp = client
                    .get(format!("{base}/api/projects/{project_id}/nodes{query}"))
                    .send()
                    .await?;
                let body: serde_json::Value = resp.json().await?;
                if let Some(nodes) = body["data"].as_array() {
                    let displayed: Box<dyn Iterator<Item = (usize, &serde_json::Value)>> = if leaves
                    {
                        let filtered: Vec<(usize, &serde_json::Value)> = nodes
                            .iter()
                            .enumerate()
                            .filter(|(_, n)| {
                                let nid = n["id"].as_str().unwrap_or("");
                                let out = out_degree.get(nid).copied().unwrap_or(0);
                                let inv = in_degree.get(nid).copied().unwrap_or(0);
                                let v = n["properties"]["verdict"].as_str().unwrap_or("");
                                out == 0 && inv > 0 && v != "failed"
                            })
                            .collect();
                        Box::new(filtered.into_iter())
                    } else {
                        Box::new(nodes.iter().enumerate())
                    };
                    for (i, n) in displayed {
                        let nid = n["id"].as_str().unwrap_or("?");
                        let short_id = &nid.chars().take(8).collect::<String>();
                        let truncated = sanitize_cell(n["label"].as_str().unwrap_or("?"), 30);
                        let node_type = sanitize_cell(n["node_type"].as_str().unwrap_or("-"), 20);
                        let verdict =
                            sanitize_cell(n["properties"]["verdict"].as_str().unwrap_or("-"), 12);
                        let evidence =
                            sanitize_cell(n["properties"]["evidence"].as_str().unwrap_or("-"), 15);
                        let out = out_degree.get(nid).copied().unwrap_or(0);
                        let inv = in_degree.get(nid).copied().unwrap_or(0);
                        println!(
                            "#{:<3} {:<10} {:32} {:20} verdict={:12}  evidence={:15}  →{:<2} ←{:<2}",
                            i + 1,
                            short_id,
                            truncated,
                            node_type,
                            verdict,
                            evidence,
                            out,
                            inv,
                        );
                    }
                } else {
                    println!("{}", serde_json::to_string_pretty(&body)?);
                }
            }
        }
        NodeAction::Get {
            project_id,
            node_id,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resolved = resolve_node_id(client, base, &project_id, &node_id).await?;
            let resp = client
                .get(format!("{base}/api/projects/{project_id}/nodes/{resolved}"))
                .send()
                .await?;
            print_result(resp).await?;
        }
        NodeAction::Update {
            project_id,
            node_id,
            label,
            node_type,
            props,
            content,
            content_stdin,
            content_file,
            append_content,
            set_verdict,
            set_evidence,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resolved_id = resolve_node_id(client, base, &project_id, &node_id).await?;

            // Build partial properties update (for raw JSON merge)
            let props_update: Option<serde_json::Value> = props
                .as_deref()
                .map(|s| serde_json::from_str(s))
                .transpose()?;

            let (content_text, has_content) =
                read_content(content, content_stdin, content_file).await?;

            let body = UpdateNodeRequest {
                label,
                node_type: node_type.map(|nt| parse_node_type(&resolve_type_abbrev(&nt))),
                position: None,
                properties: props_update,
                content: if has_content { content_text } else { None },
                append_content,
                set_verdict,
                set_evidence,
            };
            let resp = client
                .put(format!(
                    "{base}/api/projects/{project_id}/nodes/{resolved_id}"
                ))
                .json(&body)
                .send()
                .await?;
            _print_created(resp, Some("updated")).await?;
        }
        NodeAction::Delete {
            project_id,
            node_id,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resolved = resolve_node_id(client, base, &project_id, &node_id).await?;
            let resp = client
                .delete(format!("{base}/api/projects/{project_id}/nodes/{resolved}"))
                .send()
                .await?;
            _print_created(resp, None).await?;
        }
        NodeAction::Search { project_id, query } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            if query.trim().is_empty() {
                eprintln!("Error: search query cannot be empty");
                std::process::exit(1);
            }
            let resp = client
                .get(format!(
                    "{base}/api/projects/{project_id}/nodes/search?q={}",
                    url_encode_query(&query)
                ))
                .send()
                .await?;
            let body: serde_json::Value = resp.json().await?;
            if let Some(nodes) = body["data"].as_array() {
                if nodes.is_empty() {
                    println!("no nodes found matching '{}'", query);
                } else {
                    for n in nodes {
                        let id = n["id"].as_str().unwrap_or("?");
                        let label = sanitize_cell(n["label"].as_str().unwrap_or("?"), 40);
                        let verdict =
                            sanitize_cell(n["properties"]["verdict"].as_str().unwrap_or("-"), 12);
                        let evidence =
                            sanitize_cell(n["properties"]["evidence"].as_str().unwrap_or("-"), 15);
                        println!("{id}  {label}  verdict={verdict}  evidence={evidence}");
                    }
                    eprintln!("— {} node(s) found —", nodes.len());
                }
            } else if let Some(err) = body["error"].as_str() {
                eprintln!("Error: {err}");
                std::process::exit(1);
            } else {
                eprintln!("no nodes in project");
            }
        }
    }
    Ok(())
}

// ── Edge handlers ──

async fn handle_edge(
    client: &reqwest::Client,
    base: &str,
    action: EdgeAction,
) -> Result<(), BoxErr> {
    match action {
        EdgeAction::Add {
            project_id,
            from,
            to,
            label,
            from_label,
            to_label,
            json,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resolved_from = match (from, from_label) {
                (Some(id), _) => resolve_node_id(client, base, &project_id, &id).await?,
                (None, Some(lbl)) => resolve_node_by_label(client, base, &project_id, &lbl).await?,
                (None, None) => {
                    return Err(
                        "source node required: provide FROM positional arg or --from-label".into(),
                    )
                }
            };
            let resolved_to = match (to, to_label) {
                (Some(id), _) => resolve_node_id(client, base, &project_id, &id).await?,
                (None, Some(lbl)) => resolve_node_by_label(client, base, &project_id, &lbl).await?,
                (None, None) => {
                    return Err(
                        "target node required: provide TO positional arg or --to-label".into(),
                    )
                }
            };
            let body = CreateEdgeRequest {
                source_node_id: Some(uuid::Uuid::parse_str(&resolved_from)?),
                target_node_id: Some(uuid::Uuid::parse_str(&resolved_to)?),
                label,
                source_label: None,
                target_label: None,
            };
            let resp = client
                .post(format!("{base}/api/projects/{project_id}/edges"))
                .json(&body)
                .send()
                .await?;
            if json {
                print_json(resp).await?;
            } else {
                _print_created(resp, Some("created")).await?;
            }
        }
        EdgeAction::List { project_id, json } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let edges_resp = client
                .get(format!("{base}/api/projects/{project_id}/edges"))
                .send()
                .await?;
            if json {
                print_result(edges_resp).await?;
            } else {
                let edges_body: serde_json::Value = edges_resp.json().await?;
                // Also fetch nodes to resolve source/target labels
                let nodes_resp = client
                    .get(format!("{base}/api/projects/{project_id}/nodes"))
                    .send()
                    .await?;
                let nodes_body: serde_json::Value = nodes_resp.json().await?;
                // Build id→label map
                let mut node_labels: std::collections::HashMap<String, String> =
                    std::collections::HashMap::new();
                if let Some(nodes) = nodes_body["data"].as_array() {
                    for n in nodes {
                        if let (Some(id), Some(label)) = (n["id"].as_str(), n["label"].as_str()) {
                            node_labels.insert(id.to_string(), label.to_string());
                        }
                    }
                }
                if let Some(edges) = edges_body["data"].as_array() {
                    for (i, e) in edges.iter().enumerate() {
                        let short_id = e["id"]
                            .as_str()
                            .unwrap_or("?")
                            .chars()
                            .take(8)
                            .collect::<String>();
                        let source_id = e["source_node_id"].as_str().unwrap_or("?");
                        let target_id = e["target_node_id"].as_str().unwrap_or("?");
                        let from_label = node_labels
                            .get(source_id)
                            .map(|l| sanitize_cell(l, 20))
                            .unwrap_or_else(|| source_id.chars().take(8).collect());
                        let to_label = node_labels
                            .get(target_id)
                            .map(|l| sanitize_cell(l, 20))
                            .unwrap_or_else(|| target_id.chars().take(8).collect());
                        let edge_label = sanitize_cell(e["label"].as_str().unwrap_or("-"), 20);
                        println!(
                            "#{:<3} {:<10} {:>20} ──{}── {:20}",
                            i + 1,
                            short_id,
                            from_label,
                            edge_label,
                            to_label,
                        );
                    }
                } else {
                    println!("{}", serde_json::to_string_pretty(&edges_body)?);
                }
            }
        }
        EdgeAction::Update {
            project_id,
            edge_id,
            label,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let body = EdgeUpdateRequest { label: Some(label) };
            let resp = client
                .put(format!("{base}/api/projects/{project_id}/edges/{edge_id}"))
                .json(&body)
                .send()
                .await?;
            _print_created(resp, Some("updated")).await?;
        }
        EdgeAction::Delete {
            project_id,
            edge_id,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resp = client
                .delete(format!("{base}/api/projects/{project_id}/edges/{edge_id}"))
                .send()
                .await?;
            _print_created(resp, None).await?;
        }
    }
    Ok(())
}

// ── Credential handlers ──

async fn handle_credential(
    client: &reqwest::Client,
    base: &str,
    action: CredentialAction,
) -> Result<(), BoxErr> {
    match action {
        CredentialAction::Add {
            project_id,
            label,
            credential_type,
            secret,
            username,
            source_node,
            status,
            notes,
            json,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let source_node_id = match source_node {
                Some(id) => Some(uuid::Uuid::parse_str(
                    &resolve_node_id(client, base, &project_id, &id).await?,
                )?),
                None => None,
            };
            let body = CreateCredentialRequest {
                label,
                credential_type: parse_credential_type(&credential_type)?,
                username,
                secret,
                source_node_id,
                status: parse_credential_status(&status)?,
                notes,
            };
            let resp = client
                .post(format!("{base}/api/projects/{project_id}/credentials"))
                .json(&body)
                .send()
                .await?;
            if json {
                print_json(resp).await?;
            } else {
                print_created(resp).await?;
            }
        }
        CredentialAction::List {
            project_id,
            show_secrets,
            json,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let resp = client
                .get(format!("{base}/api/projects/{project_id}/credentials"))
                .send()
                .await?;
            if json {
                print_result(resp).await?;
            } else {
                let body: ApiResponse<Vec<Credential>> = resp.json().await?;
                let credentials = body.data.ok_or("no data in response")?;
                for (i, credential) in credentials.iter().enumerate() {
                    let short_id: String = credential.id.to_string().chars().take(8).collect();
                    let label = sanitize_cell(&credential.label, 30);
                    let credential_type = sanitize_cell(&credential.credential_type.to_string(), 12);
                    let username = sanitize_cell(credential.username.as_deref().unwrap_or("-"), 20);
                    let secret = if show_secrets {
                        sanitize_cell(&credential.secret, 24)
                    } else {
                        "********".to_string()
                    };
                    let source = credential
                        .source_node_id
                        .map(|id| id.to_string().chars().take(8).collect::<String>())
                        .unwrap_or_else(|| "-".to_string());
                    let status = sanitize_cell(&credential.status.to_string(), 12);
                    println!(
                        "#{:<3} {:<10} {:30} {:12} user={:20} secret={:24} source={:8} status={:12}",
                        i + 1,
                        short_id,
                        label,
                        credential_type,
                        username,
                        secret,
                        source,
                        status,
                    );
                }
            }
        }
        CredentialAction::Update {
            project_id,
            credential_id,
            label,
            credential_type,
            secret,
            username,
            source_node,
            clear_source_node,
            status,
            notes,
            clear_notes,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let credential_id = resolve_credential_id(client, base, &project_id, &credential_id).await?;
            let source_node_id = if clear_source_node {
                Some(None)
            } else {
                match source_node {
                    Some(id) => Some(Some(uuid::Uuid::parse_str(
                        &resolve_node_id(client, base, &project_id, &id).await?,
                    )?)),
                    None => None,
                }
            };
            let body = UpdateCredentialRequest {
                label,
                credential_type: credential_type
                    .as_deref()
                    .map(parse_credential_type)
                    .transpose()?,
                username,
                secret,
                source_node_id,
                status: status.as_deref().map(parse_credential_status).transpose()?,
                notes: if clear_notes { Some(None) } else { notes.map(Some) },
            };
            let resp = client
                .put(format!(
                    "{base}/api/projects/{project_id}/credentials/{credential_id}"
                ))
                .json(&body)
                .send()
                .await?;
            _print_created(resp, Some("updated")).await?;
        }
        CredentialAction::Delete {
            project_id,
            credential_id,
        } => {
            let project_id = resolve_project_id(client, base, &project_id).await?;
            let credential_id = resolve_credential_id(client, base, &project_id, &credential_id).await?;
            let resp = client
                .delete(format!(
                    "{base}/api/projects/{project_id}/credentials/{credential_id}"
                ))
                .send()
                .await?;
            _print_created(resp, None).await?;
        }
    }
    Ok(())
}

/// Minimal URL-encode for query strings (encode spaces and special chars)
fn url_encode_query(s: &str) -> String {
    s.chars()
        .map(|c| match c {
            ' ' => "%20".to_string(),
            '#' => "%23".to_string(),
            '&' => "%26".to_string(),
            '?' => "%3F".to_string(),
            '/' => "%2F".to_string(),
            '%' => "%25".to_string(),
            '+' => "%2B".to_string(),
            '=' => "%3D".to_string(),
            c => c.to_string(),
        })
        .collect()
}

/// Sanitize a string for fixed-width table cell output:
/// - Replace newlines/tabs with spaces
/// - Truncate to max_len chars, appending "…"
fn sanitize_cell(s: &str, max_len: usize) -> String {
    let clean: String = s
        .chars()
        .map(|c| {
            if c == '\n' || c == '\r' || c == '\t' {
                ' '
            } else {
                c
            }
        })
        .collect();
    let clean = clean.trim();
    if clean.chars().count() > max_len {
        let truncated: String = clean.chars().take(max_len - 1).collect();
        format!("{truncated}…")
    } else {
        clean.to_string()
    }
}

async fn print_json(resp: reqwest::Response) -> Result<(), BoxErr> {
    let status = resp.status();
    let text = resp.text().await?;
    if text.trim().is_empty() {
        return Err(format!("HTTP {status} — server returned empty response (no body)").into());
    }
    match serde_json::from_str::<serde_json::Value>(&text) {
        Ok(body) => {
            println!("{}", serde_json::to_string_pretty(&body)?);
            if status.is_client_error() || status.is_server_error() {
                return Err(format!("HTTP {status}").into());
            }
        }
        Err(_) => {
            let preview: String = text.chars().take(200).collect();
            return Err(
                format!("HTTP {status} — server returned non-JSON response: {preview}").into(),
            );
        }
    }
    Ok(())
}

async fn print_tell_result(
    client: &reqwest::Client,
    base: &str,
    project_id: &str,
    resp: reqwest::Response,
) -> Result<(), BoxErr> {
    let status = resp.status();
    let body: ApiResponse<serde_json::Value> = resp.json().await?;
    if !status.is_success() || !body.success {
        return Err(body
            .error
            .unwrap_or_else(|| format!("HTTP {status}"))
            .into());
    }
    let data = body.data.ok_or("no data in response")?;
    let summary = data["summary"].as_str().unwrap_or("Done");
    println!("{summary}");

    if let Some(nodes) = data["nodes_created"].as_array() {
        if !nodes.is_empty() {
            println!();
            println!("Nodes created:");
            for node in nodes {
                let short_id = short_json_id(node.get("id"));
                let label = node["label"].as_str().unwrap_or("?");
                let node_type = node["node_type"].as_str().unwrap_or("?");
                println!("  {short_id}  {label} ({node_type})");
            }
        }
    }

    if let Some(edges) = data["edges_created"].as_array() {
        if !edges.is_empty() {
            let labels = fetch_node_labels(client, base, project_id).await?;
            println!();
            println!("Edges created:");
            for edge in edges {
                let source = edge["source_label"]
                    .as_str()
                    .map(str::to_string)
                    .or_else(|| {
                        edge["source_node_id"]
                            .as_str()
                            .and_then(|id| labels.get(id).cloned())
                    })
                    .unwrap_or_else(|| short_json_id(edge.get("source_node_id")));
                let target = edge["target_label"]
                    .as_str()
                    .map(str::to_string)
                    .or_else(|| {
                        edge["target_node_id"]
                            .as_str()
                            .and_then(|id| labels.get(id).cloned())
                    })
                    .unwrap_or_else(|| short_json_id(edge.get("target_node_id")));
                let label = edge["label"].as_str().unwrap_or("");
                if label.is_empty() {
                    println!("  {source} → {target}");
                } else {
                    println!("  {source} → {target}  [{label}]");
                }
            }
        }
    }
    Ok(())
}

async fn print_ask_result(resp: reqwest::Response) -> Result<(), BoxErr> {
    let status = resp.status();
    let body: ApiResponse<serde_json::Value> = resp.json().await?;
    if !status.is_success() || !body.success {
        return Err(body
            .error
            .unwrap_or_else(|| format!("HTTP {status}"))
            .into());
    }
    let data = body.data.ok_or("no data in response")?;
    println!("{}", data["answer"].as_str().unwrap_or(""));
    if let Some(ids) = data["relevant_node_ids"].as_array() {
        let short_ids: Vec<String> = ids
            .iter()
            .filter_map(|id| id.as_str())
            .map(|id| id.chars().take(8).collect())
            .collect();
        if !short_ids.is_empty() {
            println!();
            println!("Relevant nodes: {}", short_ids.join(", "));
        }
    }
    Ok(())
}

async fn fetch_node_labels(
    client: &reqwest::Client,
    base: &str,
    project_id: &str,
) -> Result<HashMap<String, String>, BoxErr> {
    let resp = client
        .get(format!("{base}/api/projects/{project_id}/nodes"))
        .send()
        .await?;
    let body: serde_json::Value = resp.json().await?;
    let mut labels = HashMap::new();
    if let Some(nodes) = body["data"].as_array() {
        for node in nodes {
            if let (Some(id), Some(label)) = (node["id"].as_str(), node["label"].as_str()) {
                labels.insert(id.to_string(), label.to_string());
            }
        }
    }
    Ok(labels)
}

fn short_json_id(value: Option<&serde_json::Value>) -> String {
    value
        .and_then(|v| v.as_str())
        .unwrap_or("?")
        .chars()
        .take(8)
        .collect()
}

async fn _print_created(resp: reqwest::Response, verb: Option<&str>) -> Result<(), BoxErr> {
    let status = resp.status();
    let text = resp.text().await?;
    if text.trim().is_empty() {
        return Err(format!("HTTP {status}").into());
    }
    let body: serde_json::Value = match serde_json::from_str(&text) {
        Ok(v) => v,
        Err(_) => {
            let preview: String = text.chars().take(200).collect();
            return Err(format!("HTTP {status} — non-JSON response: {preview}").into());
        }
    };
    if let Some(true) = body["success"].as_bool() {
        if let Some(data) = body.get("data") {
            if !data.is_null() {
                let short_id: String = data["id"].as_str().unwrap_or("?").chars().take(8).collect();
                if let Some(v) = verb {
                    println!("{short_id}  {v}");
                } else {
                    let label = data["label"].as_str().or_else(|| data["name"].as_str());
                    match label {
                        Some(l) => println!("{short_id}  {l}"),
                        None => println!("{short_id}  created"),
                    }
                }
            } else {
                println!("deleted");
            }
        } else {
            println!("deleted");
        }
    } else {
        let err = body["error"].as_str().unwrap_or("unknown error");
        return Err(err.to_string().into());
    }
    Ok(())
}

async fn print_created(resp: reqwest::Response) -> Result<(), BoxErr> {
    _print_created(resp, None).await
}

async fn print_result(resp: reqwest::Response) -> Result<(), BoxErr> {
    let status = resp.status();
    let text = resp.text().await?;
    if text.trim().is_empty() {
        return Err(format!("HTTP {status}").into());
    }
    match serde_json::from_str::<serde_json::Value>(&text) {
        Ok(body) => {
            if status.is_success() {
                println!("{}", serde_json::to_string_pretty(&body)?);
            } else {
                let err = body["error"].as_str().unwrap_or("unknown error");
                return Err(format!("Error: {err}").into());
            }
        }
        Err(_) => {
            let preview: String = text.chars().take(200).collect();
            return Err(format!("HTTP {status} — non-JSON response: {preview}").into());
        }
    }
    Ok(())
}
