//! fipsctl — FIPS control client
//!
//! Connects to the FIPS daemon's control socket, sends commands, and
//! pretty-prints the JSON response.
//!
//! On Unix, uses a Unix domain socket for local IPC.
//! On Windows, uses a TCP connection to localhost.

use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file};
use fips::upper::hosts::HostMap;
use fips::version;
use fips::{Identity, encode_nsec};
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::path::{Path, PathBuf};
use std::time::Duration;

/// FIPS control client
#[derive(Parser, Debug)]
#[command(
    name = "fipsctl",
    version = version::short_version(),
    long_version = version::long_version(),
    about = "Control a running FIPS daemon"
)]
struct Cli {
    /// Control socket path override
    #[arg(short = 's', long)]
    socket: Option<PathBuf>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Show node information
    Show {
        #[command(subcommand)]
        what: ShowCommands,
    },
    /// Show peer ACL information
    Acl {
        #[command(subcommand)]
        what: AclCommands,
    },
    /// Generate a new FIPS identity keypair
    Keygen {
        /// Output directory for fips.key and fips.pub
        #[arg(short = 'd', long = "dir", default_value_os_t = default_key_dir())]
        dir: PathBuf,
        /// Overwrite existing key files
        #[arg(short = 'f', long = "force")]
        force: bool,
        /// Print nsec and npub to stdout instead of writing files
        #[arg(short = 's', long = "stdout")]
        stdout: bool,
    },
    /// Connect to a peer
    Connect {
        /// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
        peer: String,
        /// Transport address (e.g., "192.168.1.1:2121")
        address: String,
        /// Transport type: udp, tcp, tor, ethernet
        transport: String,
    },
    /// Disconnect a peer
    Disconnect {
        /// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
        peer: String,
    },
    /// Query historical node statistics
    Stats {
        #[command(subcommand)]
        what: StatsCommands,
    },
}

#[derive(Subcommand, Debug)]
enum StatsCommands {
    /// List available history metrics
    List,
    /// Dump current counter values for every protocol metric family
    Metrics,
    /// List peers tracked in the stats history
    Peers,
    /// Fetch a time-series window for a metric
    History {
        /// Metric name (see `fipsctl stats list`). Node-level metrics
        /// need no `--peer`; per-peer metrics require it.
        metric: String,
        /// Peer npub (bech32) or hostname from /etc/fips/hosts for
        /// per-peer metrics
        #[arg(long)]
        peer: Option<String>,
        /// Window duration — `<N>s`, `<N>m`, `<N>h`
        #[arg(long, default_value = "10m")]
        window: String,
        /// Sample resolution — `1s` (fast ring) or `1m` (slow ring)
        #[arg(long, default_value = "1s")]
        granularity: String,
        /// Render a Unicode block sparkline instead of JSON
        #[arg(long)]
        plot: bool,
    },
}

#[derive(Subcommand, Debug)]
enum ShowCommands {
    /// Node status overview
    Status,
    /// Authenticated peers
    Peers,
    /// Active links
    Links,
    /// Spanning tree state
    Tree,
    /// End-to-end sessions
    Sessions,
    /// Bloom filter state
    Bloom,
    /// MMP metrics summary
    Mmp,
    /// Coordinate cache stats
    Cache,
    /// Pending handshake connections
    Connections,
    /// Transport instances
    Transports,
    /// Routing table summary
    Routing,
    /// Identity cache entries (known node pubkeys)
    IdentityCache,
}

#[derive(Subcommand, Debug)]
enum AclCommands {
    /// Loaded peer ACL state
    Show,
}

impl ShowCommands {
    fn command_name(&self) -> &'static str {
        match self {
            ShowCommands::Status => "show_status",
            ShowCommands::Peers => "show_peers",
            ShowCommands::Links => "show_links",
            ShowCommands::Tree => "show_tree",
            ShowCommands::Sessions => "show_sessions",
            ShowCommands::Bloom => "show_bloom",
            ShowCommands::Mmp => "show_mmp",
            ShowCommands::Cache => "show_cache",
            ShowCommands::Connections => "show_connections",
            ShowCommands::Transports => "show_transports",
            ShowCommands::Routing => "show_routing",
            ShowCommands::IdentityCache => "show_identity_cache",
        }
    }
}

impl AclCommands {
    fn command_name(&self) -> &'static str {
        match self {
            AclCommands::Show => "show_acl",
        }
    }
}

fn default_socket_path() -> PathBuf {
    fips::config::default_control_path()
}

/// Send a JSON request to the control socket and return the response.
///
/// On Unix, connects via Unix domain socket.
/// On Windows, connects via TCP to localhost.
#[cfg(unix)]
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
    use std::os::unix::net::UnixStream;

    let mut stream = UnixStream::connect(socket_path).map_err(|e| {
        if e.kind() == std::io::ErrorKind::PermissionDenied {
            format!(
                "cannot connect to {}: {}\n\
                 Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER\n\
                 Then log out and back in for the change to take effect.",
                socket_path.display(),
                e
            )
        } else {
            format!(
                "cannot connect to {}: {}\nIs the FIPS daemon running?",
                socket_path.display(),
                e
            )
        }
    })?;

    let timeout = Duration::from_secs(5);
    let _ = stream.set_read_timeout(Some(timeout));
    let _ = stream.set_write_timeout(Some(timeout));

    stream
        .write_all(request_json.as_bytes())
        .map_err(|e| format!("failed to send request: {e}"))?;
    let _ = stream.shutdown(std::net::Shutdown::Write);

    let reader = BufReader::new(&stream);
    let line = reader
        .lines()
        .next()
        .ok_or("no response from daemon")?
        .map_err(|e| format!("failed to read response: {e}"))?;

    serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
}

#[cfg(windows)]
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
    use std::net::TcpStream;

    let port_str = socket_path.to_string_lossy();
    let port: u16 = match port_str.parse() {
        Ok(p) => p,
        Err(_) => {
            eprintln!("warning: invalid port '{}', using default 21210", port_str);
            21210
        }
    };
    let addr = format!("127.0.0.1:{port}");

    let mut stream = TcpStream::connect(&addr).map_err(|e| {
        format!(
            "cannot connect to {}: {}\nIs the FIPS daemon running?",
            addr, e
        )
    })?;

    let timeout = Duration::from_secs(5);
    let _ = stream.set_read_timeout(Some(timeout));
    let _ = stream.set_write_timeout(Some(timeout));

    stream
        .write_all(request_json.as_bytes())
        .map_err(|e| format!("failed to send request: {e}"))?;
    let _ = stream.shutdown(std::net::Shutdown::Write);

    let reader = BufReader::new(&stream);
    let line = reader
        .lines()
        .next()
        .ok_or("no response from daemon")?
        .map_err(|e| format!("failed to read response: {e}"))?;

    serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
}

/// Build a request JSON string for a simple command (no params).
fn build_query(command: &str) -> String {
    format!("{{\"command\":\"{command}\"}}\n")
}

/// Build a request JSON string for a command with params.
fn build_command(command: &str, params: serde_json::Value) -> String {
    let req = serde_json::json!({"command": command, "params": params});
    format!("{}\n", serde_json::to_string(&req).unwrap())
}

/// Print a control socket response, handling error status.
fn print_response(value: &serde_json::Value) {
    let status = value
        .get("status")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");

    if status == "error" {
        let msg = value
            .get("message")
            .and_then(|v| v.as_str())
            .unwrap_or("unknown error");
        eprintln!("error: {msg}");
        std::process::exit(1);
    }

    let output = if let Some(data) = value.get("data") {
        serde_json::to_string_pretty(data)
    } else {
        serde_json::to_string_pretty(value)
    };
    println!("{}", output.unwrap_or_else(|_| format!("{value}")));
}

/// Default directory for keygen output.
///
/// Must match the platform's config dir, since the daemon derives key
/// paths from the config file's location.
fn default_key_dir() -> PathBuf {
    #[cfg(unix)]
    {
        PathBuf::from(fips::config::SYSTEM_CONFIG_DIR)
    }
    #[cfg(windows)]
    {
        dirs::config_dir()
            .map(|d| d.join("fips"))
            .unwrap_or_else(|| PathBuf::from("C:\\ProgramData\\fips"))
    }
}

/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
///
/// Handles three common syntaxes:
///   - bare IPv6:          `fd9d:...`
///   - bracketed + port:   `[fd9d:...]:2121`
///   - bare IPv6 + port:   `fd9d:...:2121` (ambiguous; accepted if tail is numeric)
fn is_fips_mesh_address(address: &str) -> bool {
    let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd;

    if let Ok(a) = address.parse::<Ipv6Addr>() {
        return is_ula(&a);
    }
    if let Ok(sa) = address.parse::<SocketAddrV6>() {
        return is_ula(sa.ip());
    }
    if let Some((host, port)) = address.rsplit_once(':')
        && port.chars().all(|c| c.is_ascii_digit())
        && !port.is_empty()
    {
        let host = host.trim_start_matches('[').trim_end_matches(']');
        if let Ok(a) = host.parse::<Ipv6Addr>() {
            return is_ula(&a);
        }
    }
    false
}

/// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint.
///
/// FIPS mesh ULAs are derived from npubs and only make sense as destinations
/// inside an already-established mesh — they are not valid udp/tcp/ethernet
/// transport endpoints. Without this check the CLI echoes success while the
/// daemon rejects the bind with EAFNOSUPPORT (issue #61).
fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> {
    let checked = matches!(transport, "udp" | "tcp" | "ethernet");
    if checked && is_fips_mesh_address(address) {
        return Err(format!(
            "'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\
             Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')."
        ));
    }
    Ok(())
}

/// Resolve a peer identifier to an npub.
///
/// If the identifier starts with "npub1", it's returned as-is.
/// Otherwise, it's looked up as a hostname in the hosts file.
fn resolve_peer(peer: &str) -> String {
    if peer.starts_with("npub1") {
        return peer.to_string();
    }

    let hosts = HostMap::load_hosts_file(Path::new(fips::upper::hosts::DEFAULT_HOSTS_PATH));
    match hosts.lookup_npub(peer) {
        Some(npub) => npub.to_string(),
        None => {
            eprintln!("error: unknown host '{peer}'");
            eprintln!(
                "Not found in {} and not an npub.",
                fips::upper::hosts::DEFAULT_HOSTS_PATH
            );
            std::process::exit(1);
        }
    }
}

fn main() {
    let cli = Cli::parse();

    // Commands that don't require a running daemon
    if let Commands::Keygen { dir, force, stdout } = &cli.command {
        let identity = Identity::generate();
        let nsec = encode_nsec(&identity.keypair().secret_key());
        let npub = identity.npub();

        if *stdout {
            println!("{nsec}");
            println!("{npub}");
            return;
        }

        let key_path = dir.join("fips.key");
        let pub_path = dir.join("fips.pub");

        if key_path.exists() && !force {
            eprintln!("error: key file already exists: {}", key_path.display());
            eprintln!("Use --force to overwrite.");
            std::process::exit(1);
        }

        if let Err(e) = std::fs::create_dir_all(dir) {
            eprintln!("error: cannot create directory {}: {e}", dir.display());
            std::process::exit(1);
        }

        if let Err(e) = write_key_file(&key_path, &nsec) {
            eprintln!("error: failed to write key file: {e}");
            std::process::exit(1);
        }

        if let Err(e) = write_pub_file(&pub_path, &npub) {
            eprintln!("error: failed to write pub file: {e}");
            std::process::exit(1);
        }

        eprintln!("{npub}");
        eprintln!("Key files written to: {}/", dir.display());
        eprintln!();
        eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
        eprintln!("      or these keys will be overwritten on next daemon start.");
        return;
    }

    let socket_path = cli.socket.unwrap_or_else(default_socket_path);

    let request = match &cli.command {
        Commands::Show { what } => build_query(what.command_name()),
        Commands::Acl { what } => build_query(what.command_name()),
        Commands::Connect {
            peer,
            address,
            transport,
        } => {
            if let Err(e) = validate_connect_address(address, transport) {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
            let npub = resolve_peer(peer);
            build_command(
                "connect",
                serde_json::json!({
                    "npub": npub,
                    "address": address,
                    "transport": transport,
                }),
            )
        }
        Commands::Disconnect { peer } => {
            let npub = resolve_peer(peer);
            build_command("disconnect", serde_json::json!({"npub": npub}))
        }
        Commands::Stats { what } => match what {
            StatsCommands::List => build_query("show_stats_list"),
            StatsCommands::Metrics => build_query("show_metrics"),
            StatsCommands::Peers => build_query("show_stats_peers"),
            StatsCommands::History {
                metric,
                peer,
                window,
                granularity,
                ..
            } => {
                let mut params = serde_json::json!({
                    "metric": metric,
                    "window": window,
                    "granularity": granularity,
                });
                if let Some(p) = peer {
                    let resolved = resolve_peer(p);
                    params["peer"] = serde_json::json!(resolved);
                }
                build_command("show_stats_history", params)
            }
        },
        Commands::Keygen { .. } => unreachable!(),
    };

    // For plot output we need to post-process the JSON response rather
    // than pretty-print it.
    if let Commands::Stats {
        what: StatsCommands::History {
            plot: true, metric, ..
        },
    } = &cli.command
    {
        match send_request(&socket_path, &request) {
            Ok(value) => print_plot(&value, metric),
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }
        return;
    }

    match send_request(&socket_path, &request) {
        Ok(value) => print_response(&value),
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    }
}

/// Render the response as a Unicode block sparkline plot.
fn print_plot(value: &serde_json::Value, metric: &str) {
    let status = value
        .get("status")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    if status == "error" {
        let msg = value
            .get("message")
            .and_then(|v| v.as_str())
            .unwrap_or("unknown error");
        eprintln!("error: {msg}");
        std::process::exit(1);
    }

    let data = match value.get("data") {
        Some(d) => d,
        None => {
            eprintln!("error: no data in response");
            std::process::exit(1);
        }
    };

    let values: Vec<f64> = data
        .get("values")
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect())
        .unwrap_or_default();
    let unit = data.get("unit").and_then(|v| v.as_str()).unwrap_or("");
    let granularity_seconds = data
        .get("granularity_seconds")
        .and_then(|v| v.as_u64())
        .unwrap_or(1);

    if values.is_empty() {
        println!("{metric}: no data yet");
        return;
    }

    let (min, max) = values
        .iter()
        .filter(|v| !v.is_nan())
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
            (lo.min(v), hi.max(v))
        });
    let (min, max) = if min.is_finite() {
        (min, max)
    } else {
        (0.0, 0.0)
    };
    let last = values
        .iter()
        .rev()
        .find(|v| !v.is_nan())
        .copied()
        .unwrap_or(f64::NAN);
    let width_secs = (values.len() as u64) * granularity_seconds;
    let gap_count = values.iter().filter(|v| v.is_nan()).count();

    println!(
        "{metric} ({unit}) — {n} samples @ {g}s = {w}s window{gap}",
        n = values.len(),
        g = granularity_seconds,
        w = width_secs,
        gap = if gap_count > 0 {
            format!(" ({gap_count} gaps)")
        } else {
            String::new()
        },
    );
    let last_str = if last.is_nan() {
        "-".to_string()
    } else {
        format!("{last:.3}")
    };
    println!("  min={min:.3} max={max:.3} last={last_str}");
    println!("  {}", sparkline(&values, min, max));
}

/// Render a slice of values as Unicode block characters.
///
/// Uses eight discrete levels: `▁▂▃▄▅▆▇█`. Constant series and empty
/// inputs render as a single-level line (`▄`).
fn sparkline(values: &[f64], min: f64, max: f64) -> String {
    const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
    let range = max - min;
    values
        .iter()
        .map(|&v| {
            if v.is_nan() {
                ' '
            } else if !range.is_finite() || range <= 0.0 {
                BLOCKS[3]
            } else {
                let norm = ((v - min) / range).clamp(0.0, 1.0);
                let idx = (norm * (BLOCKS.len() as f64 - 1.0)).round() as usize;
                BLOCKS[idx.min(BLOCKS.len() - 1)]
            }
        })
        .collect()
}

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

    // macOS packaging ships config under /usr/local/etc/fips/.
    #[cfg(target_os = "macos")]
    #[test]
    fn test_default_key_dir_follows_macos_packaging_layout() {
        assert_eq!(default_key_dir(), PathBuf::from("/usr/local/etc/fips"));
    }

    // Non-macOS Unix keeps the historic /etc/fips/ location.
    #[cfg(all(unix, not(target_os = "macos")))]
    #[test]
    fn test_default_key_dir_keeps_etc_fips_layout() {
        assert_eq!(default_key_dir(), PathBuf::from("/etc/fips"));
    }

    #[test]
    fn test_acl_show_command_name() {
        assert_eq!(AclCommands::Show.command_name(), "show_acl");
    }

    #[test]
    fn test_cli_parses_acl_show() {
        let cli = Cli::try_parse_from(["fipsctl", "acl", "show"]).unwrap();

        assert!(matches!(
            cli.command,
            Commands::Acl {
                what: AclCommands::Show
            }
        ));
    }

    #[test]
    fn detects_bare_ula_literal() {
        assert!(is_fips_mesh_address("fd9d:abcd::1"));
        assert!(is_fips_mesh_address("fd00::"));
        assert!(is_fips_mesh_address(
            "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
        ));
    }

    #[test]
    fn detects_bracketed_ula_with_port() {
        assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121"));
        assert!(is_fips_mesh_address("[fd00::1]:8443"));
    }

    #[test]
    fn detects_bare_ula_with_port() {
        assert!(is_fips_mesh_address("fd9d:abcd::1:2121"));
    }

    #[test]
    fn rejects_non_ula_ipv6() {
        // fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only
        // block the fd-prefixed half that FIPS actually uses.
        assert!(!is_fips_mesh_address("fc00::1"));
        assert!(!is_fips_mesh_address("::1"));
        assert!(!is_fips_mesh_address("2001:db8::1"));
        assert!(!is_fips_mesh_address("[2001:db8::1]:2121"));
    }

    #[test]
    fn ignores_ipv4_and_hostnames() {
        assert!(!is_fips_mesh_address("192.0.2.1:2121"));
        assert!(!is_fips_mesh_address("peer.example.com:2121"));
        assert!(!is_fips_mesh_address("coinos.pro:2121"));
    }

    #[test]
    fn validates_only_target_transports() {
        assert!(validate_connect_address("fd9d::1:2121", "udp").is_err());
        assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err());
        assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err());
        // Other transports are not inspected — they may legitimately accept
        // non-IP endpoints (tor onion, etc.).
        assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok());
    }

    #[test]
    fn allows_valid_endpoints() {
        assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok());
        assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok());
        assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok());
    }
}
