//! `cargo kick info` — print a snapshot of the current kick-rs project.
//!
//! Pulls together:
//! - Package metadata from `Cargo.toml` (name, version)
//! - Which `kick-rs` version + features the project declares
//! - Every module on disk (under `src/modules/<name>/mod.rs`), plus
//!   the services and contributors registered in each module's
//!   builder chain.
//!
//! Module-detail extraction is text-level — we scan for the same
//! patterns the scaffold + generators emit (`define_module(...)`,
//! `.prefix(...)`, `.service::<...>()`, `.contribute(...)`). That
//! intentionally undercovers exotic adopter shapes; cleanly-shaped
//! projects (generated by `cargo kick g`) report perfectly.

use crate::generate::{find_project_root, GenerateError};
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Value};

/// Decoded form of the `info` subcommand.
pub struct InfoArgs {
    pub project_root: Option<PathBuf>,
    pub dep_name: String,
}

/// Top-level snapshot rendered by [`render_info`].
#[derive(Debug)]
pub struct ProjectInfo {
    pub root: PathBuf,
    pub package_name: String,
    pub package_version: String,
    pub kick_rs_version: Option<String>,
    pub kick_rs_features: Vec<String>,
    pub modules: Vec<ModuleInfo>,
}

#[derive(Debug)]
pub struct ModuleInfo {
    /// Directory name on disk (e.g. `users`).
    pub dir_name: String,
    /// `define_module("X")` literal, when found.
    pub declared_name: Option<String>,
    /// `.prefix("/X")` literal, when found.
    pub prefix: Option<String>,
    /// Identifiers between `.service::<` and `>()`.
    pub services: Vec<String>,
    /// Identifiers between `.contribute(` and `)`.
    pub contributors: Vec<String>,
}

#[derive(Debug)]
pub enum InfoError {
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    Toml {
        path: PathBuf,
        source: Box<toml_edit::TomlError>,
    },
    ProjectRoot(GenerateError),
}

impl std::fmt::Display for InfoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
            Self::Toml { path, source } => {
                write!(f, "could not parse `{}`: {source}", path.display())
            }
            Self::ProjectRoot(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for InfoError {}

/// Collect the snapshot from disk.
pub fn collect_info(args: &InfoArgs) -> Result<ProjectInfo, InfoError> {
    let root = match &args.project_root {
        Some(p) => p.clone(),
        None => find_project_root(Path::new(".")).map_err(InfoError::ProjectRoot)?,
    };

    let cargo_toml = root.join("Cargo.toml");
    let contents = fs::read_to_string(&cargo_toml).map_err(|e| InfoError::Io {
        path: cargo_toml.clone(),
        source: e,
    })?;
    let doc: DocumentMut = contents.parse().map_err(|e| InfoError::Toml {
        path: cargo_toml.clone(),
        source: Box::new(e),
    })?;

    let pkg = doc
        .get("package")
        .and_then(|i| i.as_table_like())
        .ok_or_else(|| InfoError::Io {
            path: cargo_toml.clone(),
            source: std::io::Error::other("Cargo.toml has no [package] table"),
        })?;
    let package_name = pkg
        .get("name")
        .and_then(|i| i.as_str())
        .unwrap_or("<unknown>")
        .to_owned();
    let package_version = pkg
        .get("version")
        .and_then(|i| i.as_str())
        .unwrap_or("<unknown>")
        .to_owned();

    let (kick_rs_version, kick_rs_features) = doc
        .get("dependencies")
        .and_then(|i| i.as_table_like())
        .and_then(|t| t.get(&args.dep_name))
        .map(|item| match item {
            toml_edit::Item::Value(Value::String(s)) => (Some(s.value().to_owned()), Vec::new()),
            toml_edit::Item::Value(Value::InlineTable(t)) => {
                let v = t.get("version").and_then(|x| x.as_str()).map(str::to_owned);
                let feats = t
                    .get("features")
                    .and_then(|x| x.as_array())
                    .map(|a| {
                        a.iter()
                            .filter_map(|x| x.as_str().map(str::to_owned))
                            .collect()
                    })
                    .unwrap_or_default();
                (v, feats)
            }
            _ => (None, Vec::new()),
        })
        .unwrap_or((None, Vec::new()));

    let modules = collect_modules(&root.join("src/modules"));

    Ok(ProjectInfo {
        root,
        package_name,
        package_version,
        kick_rs_version,
        kick_rs_features,
        modules,
    })
}

fn collect_modules(modules_dir: &Path) -> Vec<ModuleInfo> {
    let Ok(entries) = fs::read_dir(modules_dir) else {
        return Vec::new();
    };
    let mut out: Vec<ModuleInfo> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let path = e.path();
            if !path.is_dir() {
                return None;
            }
            let dir_name = path.file_name()?.to_str()?.to_owned();
            // Each module owns a mod.rs. If absent, this isn't a module —
            // skip it rather than reporting a half-populated entry.
            let mod_rs = path.join("mod.rs");
            let body = fs::read_to_string(&mod_rs).ok()?;
            Some(extract_module_info(dir_name, &body))
        })
        .collect();
    out.sort_by(|a, b| a.dir_name.cmp(&b.dir_name));
    out
}

/// Pull the `define_module(...)`, `.prefix(...)`, `.service::<...>()`,
/// `.contribute(...)` mentions out of a module's `mod.rs`. Text-level
/// scan, conservative on purpose — adopters with custom builder
/// wrappers fall back to seeing just the directory name + a
/// dir_name-only entry, which is still useful.
pub(crate) fn extract_module_info(dir_name: String, body: &str) -> ModuleInfo {
    // r#""# is the *empty* string (the content between r#" and "# is
    // zero chars). For a single `"` close delimiter we use the
    // ordinary escaped form.
    let declared_name = scan_between(body, "define_module(\"", "\"");
    let prefix = scan_between(body, ".prefix(\"", "\"");
    let services = scan_all_between(body, ".service::<", ">()");
    let contributors = scan_all_between(body, ".contribute(", ")");
    ModuleInfo {
        dir_name,
        declared_name,
        prefix,
        services,
        contributors,
    }
}

/// Find the *first* occurrence of `open`, then everything up to (but
/// not including) the next `close`. Returns the matched span trimmed
/// of leading whitespace, or `None` if either delimiter is missing.
fn scan_between(haystack: &str, open: &str, close: &str) -> Option<String> {
    let start = haystack.find(open)?;
    let after_open = &haystack[start + open.len()..];
    let end = after_open.find(close)?;
    Some(after_open[..end].trim().to_owned())
}

/// Find *every* occurrence of `open` followed by `close`, returning
/// the spans in source order.
fn scan_all_between(haystack: &str, open: &str, close: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cursor = 0;
    while let Some(rel) = haystack[cursor..].find(open) {
        let abs = cursor + rel;
        let after_open = abs + open.len();
        let Some(rel_close) = haystack[after_open..].find(close) else {
            break;
        };
        let abs_close = after_open + rel_close;
        let token = haystack[after_open..abs_close].trim().to_owned();
        if !token.is_empty() {
            out.push(token);
        }
        cursor = abs_close + close.len();
    }
    out
}

/// Render the snapshot for the CLI.
pub fn render_info(info: &ProjectInfo) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "kick-rs project: {pkg} {ver}\n",
        pkg = info.package_name,
        ver = info.package_version,
    ));
    out.push_str(&format!("  root:        {}\n", info.root.display()));
    out.push_str(&format!(
        "  kick-rs dep: {}\n",
        info.kick_rs_version
            .as_deref()
            .unwrap_or("<not depended on>"),
    ));
    out.push_str(&format!(
        "  features:    {}\n",
        if info.kick_rs_features.is_empty() {
            "<none>".to_owned()
        } else {
            info.kick_rs_features.join(", ")
        }
    ));
    out.push('\n');

    if info.modules.is_empty() {
        out.push_str("modules:       <none detected under src/modules/>\n");
    } else {
        out.push_str(&format!("modules ({}):\n", info.modules.len()));
        for m in &info.modules {
            let label = match (&m.declared_name, &m.prefix) {
                (Some(name), Some(prefix)) => format!("{name} (prefix {prefix})"),
                (Some(name), None) => format!("{name} (no prefix)"),
                (None, _) => format!("{} (couldn't parse define_module)", m.dir_name),
            };
            out.push_str(&format!("  - {}\n", label));
            if !m.services.is_empty() {
                out.push_str(&format!("      services:     {}\n", m.services.join(", ")));
            }
            if !m.contributors.is_empty() {
                out.push_str(&format!(
                    "      contributors: {}\n",
                    m.contributors.join(", ")
                ));
            }
        }
    }
    out
}

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

    #[test]
    fn extract_module_info_pulls_name_prefix_services_contribs() {
        let body = r#"
            pub mod handlers;
            use kick_rs::{define_module, Module};
            use email_sender::EmailSender;
            use load_user::LoadUser;

            pub fn define() -> Module {
                define_module("users")
                    .prefix("/users")
                    .service::<EmailSender>()
                    .contribute(LoadUser)
                    .get("/", handlers::index)
                    .build()
            }
        "#;
        let m = extract_module_info("users".into(), body);
        assert_eq!(m.declared_name.as_deref(), Some("users"));
        assert_eq!(m.prefix.as_deref(), Some("/users"));
        assert_eq!(m.services, vec!["EmailSender"]);
        assert_eq!(m.contributors, vec!["LoadUser"]);
    }

    #[test]
    fn extract_module_info_handles_multiple_services_and_contribs() {
        let body = r#"
            define_module("hub")
                .prefix("/hub")
                .service::<A>()
                .service::<B>()
                .contribute(X)
                .contribute(Y)
                .build()
        "#;
        let m = extract_module_info("hub".into(), body);
        assert_eq!(m.services, vec!["A", "B"]);
        assert_eq!(m.contributors, vec!["X", "Y"]);
    }

    #[test]
    fn extract_module_info_tolerates_missing_define_module() {
        // Adopter wrote a custom builder wrapper — we still want a row.
        let body = "// no define_module here\n";
        let m = extract_module_info("oddball".into(), body);
        assert_eq!(m.declared_name, None);
        assert_eq!(m.prefix, None);
        assert!(m.services.is_empty());
        assert!(m.contributors.is_empty());
    }

    fn make_proj(dir: &Path) {
        fs::create_dir_all(dir.join("src/modules/users")).unwrap();
        fs::write(dir.join("src/modules/mod.rs"), "pub mod users;\n").unwrap();
        fs::write(
            dir.join("src/modules/users/mod.rs"),
            r#"use kick_rs::{define_module, Module};
               pub fn define() -> Module {
                   define_module("users")
                       .prefix("/users")
                       .build()
               }
            "#,
        )
        .unwrap();
        fs::write(
            dir.join("Cargo.toml"),
            r#"[package]
name = "demo"
version = "0.4.2"
edition = "2021"

[dependencies]
kick-rs = { version = "0.1.0-alpha.3", features = ["macros", "openapi"] }
"#,
        )
        .unwrap();
    }

    #[test]
    fn collect_info_reports_package_dep_and_modules() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().join("proj");
        make_proj(&root);

        let info = collect_info(&InfoArgs {
            project_root: Some(root.clone()),
            dep_name: "kick-rs".into(),
        })
        .unwrap();

        assert_eq!(info.package_name, "demo");
        assert_eq!(info.package_version, "0.4.2");
        assert_eq!(info.kick_rs_version.as_deref(), Some("0.1.0-alpha.3"));
        assert_eq!(info.kick_rs_features, vec!["macros", "openapi"]);
        assert_eq!(info.modules.len(), 1);
        assert_eq!(info.modules[0].dir_name, "users");
        assert_eq!(info.modules[0].declared_name.as_deref(), Some("users"));
        assert_eq!(info.modules[0].prefix.as_deref(), Some("/users"));
    }

    #[test]
    fn collect_info_handles_string_dep_form() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().join("proj");
        make_proj(&root);
        // Replace inline-table with string form to exercise both shapes.
        fs::write(
            root.join("Cargo.toml"),
            r#"[package]
name = "demo"
version = "0.1.0"

[dependencies]
kick-rs = "0.2.0"
"#,
        )
        .unwrap();

        let info = collect_info(&InfoArgs {
            project_root: Some(root.clone()),
            dep_name: "kick-rs".into(),
        })
        .unwrap();
        assert_eq!(info.kick_rs_version.as_deref(), Some("0.2.0"));
        assert!(info.kick_rs_features.is_empty());
    }

    #[test]
    fn render_info_includes_key_fields() {
        let info = ProjectInfo {
            root: PathBuf::from("/x"),
            package_name: "demo".into(),
            package_version: "0.1.0".into(),
            kick_rs_version: Some("0.1.0-alpha.3".into()),
            kick_rs_features: vec!["macros".into(), "openapi".into()],
            modules: vec![ModuleInfo {
                dir_name: "users".into(),
                declared_name: Some("users".into()),
                prefix: Some("/users".into()),
                services: vec!["S".into()],
                contributors: vec!["C".into()],
            }],
        };
        let s = render_info(&info);
        assert!(s.contains("kick-rs project: demo 0.1.0"));
        assert!(s.contains("kick-rs dep: 0.1.0-alpha.3"));
        assert!(s.contains("features:    macros, openapi"));
        assert!(s.contains("- users (prefix /users)"));
        assert!(s.contains("services:     S"));
        assert!(s.contains("contributors: C"));
    }
}
