//! Handler for the `unreleased` command.
//!
//! Shows a list of stories merged to master since the last release tag.

use super::CommandContext;

/// Show stories merged since the last release tag.
///
/// Finds the most recent git tag, then lists all story merge commits between
/// that tag and HEAD on master.  Each entry shows the story number and name.
/// Returns a clear message when there are no unreleased stories or no tags.
pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
    let root = ctx.effective_root();
    let tag = find_last_release_tag(root);

    let commits = list_merge_commits_since(root, tag.as_deref());

    if commits.is_empty() {
        let msg = match &tag {
            Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
            None => "No release tags found and no story merge commits on master.".to_string(),
        };
        return Some(msg);
    }

    let mut stories: Vec<(u64, String)> = commits
        .iter()
        .filter_map(|subject| parse_story_from_subject(subject))
        .collect();

    // Sort by story number, deduplicate.
    stories.sort_by_key(|(n, _)| *n);
    stories.dedup_by_key(|(n, _)| *n);

    if stories.is_empty() {
        let msg = match &tag {
            Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
            None => "No release tags found and no story merge commits on master.".to_string(),
        };
        return Some(msg);
    }

    // Look up human-readable names for each story.
    let mut out = match &tag {
        Some(t) => format!("**Unreleased stories since {t}:**\n\n"),
        None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
    };
    for (num, slug) in &stories {
        let name = find_story_name(root, &num.to_string()).unwrap_or_else(|| slug_to_name(slug));
        out.push_str(&format!("- **{num}** — {name}\n"));
    }
    Some(out)
}

// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------

/// Return the most recent release tag, or `None` if there are no tags.
///
/// Uses `git tag --sort=-creatordate` to get the newest tag first.
fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
    use std::process::Command;
    let output = Command::new("git")
        .args(["tag", "--sort=-creatordate"])
        .current_dir(root)
        .output()
        .ok()
        .filter(|o| o.status.success())?;
    let text = String::from_utf8_lossy(&output.stdout);
    let tag = text.lines().next()?.trim().to_string();
    if tag.is_empty() { None } else { Some(tag) }
}

/// Return the subjects of all `huskies: merge …` commits reachable from HEAD
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
fn list_merge_commits_since(root: &std::path::Path, since_tag: Option<&str>) -> Vec<String> {
    use std::process::Command;

    let range = match since_tag {
        Some(tag) => format!("{tag}..HEAD"),
        None => "HEAD".to_string(),
    };

    let output = Command::new("git")
        .args([
            "log",
            &range,
            "--format=%s",
            "--extended-regexp",
            "--grep",
            "(huskies|storkit|story-kit): merge [0-9]+_",
        ])
        .current_dir(root)
        .output()
        .ok()
        .filter(|o| o.status.success());

    match output {
        Some(o) => String::from_utf8_lossy(&o.stdout)
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect(),
        None => Vec::new(),
    }
}

/// Parse a story number and slug from a merge commit subject like
/// `huskies: merge 386_story_unreleased_command`.
///
/// Returns `(story_number, slug_remainder)` or `None` if the subject doesn't
/// match the expected pattern.
fn parse_story_from_subject(subject: &str) -> Option<(u64, String)> {
    // Match "huskies: merge NNN_rest" or "story-kit: merge NNN_rest"
    let rest = subject
        .strip_prefix("huskies: merge ")
        .or_else(|| subject.strip_prefix("story-kit: merge "))?;

    let (num_str, slug) = rest.split_once('_')?;
    let num: u64 = num_str.parse().ok()?;
    Some((num, slug.to_string()))
}

/// Convert an underscore-separated slug to a title-case name.
///
/// Used as a fallback when no pipeline file is found.
fn slug_to_name(slug: &str) -> String {
    let words: Vec<String> = slug
        .split('_')
        .filter(|w| !w.is_empty())
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
                None => String::new(),
            }
        })
        .collect();
    words.join(" ")
}

/// Find the human-readable name of a story from the CRDT (story 929 —
/// CRDT is the sole source of story metadata).
fn find_story_name(_root: &std::path::Path, num_str: &str) -> Option<String> {
    let items = crate::crdt_state::read_all_items()?;
    for item in items {
        if item.story_id().split('_').next().unwrap_or("") == num_str {
            return Some(item.name().to_string());
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

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

    fn unreleased_cmd_with_root(root: &std::path::Path) -> 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, "@timmy unreleased")
    }

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

    #[test]
    fn unreleased_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("unreleased"),
            "help should list unreleased command: {output}"
        );
    }

    #[test]
    fn unreleased_command_no_tags_returns_message() {
        // A temp dir that is not a git repo — git commands will fail gracefully.
        let tmp = tempfile::TempDir::new().unwrap();
        let output = unreleased_cmd_with_root(tmp.path()).unwrap();
        // Should return some message (not panic), either about no tags or no commits.
        assert!(
            !output.is_empty(),
            "should return a non-empty message: {output}"
        );
    }

    #[test]
    fn unreleased_command_real_repo_returns_response() {
        // Run against the actual repo root to exercise the git path.
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap_or(std::path::Path::new("."));
        let output = unreleased_cmd_with_root(repo_root).unwrap();
        // The response should mention "unreleased" or "no unreleased" — just make
        // sure it's non-empty and doesn't panic.
        assert!(
            !output.is_empty(),
            "should return a non-empty message: {output}"
        );
    }

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

    // -- parse_story_from_subject ------------------------------------------

    #[test]
    fn parse_story_huskies_prefix() {
        let result = parse_story_from_subject("huskies: merge 386_story_unreleased_command");
        assert_eq!(result, Some((386, "story_unreleased_command".to_string())));
    }

    #[test]
    fn parse_story_legacy_prefix() {
        let result = parse_story_from_subject("story-kit: merge 42_story_add_feature");
        assert_eq!(result, Some((42, "story_add_feature".to_string())));
    }

    #[test]
    fn parse_story_no_match() {
        let result = parse_story_from_subject("fix: typo in README");
        assert_eq!(result, None);
    }

    #[test]
    fn parse_story_no_underscore_after_number() {
        let result = parse_story_from_subject("huskies: merge 123");
        assert_eq!(result, None);
    }

    // -- slug_to_name --------------------------------------------------

    #[test]
    fn slug_to_name_basic() {
        assert_eq!(slug_to_name("story_add_feature"), "Story Add Feature");
    }

    #[test]
    fn slug_to_name_single_word() {
        assert_eq!(slug_to_name("feature"), "Feature");
    }
}
