//! Gateway notification polling — pure event formatting.
//!
//! Formats pipeline events from project containers into gateway notifications
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
//! tasks, sending messages) lives in `io.rs`.

use crate::pipeline_state::Stage;
use crate::service::events::StoredEvent;
use crate::service::notifications::{
    format_blocked_notification, format_disk_recovery_notification,
    format_disk_warning_notification, format_error_notification, format_stage_notification,
};

/// Format a [`StoredEvent`] from a project into a gateway notification.
///
/// Prefixes the message with `[project-name]` so users can distinguish which
/// project emitted the event.
pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String, String) {
    let prefix = format!("[{project_name}] ");

    match event {
        StoredEvent::StageTransition {
            story_id,
            story_name,
            from_stage,
            to_stage,
            ..
        } => {
            let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming);
            let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
            let (plain, html) =
                format_stage_notification(story_id, story_name, &from_typed, &to_typed);
            (format!("{prefix}{plain}"), format!("{prefix}{html}"))
        }
        StoredEvent::MergeFailure {
            story_id,
            story_name,
            reason,
            ..
        } => {
            let (plain, html) = format_error_notification(story_id, story_name, reason);
            (format!("{prefix}{plain}"), format!("{prefix}{html}"))
        }
        StoredEvent::StoryBlocked {
            story_id,
            story_name,
            reason,
            ..
        } => {
            let (plain, html) = format_blocked_notification(story_id, story_name, reason);
            (format!("{prefix}{plain}"), format!("{prefix}{html}"))
        }
        StoredEvent::DiskSpaceWarning {
            level,
            free_bytes,
            target_bytes,
            worktrees_bytes,
            host_id,
            ..
        } => {
            let (plain, html) = format_disk_warning_notification(
                level,
                host_id,
                *free_bytes,
                *target_bytes,
                *worktrees_bytes,
            );
            (format!("{prefix}{plain}"), format!("{prefix}{html}"))
        }
        StoredEvent::DiskSpaceRecovered {
            free_bytes,
            host_id,
            ..
        } => {
            let (plain, html) = format_disk_recovery_notification(host_id, *free_bytes);
            (format!("{prefix}{plain}"), format!("{prefix}{html}"))
        }
    }
}

/// Format a [`StoredEvent`] from a project as a compact audit line for LLM context.
///
/// Produces a structured one-line entry with stable `key=value` fields, including
/// the project name, mirroring the sled-side `format_audit_entry` format.
#[cfg(test)]
pub fn format_gateway_audit_line(project: &str, event: &StoredEvent) -> String {
    use crate::pipeline_state::stage_label;
    let ts_ms = event.timestamp_ms();
    let ts = chrono::DateTime::from_timestamp_millis(ts_ms as i64)
        .unwrap_or_else(chrono::Utc::now)
        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);

    match event {
        StoredEvent::StageTransition {
            story_id,
            from_stage,
            to_stage,
            ..
        } => {
            let from_label = stage_label(&Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming));
            let to_label = stage_label(&Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming));
            format!(
                "audit ts={ts} project={project} id={story_id} from={from_label} to={to_label} event=stage_transition"
            )
        }
        StoredEvent::MergeFailure {
            story_id, reason, ..
        } => {
            format!(
                "audit ts={ts} project={project} id={story_id} event=merge_failure reason={reason}"
            )
        }
        StoredEvent::StoryBlocked {
            story_id, reason, ..
        } => {
            format!(
                "audit ts={ts} project={project} id={story_id} event=story_blocked reason={reason}"
            )
        }
        StoredEvent::DiskSpaceWarning { level, host_id, .. } => {
            format!(
                "audit ts={ts} project={project} event=disk_space_warning level={level} host={host_id}"
            )
        }
        StoredEvent::DiskSpaceRecovered { host_id, .. } => {
            format!("audit ts={ts} project={project} event=disk_space_recovered host={host_id}")
        }
    }
}

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

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

    #[test]
    fn stage_transition_prefixes_project_name() {
        let event = StoredEvent::StageTransition {
            story_id: "42_story_my_feature".to_string(),
            story_name: String::new(),
            from_stage: "coding".to_string(),
            to_stage: "qa".to_string(),
            timestamp_ms: 1000,
        };
        let (plain, html) = format_gateway_event("huskies", &event);
        assert!(plain.starts_with("[huskies] "));
        assert!(html.starts_with("[huskies] "));
        assert!(plain.contains("Current"));
        assert!(plain.contains("QA"));
    }

    #[test]
    fn merge_failure_prefixes_project_name() {
        let event = StoredEvent::MergeFailure {
            story_id: "42_story_my_feature".to_string(),
            story_name: String::new(),
            reason: "merge conflict".to_string(),
            timestamp_ms: 1000,
        };
        let (plain, _html) = format_gateway_event("robot-studio", &event);
        assert!(plain.starts_with("[robot-studio] "));
        assert!(plain.contains("merge conflict"));
    }

    #[test]
    fn story_blocked_prefixes_project_name() {
        let event = StoredEvent::StoryBlocked {
            story_id: "43_story_bar".to_string(),
            story_name: String::new(),
            reason: "retry limit exceeded".to_string(),
            timestamp_ms: 2000,
        };
        let (plain, _html) = format_gateway_event("huskies", &event);
        assert!(plain.starts_with("[huskies] "));
        assert!(plain.contains("BLOCKED"));
    }

    #[test]
    fn stage_transition_with_story_name_includes_title() {
        let event = StoredEvent::StageTransition {
            story_id: "10_story_feat".to_string(),
            story_name: "My New Feature".to_string(),
            from_stage: "backlog".to_string(),
            to_stage: "coding".to_string(),
            timestamp_ms: 100,
        };
        let (plain, html) = format_gateway_event("huskies", &event);
        assert!(plain.starts_with("[huskies] "));
        assert!(
            plain.contains("My New Feature"),
            "plain must include story title; got: {plain}"
        );
        assert!(
            html.contains("My New Feature"),
            "html must include story title; got: {html}"
        );
        assert!(plain.contains("#10"));
        assert!(plain.contains("Backlog"));
        assert!(plain.contains("Current"));
    }

    #[test]
    fn merge_failure_with_story_name_includes_title() {
        let event = StoredEvent::MergeFailure {
            story_id: "7_story_bar".to_string(),
            story_name: "Bar Feature".to_string(),
            reason: "conflict in lib.rs".to_string(),
            timestamp_ms: 200,
        };
        let (plain, html) = format_gateway_event("robot-studio", &event);
        assert!(plain.starts_with("[robot-studio] "));
        assert!(
            plain.contains("Bar Feature"),
            "plain must include story title; got: {plain}"
        );
        assert!(
            html.contains("Bar Feature"),
            "html must include story title; got: {html}"
        );
        assert!(plain.contains("#7"));
        assert!(plain.contains("conflict in lib.rs"));
    }

    #[test]
    fn story_blocked_with_story_name_includes_title() {
        let event = StoredEvent::StoryBlocked {
            story_id: "3_story_baz".to_string(),
            story_name: "Baz Story".to_string(),
            reason: "retry limit exceeded".to_string(),
            timestamp_ms: 300,
        };
        let (plain, html) = format_gateway_event("huskies", &event);
        assert!(plain.starts_with("[huskies] "));
        assert!(
            plain.contains("Baz Story"),
            "plain must include story title; got: {plain}"
        );
        assert!(
            html.contains("Baz Story"),
            "html must include story title; got: {html}"
        );
        assert!(plain.contains("#3"));
        assert!(plain.contains("BLOCKED"));
    }

    #[test]
    fn unnamed_story_falls_back_gracefully() {
        let event = StoredEvent::StageTransition {
            story_id: "5_story_unnamed".to_string(),
            story_name: String::new(),
            from_stage: "backlog".to_string(),
            to_stage: "qa".to_string(),
            timestamp_ms: 50,
        };
        let (plain, _html) = format_gateway_event("proj", &event);
        assert!(plain.starts_with("[proj] "));
        assert!(plain.contains("#5"));
        assert!(plain.contains("Backlog"));
        assert!(plain.contains("QA"));
        // Must NOT contain stray empty-name artefacts between # and —
        assert!(
            !plain.contains("  "),
            "should not have double spaces from empty name; got: {plain}"
        );
    }

    // -- format_gateway_audit_line -------------------------------------------

    #[test]
    fn audit_line_stage_transition_contains_project_and_stages() {
        let event = StoredEvent::StageTransition {
            story_id: "42_story_feat".to_string(),
            story_name: String::new(),
            from_stage: "2_current".to_string(),
            to_stage: "3_qa".to_string(),
            timestamp_ms: 1_000_000,
        };
        let line = format_gateway_audit_line("huskies", &event);
        assert!(
            line.starts_with("audit ts="),
            "must start with audit ts=; got: {line}"
        );
        assert!(
            line.contains("project=huskies"),
            "must contain project; got: {line}"
        );
        assert!(
            line.contains("id=42_story_feat"),
            "must contain story id; got: {line}"
        );
        assert!(
            line.contains("event=stage_transition"),
            "must name event; got: {line}"
        );
        assert!(line.contains("from="), "must have from field; got: {line}");
        assert!(line.contains("to="), "must have to field; got: {line}");
    }

    #[test]
    fn audit_line_merge_failure_contains_reason() {
        let event = StoredEvent::MergeFailure {
            story_id: "7_story_bar".to_string(),
            story_name: String::new(),
            reason: "conflict in main.rs".to_string(),
            timestamp_ms: 2_000_000,
        };
        let line = format_gateway_audit_line("robot-studio", &event);
        assert!(line.contains("project=robot-studio"), "got: {line}");
        assert!(line.contains("event=merge_failure"), "got: {line}");
        assert!(line.contains("reason=conflict in main.rs"), "got: {line}");
    }

    #[test]
    fn audit_line_story_blocked_contains_reason() {
        let event = StoredEvent::StoryBlocked {
            story_id: "3_story_baz".to_string(),
            story_name: String::new(),
            reason: "retry limit exceeded".to_string(),
            timestamp_ms: 3_000_000,
        };
        let line = format_gateway_audit_line("proj", &event);
        assert!(line.contains("project=proj"), "got: {line}");
        assert!(line.contains("event=story_blocked"), "got: {line}");
        assert!(line.contains("reason=retry limit exceeded"), "got: {line}");
    }
}
