//! Handler for the `overview` command.

use super::CommandContext;

/// Show implementation summary for a story identified by its number.
///
/// Finds the `huskies: merge {story_id}` commit on master, displays the
/// git diff --stat (files changed with line counts), and extracts key
/// function/struct/type names added or modified in the implementation.
/// Returns a friendly message when no merge commit is found.
#[allow(clippy::string_slice)] // commit_hash is hex (ASCII), min(8) always within bounds
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
    let num_str = ctx.args.trim();
    if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }

    let commit_hash = match find_story_merge_commit(ctx.effective_root(), num_str) {
        Some(h) => h,
        None => {
            return Some(format!(
                "No implementation found for story **{num_str}**. \
                It may still be in the backlog or was never merged."
            ));
        }
    };

    let stat_output = get_commit_stat(ctx.effective_root(), &commit_hash);
    let symbols = extract_diff_symbols(ctx.effective_root(), &commit_hash);
    let story_name = find_story_name(ctx.effective_root(), num_str);

    let short_hash = &commit_hash[..commit_hash.len().min(8)];
    let mut out = match story_name {
        Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"),
        None => format!("**Overview: Story {num_str}**\n\n"),
    };
    out.push_str(&format!("Commit: `{short_hash}`\n\n"));

    // Parse stat output: collect per-file lines and the summary line.
    let mut file_lines: Vec<String> = Vec::new();
    let mut summary_line = String::new();
    for line in stat_output.lines() {
        if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) {
            summary_line = line.trim().to_string();
        } else if !line.trim().is_empty() && line.contains('|') {
            file_lines.push(line.trim().to_string());
        }
    }

    if !summary_line.is_empty() {
        out.push_str(&format!("**Changes:** {summary_line}\n"));
    }

    if !file_lines.is_empty() {
        out.push_str("**Files:**\n");
        for f in file_lines.iter().take(8) {
            out.push_str(&format!("  • `{f}`\n"));
        }
        if file_lines.len() > 8 {
            out.push_str(&format!("  … and {} more\n", file_lines.len() - 8));
        }
    }

    if !symbols.is_empty() {
        out.push_str("\n**Key symbols:**\n");
        for sym in &symbols {
            out.push_str(&format!("  • {sym}\n"));
        }
    }

    Some(out)
}

/// Find the merge commit hash for a story by its numeric ID.
///
/// Searches git log for a commit whose subject matches
/// `huskies: merge {num}_*` or the legacy `story-kit: merge {num}_*`.
fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<String> {
    use std::process::Command;
    // Match both the current prefix and the legacy one from before the rename.
    let grep_pattern = format!("(huskies|storkit|story-kit): merge {num_str}_");
    let output = Command::new("git")
        .args([
            "log",
            "--format=%H",
            "--all",
            "--extended-regexp",
            "--grep",
            &grep_pattern,
        ])
        .current_dir(root)
        .output()
        .ok()
        .filter(|o| o.status.success())?;
    let text = String::from_utf8_lossy(&output.stdout);
    let hash = text.lines().next()?.trim().to_string();
    if hash.is_empty() { None } else { Some(hash) }
}

/// Find the human-readable name of a story from the CRDT name register
/// (story 929 — CRDT is the sole source of story metadata).
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
    let (story_id, _, _, _) = crate::chat::lookup::find_story_by_number(root, num_str)?;
    crate::crdt_state::read_item(&story_id).map(|w| w.name().to_string())
}

/// Return the `git show --stat` output for a commit.
fn get_commit_stat(root: &std::path::Path, hash: &str) -> String {
    use std::process::Command;
    Command::new("git")
        .args(["show", "--stat", hash])
        .current_dir(root)
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
        .unwrap_or_default()
}

/// Extract up to 12 unique top-level symbol definitions from a commit diff.
///
/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`,
/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``.
#[allow(clippy::string_slice)] // line starts with '+' (ASCII), so &line[1..] is always a valid boundary
fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec<String> {
    use std::process::Command;
    let output = Command::new("git")
        .args(["show", hash])
        .current_dir(root)
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
        .unwrap_or_default();

    let mut symbols: Vec<String> = Vec::new();
    for line in output.lines() {
        if !line.starts_with('+') || line.starts_with("+++") {
            continue;
        }
        if let Some(sym) = parse_symbol_definition(&line[1..]) {
            if !symbols.contains(&sym) {
                symbols.push(sym);
            }
            if symbols.len() >= 12 {
                break;
            }
        }
    }
    symbols
}

/// Parse a single line of code and return a formatted symbol if it opens a
/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`).
fn parse_symbol_definition(code: &str) -> Option<String> {
    let t = code.trim();
    let patterns: &[(&str, &str)] = &[
        ("pub async fn ", "fn"),
        ("async fn ", "fn"),
        ("pub fn ", "fn"),
        ("fn ", "fn"),
        ("pub struct ", "struct"),
        ("struct ", "struct"),
        ("pub enum ", "enum"),
        ("enum ", "enum"),
        ("pub type ", "type"),
        ("type ", "type"),
        ("pub trait ", "trait"),
        ("trait ", "trait"),
        ("impl ", "impl"),
    ];
    for (prefix, kind) in patterns {
        if let Some(rest) = t.strip_prefix(prefix) {
            let name: String = rest
                .chars()
                .take_while(|c| c.is_alphanumeric() || *c == '_')
                .collect();
            if !name.is_empty() {
                return Some(format!("`{name}` ({kind})"));
            }
        }
    }
    None
}

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

    use super::super::{CommandDispatch, try_handle_command};

    fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
        let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
        let room_id = "!test:example.com".to_string();
        let dispatch = CommandDispatch {
            services: &services,
            project_root: &services.project_root,
            bot_user_id: "@timmy:homeserver.local",
            room_id: &room_id,
        };
        try_handle_command(&dispatch, &format!("@timmy overview {args}"))
    }

    #[test]
    fn overview_command_is_registered() {
        use super::super::commands;
        let found = commands().iter().any(|c| c.name == "overview");
        assert!(found, "overview command must be in the registry");
    }

    #[test]
    fn overview_command_appears_in_help() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy help",
        );
        let output = result.unwrap();
        assert!(
            output.contains("overview"),
            "help should list overview command: {output}"
        );
    }

    #[test]
    fn overview_command_no_args_routes_to_timmy() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = overview_cmd_with_root(tmp.path(), "");
        assert!(
            result.is_none(),
            "no args should route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn overview_command_non_numeric_arg_routes_to_timmy() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = overview_cmd_with_root(tmp.path(), "of the auth refactor");
        assert!(
            result.is_none(),
            "natural-language args should route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn overview_command_well_formed_runs_handler() {
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap_or(std::path::Path::new("."));
        let result = overview_cmd_with_root(repo_root, "99999");
        assert!(
            result.is_some(),
            "well-formed numeric arg should run the command handler: {result:?}"
        );
    }

    #[test]
    fn overview_command_not_found_returns_friendly_message() {
        // Use the real repo root but a story number that was never merged.
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap_or(std::path::Path::new("."));
        let output = overview_cmd_with_root(repo_root, "99999").unwrap();
        assert!(
            output.contains("99999"),
            "not-found message should include the story number: {output}"
        );
        assert!(
            output.contains("backlog") || output.contains("No implementation"),
            "not-found message should explain why: {output}"
        );
    }

    #[test]
    fn overview_command_found_shows_commit_and_stat() {
        // Story 324 has a real merge commit in master.
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap_or(std::path::Path::new("."));
        let output = overview_cmd_with_root(repo_root, "324").unwrap();
        assert!(
            output.contains("**Overview: Story 324"),
            "output should show story header: {output}"
        );
        assert!(
            output.contains("Commit:"),
            "output should show commit hash: {output}"
        );
        assert!(
            output.contains("**Changes:**") || output.contains("**Files:**"),
            "output should show file changes: {output}"
        );
    }

    #[test]
    fn overview_command_case_insensitive() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy OVERVIEW 1",
        );
        assert!(result.is_some(), "OVERVIEW should match case-insensitively");
    }

    // -- parse_symbol_definition --------------------------------------------

    #[test]
    fn parse_symbol_pub_fn() {
        let result =
            parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option<String> {");
        assert_eq!(result, Some("`handle_foo` (fn)".to_string()));
    }

    #[test]
    fn parse_symbol_pub_struct() {
        let result = parse_symbol_definition("pub struct SlackTransport {");
        assert_eq!(result, Some("`SlackTransport` (struct)".to_string()));
    }

    #[test]
    fn parse_symbol_impl() {
        let result = parse_symbol_definition("impl ChatTransport for SlackTransport {");
        assert_eq!(result, Some("`ChatTransport` (impl)".to_string()));
    }

    #[test]
    fn parse_symbol_no_match() {
        let result = parse_symbol_definition("    let x = 42;");
        assert_eq!(result, None);
    }

    #[test]
    fn parse_symbol_pub_enum() {
        let result = parse_symbol_definition("pub enum QaMode {");
        assert_eq!(result, Some("`QaMode` (enum)".to_string()));
    }

    #[test]
    fn parse_symbol_pub_type() {
        let result = parse_symbol_definition(
            "pub type SlackHistory = Arc<Mutex<HashMap<String, Vec<u8>>>>;",
        );
        assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
    }
}
