//! Gateway aggregation — pure functions for cross-project pipeline status.
//!
//! Formats aggregated pipeline data into compact text suitable for chat
//! transports (Matrix, Slack). Uses `service::pipeline::aggregate_pipeline_counts`
//! for per-project parsing.

use super::identity::SledIdentityReport;
use serde_json::Value;
use std::collections::BTreeMap;

/// Format an aggregated status map as a compact, one-line-per-project string
/// suitable for Matrix/Slack messages.
///
/// Healthy projects: `🟢 **name** — B:5 C:2 Q:1 M:0 D:12`
/// Blocked items appended on the same line: `| blocked: 42 [story]`
/// Unreachable projects: `🔴 **name** — UNREACHABLE`
pub fn format_aggregate_status_compact(statuses: &BTreeMap<String, Value>) -> String {
    let mut lines: Vec<String> = Vec::new();
    for (name, status) in statuses {
        if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
            lines.push(format!("\u{1F534} **{name}** — UNREACHABLE: {err}"));
        } else {
            let counts = status.get("counts");
            let b = counts
                .and_then(|c| c.get("backlog"))
                .and_then(|n| n.as_u64())
                .unwrap_or(0);
            let c = counts
                .and_then(|c| c.get("current"))
                .and_then(|n| n.as_u64())
                .unwrap_or(0);
            let q = counts
                .and_then(|c| c.get("qa"))
                .and_then(|n| n.as_u64())
                .unwrap_or(0);
            let m = counts
                .and_then(|c| c.get("merge"))
                .and_then(|n| n.as_u64())
                .unwrap_or(0);
            let d = counts
                .and_then(|c| c.get("done"))
                .and_then(|n| n.as_u64())
                .unwrap_or(0);

            let blocked_arr = status
                .get("blocked")
                .and_then(|a| a.as_array())
                .cloned()
                .unwrap_or_default();

            let indicator = if blocked_arr.is_empty() {
                "\u{1F7E2}" // 🟢
            } else {
                "\u{1F7E0}" // 🟠
            };

            let mut line = format!("{indicator} **{name}** — B:{b} C:{c} Q:{q} M:{m} D:{d}");

            if !blocked_arr.is_empty() {
                let ids: Vec<String> = blocked_arr
                    .iter()
                    .filter_map(|item| item.get("story_id").and_then(|s| s.as_str()))
                    .map(|s| s.to_string())
                    .collect();
                line.push_str(&format!(" | blocked: {}", ids.join(", ")));
            }

            lines.push(line);
        }
    }
    if lines.is_empty() {
        return "No projects registered.".to_string();
    }
    format!("**All Projects**\n\n{}", lines.join("\n\n"))
}

/// Format per-project active pipeline items as a compact cross-sled overview
/// for the `overview` gateway chat command (story 1193).
///
/// Only items in the In Progress (`current`), QA (`qa`), and Merge (`merge`)
/// stages are listed — one line per item, grouped under a `**project**`
/// heading. Projects with no items in those stages get a one-line "idle"
/// note. Projects whose status carries an `error` key (fetch failure or no
/// URL configured) are marked unreachable rather than being dropped from the
/// output, so a disconnected sled is never silently omitted.
pub fn format_overview_compact(items_by_project: &BTreeMap<String, Value>) -> String {
    if items_by_project.is_empty() {
        return "No projects registered.".to_string();
    }

    let mut sections: Vec<String> = Vec::new();
    for (name, status) in items_by_project {
        if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
            sections.push(format!("**{name}** — unreachable: {err}"));
            continue;
        }

        let active = status
            .get("active")
            .and_then(|a| a.as_array())
            .cloned()
            .unwrap_or_default();

        let lines: Vec<String> = active
            .iter()
            .filter_map(|item| {
                let label = match item.get("stage").and_then(|s| s.as_str()).unwrap_or("") {
                    "current" => "In Progress",
                    "qa" => "QA",
                    "merge" => "Merge",
                    _ => return None,
                };
                let story_id = item.get("story_id").and_then(|s| s.as_str()).unwrap_or("?");
                let story_name = item.get("name").and_then(|s| s.as_str()).unwrap_or("");
                Some(format!("  • {story_id} — {story_name} ({label})"))
            })
            .collect();

        if lines.is_empty() {
            sections.push(format!("**{name}** — idle"));
        } else {
            sections.push(format!("**{name}**\n{}", lines.join("\n")));
        }
    }

    format!("**Overview: Active Work**\n\n{}", sections.join("\n\n"))
}

/// Format `fleet_identity` read-mode reports as Markdown, one line per sled.
///
/// Matches, first-contacts (no pin recorded yet), and unreachable sleds get a
/// plain status line. A verified mismatch is called out with the same
/// wording as the `upgrade` command's identity check
/// (`chat::transport::matrix::sled_upgrade::verify_sled_identity`) so an
/// operator sees one consistent message for "wrong container answered"
/// regardless of which command surfaced it — including naming the container
/// as `huskies-{project}`.
pub fn format_identity_reports(reports: &[SledIdentityReport]) -> String {
    if reports.is_empty() {
        return "No projects registered.".to_string();
    }

    let lines: Vec<String> = reports
        .iter()
        .map(|r| {
            let container_name = format!("huskies-{}", r.project);
            let url = r.url.as_deref().unwrap_or("(no url configured)");

            if !r.connected {
                return format!("\u{1F534} **{}** — unreachable at `{url}`", r.project);
            }

            match (&r.expected_pin, &r.live_node_id, r.matched) {
                (_, _, true) => format!("\u{1F7E2} **{}** — matches pin `{url}`", r.project),
                (Some(expected), Some(live), false) => format!(
                    "\u{1F7E0} **identity mismatch** for `{container_name}` at `{url}`: expected \
                     node_id `{expected}`, but the container that answered identified as \
                     `{live}`. Refusing to proceed — this may not be the sled you expect."
                ),
                (None, Some(live), false) => format!(
                    "\u{1F7E1} **{}** — no pin recorded yet at `{url}`; live node_id `{live}` \
                     (re-pin to trust it)",
                    r.project
                ),
                (_, None, false) => format!(
                    "\u{1F534} **identity verification failed** for `{container_name}` at `{url}`: \
                     the `/identity` response's signature is missing or did not verify. \
                     Refusing to proceed — the container's identity cannot be trusted."
                ),
            }
        })
        .collect();

    format!("**Fleet Identity**\n\n{}", lines.join("\n"))
}

/// Find the registered project whose pipeline contains a story with the
/// given numeric ID prefix, searching `active`, `backlog`, and `archived`
/// alike so gateway `status <n>` resolves regardless of which project is
/// currently "active" (story 1203).
///
/// `items_by_project` is the output of `fetch_all_project_pipeline_items`.
/// Projects whose entry carries an `error` key (unreachable, no URL) are
/// skipped rather than matched. Returns the owning project's name, or `None`
/// if no registered, reachable project has a matching story.
pub fn find_project_containing_story(
    items_by_project: &BTreeMap<String, Value>,
    num_str: &str,
) -> Option<String> {
    for (name, status) in items_by_project {
        if status.get("error").is_some() {
            continue;
        }
        for key in ["active", "backlog", "archived"] {
            let Some(items) = status.get(key).and_then(|a| a.as_array()) else {
                continue;
            };
            let found = items.iter().any(|item| {
                item.get("story_id")
                    .and_then(|s| s.as_str())
                    .map(|story_id| story_number_prefix(story_id) == num_str)
                    .unwrap_or(false)
            });
            if found {
                return Some(name.clone());
            }
        }
    }
    None
}

/// Extract the leading numeric prefix from a `story_id` like `"42_story_x"`,
/// or `""` if the ID has no purely-digit prefix before the first `_`.
fn story_number_prefix(story_id: &str) -> &str {
    story_id
        .split('_')
        .next()
        .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
        .unwrap_or("")
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    #[test]
    fn format_healthy_project() {
        let mut statuses = BTreeMap::new();
        statuses.insert(
            "huskies".to_string(),
            json!({
                "counts": { "backlog": 5, "current": 2, "qa": 1, "merge": 0, "done": 12 },
                "blocked": []
            }),
        );
        let output = format_aggregate_status_compact(&statuses);
        assert!(output.contains("huskies"));
        assert!(output.contains("B:5"));
        assert!(output.contains("C:2"));
        assert!(output.contains("Q:1"));
        assert!(output.contains("D:12"));
        assert!(!output.contains("blocked:"));
    }

    #[test]
    fn format_unreachable_project() {
        let mut statuses = BTreeMap::new();
        statuses.insert(
            "broken".to_string(),
            json!({ "error": "connection refused" }),
        );
        let output = format_aggregate_status_compact(&statuses);
        assert!(output.contains("broken"));
        assert!(output.contains("UNREACHABLE"));
        assert!(output.contains("connection refused"));
    }

    #[test]
    fn format_blocked_items_shown() {
        let mut statuses = BTreeMap::new();
        statuses.insert(
            "myproj".to_string(),
            json!({
                "counts": { "backlog": 0, "current": 1, "qa": 0, "merge": 0, "done": 0 },
                "blocked": [{ "story_id": "42_story_x", "name": "X", "stage": "current", "reason": "blocked" }]
            }),
        );
        let output = format_aggregate_status_compact(&statuses);
        assert!(output.contains("blocked:"));
        assert!(output.contains("42_story_x"));
    }

    #[test]
    fn format_empty_projects() {
        let statuses = BTreeMap::new();
        let output = format_aggregate_status_compact(&statuses);
        assert_eq!(output, "No projects registered.");
    }

    // ── format_overview_compact ────────────────────────────────────────────

    #[test]
    fn overview_lists_items_grouped_by_project_with_stage_labels() {
        let mut items = BTreeMap::new();
        items.insert(
            "huskies".to_string(),
            json!({
                "active": [
                    { "story_id": "42_story_a", "name": "A", "stage": "current" },
                    { "story_id": "43_story_b", "name": "B", "stage": "qa" },
                    { "story_id": "44_story_c", "name": "C", "stage": "merge" },
                    { "story_id": "45_story_d", "name": "D", "stage": "done" },
                ]
            }),
        );
        let output = format_overview_compact(&items);
        assert!(output.contains("**huskies**"));
        assert!(output.contains("42_story_a — A (In Progress)"));
        assert!(output.contains("43_story_b — B (QA)"));
        assert!(output.contains("44_story_c — C (Merge)"));
        assert!(
            !output.contains("45_story_d"),
            "done-stage items must not appear in the overview: {output}"
        );
    }

    #[test]
    fn overview_idle_project_gets_idle_note() {
        let mut items = BTreeMap::new();
        items.insert("quiet-proj".to_string(), json!({ "active": [] }));
        let output = format_overview_compact(&items);
        assert!(output.contains("**quiet-proj** — idle"));
    }

    #[test]
    fn overview_unreachable_project_is_marked_not_omitted() {
        let mut items = BTreeMap::new();
        items.insert(
            "down-proj".to_string(),
            json!({ "error": "connection refused" }),
        );
        let output = format_overview_compact(&items);
        assert!(output.contains("down-proj"));
        assert!(output.contains("unreachable"));
        assert!(output.contains("connection refused"));
    }

    #[test]
    fn overview_no_url_project_marked_unreachable() {
        let mut items = BTreeMap::new();
        items.insert(
            "no-url-proj".to_string(),
            json!({ "error": "no URL configured" }),
        );
        let output = format_overview_compact(&items);
        assert!(output.contains("no-url-proj"));
        assert!(output.contains("unreachable: no URL configured"));
    }

    #[test]
    fn overview_empty_projects_map() {
        let items = BTreeMap::new();
        let output = format_overview_compact(&items);
        assert_eq!(output, "No projects registered.");
    }

    #[test]
    fn overview_multiple_projects_one_section_each() {
        let mut items = BTreeMap::new();
        items.insert(
            "alpha".to_string(),
            json!({ "active": [{ "story_id": "1_x", "name": "X", "stage": "current" }] }),
        );
        items.insert("beta".to_string(), json!({ "active": [] }));
        let output = format_overview_compact(&items);
        assert!(output.contains("**alpha**"));
        assert!(output.contains("**beta** — idle"));
        // alpha's section should come before beta's (BTreeMap sorted order).
        assert!(output.find("alpha").unwrap() < output.find("beta").unwrap());
    }

    // ── format_identity_reports (story 1206 AC3) ───────────────────────────

    #[test]
    fn identity_report_mismatch_names_container_consistent_with_upgrade_sweep() {
        let reports = vec![SledIdentityReport {
            project: "myapp".to_string(),
            url: Some("http://sled:3001".to_string()),
            connected: true,
            expected_pin: Some("expected-id".to_string()),
            live_node_id: Some("different-id".to_string()),
            matched: false,
        }];
        let output = format_identity_reports(&reports);
        assert!(output.contains("**identity mismatch**"));
        assert!(
            output.contains("`huskies-myapp`"),
            "must name the container as huskies-<project>: {output}"
        );
        assert!(output.contains("expected node_id `expected-id`"));
        assert!(output.contains("identified as `different-id`"));
        assert!(output.contains("Refusing to proceed"));
    }

    #[test]
    fn identity_report_match_is_a_plain_status_line() {
        let reports = vec![SledIdentityReport {
            project: "myapp".to_string(),
            url: Some("http://sled:3001".to_string()),
            connected: true,
            expected_pin: Some("abc".to_string()),
            live_node_id: Some("abc".to_string()),
            matched: true,
        }];
        let output = format_identity_reports(&reports);
        assert!(output.contains("myapp"));
        assert!(output.contains("matches pin"));
        assert!(!output.contains("mismatch"));
    }

    #[test]
    fn identity_report_unreachable_sled_is_marked() {
        let reports = vec![SledIdentityReport {
            project: "myapp".to_string(),
            url: Some("http://sled:3001".to_string()),
            connected: false,
            expected_pin: Some("abc".to_string()),
            live_node_id: None,
            matched: false,
        }];
        let output = format_identity_reports(&reports);
        assert!(output.contains("unreachable"));
    }

    #[test]
    fn identity_report_no_pin_recorded_invites_a_repin() {
        let reports = vec![SledIdentityReport {
            project: "myapp".to_string(),
            url: Some("http://sled:3001".to_string()),
            connected: true,
            expected_pin: None,
            live_node_id: Some("some-id".to_string()),
            matched: false,
        }];
        let output = format_identity_reports(&reports);
        assert!(output.contains("no pin recorded"));
        assert!(output.contains("some-id"));
    }

    #[test]
    fn identity_report_invalid_signature_is_flagged() {
        let reports = vec![SledIdentityReport {
            project: "myapp".to_string(),
            url: Some("http://sled:3001".to_string()),
            connected: true,
            expected_pin: Some("abc".to_string()),
            live_node_id: None,
            matched: false,
        }];
        let output = format_identity_reports(&reports);
        assert!(output.contains("**identity verification failed**"));
        assert!(output.contains("`huskies-myapp`"));
    }

    #[test]
    fn identity_report_empty_list() {
        let output = format_identity_reports(&[]);
        assert_eq!(output, "No projects registered.");
    }

    // ── find_project_containing_story ──────────────────────────────────────

    #[test]
    fn find_story_matches_active_array() {
        let mut items = BTreeMap::new();
        items.insert(
            "huskies".to_string(),
            json!({ "active": [{ "story_id": "42_story_x", "name": "X", "stage": "current" }], "backlog": [], "archived": [] }),
        );
        assert_eq!(
            find_project_containing_story(&items, "42"),
            Some("huskies".to_string())
        );
    }

    #[test]
    fn find_story_matches_backlog_array() {
        let mut items = BTreeMap::new();
        items.insert(
            "huskies".to_string(),
            json!({ "active": [], "backlog": [{ "story_id": "77_story_y", "name": "Y" }], "archived": [] }),
        );
        assert_eq!(
            find_project_containing_story(&items, "77"),
            Some("huskies".to_string())
        );
    }

    #[test]
    fn find_story_matches_archived_array() {
        let mut items = BTreeMap::new();
        items.insert(
            "huskies".to_string(),
            json!({ "active": [], "backlog": [], "archived": [{ "story_id": "13_story_z", "name": "Z" }] }),
        );
        assert_eq!(
            find_project_containing_story(&items, "13"),
            Some("huskies".to_string())
        );
    }

    #[test]
    fn find_story_returns_none_when_not_present_anywhere() {
        let mut items = BTreeMap::new();
        items.insert(
            "huskies".to_string(),
            json!({ "active": [{ "story_id": "1_story_a", "name": "A" }], "backlog": [], "archived": [] }),
        );
        assert_eq!(find_project_containing_story(&items, "999"), None);
    }

    #[test]
    fn find_story_skips_unreachable_projects() {
        let mut items = BTreeMap::new();
        items.insert(
            "broken".to_string(),
            json!({ "error": "connection refused" }),
        );
        items.insert(
            "huskies".to_string(),
            json!({ "active": [{ "story_id": "5_story_b", "name": "B" }], "backlog": [], "archived": [] }),
        );
        assert_eq!(
            find_project_containing_story(&items, "5"),
            Some("huskies".to_string())
        );
    }

    /// A project literally named after the digits (e.g. "42") must not
    /// spuriously match — only `story_id` prefixes are compared.
    #[test]
    fn find_story_does_not_match_on_project_name() {
        let mut items = BTreeMap::new();
        items.insert(
            "42".to_string(),
            json!({ "active": [{ "story_id": "1_story_a", "name": "A" }], "backlog": [], "archived": [] }),
        );
        assert_eq!(find_project_containing_story(&items, "42"), None);
    }
}
