//! Tests for rate-limit, story-blocked, OAuth, config-reload, and
//! new-item-creation notifications.

use super::mock_transport::MockTransport;
use super::spawn_notification_listener;
use crate::io::watcher::WatcherEvent;
use tokio::sync::broadcast;

// ── spawn_notification_listener: NewItemCreated ──────────────────────────────

/// AC1+AC2 (story 1201): a single creation produces exactly one chat line,
/// prefixed with the project name and naming the readable origin.
#[tokio::test]
async fn new_item_created_sends_single_notification_with_project_prefix_and_origin() {
    let tmp = tempfile::tempdir().unwrap();
    let sk_dir = tmp.path().join(".huskies");
    std::fs::create_dir_all(&sk_dir).unwrap();
    std::fs::write(
        sk_dir.join("project.toml"),
        "gateway_project = \"TestProj\"\n",
    )
    .unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::NewItemCreated {
            item_id: "42_story_my_feature".to_string(),
            item_type: "story".to_string(),
            name: "My Feature".to_string(),
            origin: "user alice".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(400)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (_, plain, _) = &calls[0];
    assert!(
        plain.starts_with("[TestProj] "),
        "must be prefixed with the project name; got: {plain}"
    );
    assert!(plain.contains("42"), "must contain the item number");
    assert!(plain.contains("My Feature"), "must contain the item name");
    assert!(
        plain.contains("user alice"),
        "must render the origin readably; got: {plain}"
    );
}

/// AC3 (story 1201): multiple creations within the coalescing window produce
/// one combined chat message listing all items.
#[tokio::test]
async fn new_item_created_burst_coalesces_into_one_combined_message() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    // Three creations in rapid succession (no sleep between) — all within
    // the coalescing window.
    watcher_tx
        .send(WatcherEvent::NewItemCreated {
            item_id: "1_story_a".to_string(),
            item_type: "story".to_string(),
            name: "Story A".to_string(),
            origin: "user alice".to_string(),
        })
        .unwrap();
    watcher_tx
        .send(WatcherEvent::NewItemCreated {
            item_id: "2_bug_b".to_string(),
            item_type: "bug".to_string(),
            name: "Bug B".to_string(),
            origin: "agent coder-1@story=42".to_string(),
        })
        .unwrap();
    watcher_tx
        .send(WatcherEvent::NewItemCreated {
            item_id: "3_refactor_c".to_string(),
            item_type: "refactor".to_string(),
            name: "Refactor C".to_string(),
            origin: "chat-bot Timmy@!room:home".to_string(),
        })
        .unwrap();

    // Wait past the coalescing window for the combined flush.
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(
        calls.len(),
        1,
        "A burst of creations must coalesce into one combined message; got: {calls:?}"
    );
    let (_, plain, _) = &calls[0];
    assert!(plain.contains("Story A"), "missing item 1; got: {plain}");
    assert!(plain.contains("Bug B"), "missing item 2; got: {plain}");
    assert!(plain.contains("Refactor C"), "missing item 3; got: {plain}");
}

/// AC4 (story 1201): when no chat rooms are registered, new-item creation
/// notifications are silently dropped rather than causing a panic or error —
/// mirroring the failure-isolation guarantee at the create-call boundary.
#[tokio::test]
async fn new_item_created_with_no_rooms_is_silent() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());

    watcher_tx
        .send(WatcherEvent::NewItemCreated {
            item_id: "42_story_no_rooms".to_string(),
            item_type: "story".to_string(),
            name: "No Rooms".to_string(),
            origin: "user alice".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(400)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 0, "No rooms means no notifications");
}

// ── spawn_notification_listener: MergeFailure ────────────────────────────────

/// Long gate output is truncated to the tail and includes the marker line.
#[tokio::test]
async fn merge_failure_long_output_is_truncated_to_tail() {
    let tmp = tempfile::tempdir().unwrap();
    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    // Build a reason with 50 lines (more than MERGE_FAILURE_TAIL_LINES = 30).
    let long_reason: String = (1..=50).map(|i| format!("gate-line-{i}\n")).collect();

    watcher_tx
        .send(WatcherEvent::MergeFailure {
            story_id: "1077_story_trunc".to_string(),
            reason: long_reason,
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (_, plain, _) = &calls[0];
    assert!(
        plain.contains("truncated"),
        "notification must contain the truncation marker; got: {plain}"
    );
    assert!(
        plain.contains("gate-line-50"),
        "notification must contain the last line; got: {plain}"
    );
    assert!(
        !plain.contains("gate-line-1\n"),
        "notification must not contain the first (dropped) line; got: {plain}"
    );
}

/// Short gate output (within limit) passes through unchanged, no marker added.
#[tokio::test]
async fn merge_failure_short_output_passes_through_unchanged() {
    let tmp = tempfile::tempdir().unwrap();
    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    let short_reason = "error: type mismatch on line 42\nexpected i32, found &str".to_string();

    watcher_tx
        .send(WatcherEvent::MergeFailure {
            story_id: "1077_story_short".to_string(),
            reason: short_reason.clone(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (_, plain, _) = &calls[0];
    assert!(
        !plain.contains("truncated"),
        "short output must not have a truncation marker; got: {plain}"
    );
    assert!(
        plain.contains("type mismatch"),
        "short output must be included verbatim; got: {plain}"
    );
}

// ── spawn_notification_listener: RateLimitWarning ────────────────────────────

/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
/// with a notification that names the agent and story.
#[tokio::test]
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
    let tmp = tempfile::tempdir().unwrap();
    // Seed story via CRDT (the only source of truth).
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "365_story_rate_limit",
        "2_current",
        "---\nname: Rate Limit Test Story\n---\n",
        crate::db::ItemMeta::named("Rate Limit Test Story"),
    );

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room123:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "365_story_rate_limit".to_string(),
            agent_name: "coder-1".to_string(),
        })
        .unwrap();

    // Give the spawned task time to process the event.
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (room_id, plain, _html) = &calls[0];
    assert_eq!(room_id, "!room123:example.org");
    assert!(plain.contains("365"), "plain should contain story number");
    assert!(
        plain.contains("Rate Limit Test Story"),
        "plain should contain story name"
    );
    assert!(plain.contains("coder-1"), "plain should contain agent name");
    assert!(
        plain.contains("rate limit"),
        "plain should mention rate limit"
    );
}

/// AC4: a second RateLimitWarning for the same agent within the debounce
/// window must NOT trigger a second notification.
#[tokio::test]
async fn rate_limit_warning_is_debounced() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    // Send the same warning twice in rapid succession.
    for _ in 0..2 {
        watcher_tx
            .send(WatcherEvent::RateLimitWarning {
                story_id: "42_story_debounce".to_string(),
                agent_name: "coder-2".to_string(),
            })
            .unwrap();
    }

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(
        calls.len(),
        1,
        "Debounce should suppress the second notification"
    );
}

/// AC4 (corollary): warnings for different agents are NOT debounced against
/// each other — both should produce notifications.
#[tokio::test]
async fn rate_limit_warnings_for_different_agents_both_notify() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "42_story_foo".to_string(),
            agent_name: "coder-1".to_string(),
        })
        .unwrap();
    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "42_story_foo".to_string(),
            agent_name: "coder-2".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(
        calls.len(),
        2,
        "Different agents should each trigger a notification"
    );
}

// ── spawn_notification_listener: StoryBlocked ────────────────────────────────

/// AC1: when a StoryBlocked event arrives, send_message is called with a
/// notification that includes the story number, name, and reason.
#[tokio::test]
async fn story_blocked_sends_notification_with_reason() {
    let tmp = tempfile::tempdir().unwrap();
    // Seed story via CRDT (the only source of truth).
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "425_story_blocking_test",
        "2_current",
        "---\nname: Blocking Test Story\n---\n",
        crate::db::ItemMeta::named("Blocking Test Story"),
    );

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room123:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::StoryBlocked {
            story_id: "425_story_blocking_test".to_string(),
            reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (room_id, plain, html) = &calls[0];
    assert_eq!(room_id, "!room123:example.org");
    assert!(plain.contains("425"), "plain should contain story number");
    assert!(
        plain.contains("Blocking Test Story"),
        "plain should contain story name"
    );
    assert!(
        plain.contains("BLOCKED"),
        "plain should contain BLOCKED label"
    );
    assert!(
        plain.contains("Retry limit exceeded"),
        "plain should contain the reason"
    );
    assert!(
        html.contains("BLOCKED"),
        "html should contain BLOCKED label"
    );
}

/// StoryBlocked with no room registered should not panic.
#[tokio::test]
async fn story_blocked_with_no_rooms_is_silent() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());

    watcher_tx
        .send(WatcherEvent::StoryBlocked {
            story_id: "42_story_no_rooms".to_string(),
            reason: "empty diff".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 0, "No rooms means no notifications");
}

// ── rate_limit_notifications config flag ─────────────────────────────────────

/// AC1+AC2: when rate_limit_notifications = false in project.toml,
/// RateLimitWarning events are suppressed (no send_message call).
#[tokio::test]
async fn rate_limit_warning_suppressed_when_config_false() {
    let tmp = tempfile::tempdir().unwrap();
    let sk_dir = tmp.path().join(".huskies");
    std::fs::create_dir_all(&sk_dir).unwrap();
    std::fs::write(
        sk_dir.join("project.toml"),
        "rate_limit_notifications = false\n",
    )
    .unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "42_story_suppress".to_string(),
            agent_name: "coder-1".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(
        calls.len(),
        0,
        "RateLimitWarning should be suppressed when rate_limit_notifications = false"
    );
}

/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
#[tokio::test]
async fn rate_limit_hard_block_never_sent_to_matrix() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
    watcher_tx
        .send(WatcherEvent::RateLimitHardBlock {
            story_id: "42_story_hard_block".to_string(),
            agent_name: "coder-1".to_string(),
            reset_at,
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 0, "RateLimitHardBlock must not post to Matrix");
}

/// AC3: StoryBlocked is always sent regardless of rate_limit_notifications.
#[tokio::test]
async fn story_blocked_always_sent_when_config_false() {
    let tmp = tempfile::tempdir().unwrap();
    let sk_dir = tmp.path().join(".huskies");
    std::fs::create_dir_all(&sk_dir).unwrap();
    std::fs::write(
        sk_dir.join("project.toml"),
        "rate_limit_notifications = false\n",
    )
    .unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::StoryBlocked {
            story_id: "42_story_blocked".to_string(),
            reason: "retry limit exceeded".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "StoryBlocked should always be sent");
}

/// AC5: Config is hot-reloaded — disabling rate_limit_notifications after
/// startup suppresses subsequent RateLimitWarning events.
#[tokio::test]
async fn rate_limit_warning_suppressed_after_hot_reload() {
    let tmp = tempfile::tempdir().unwrap();
    let sk_dir = tmp.path().join(".huskies");
    std::fs::create_dir_all(&sk_dir).unwrap();
    // Start with notifications enabled.
    std::fs::write(
        sk_dir.join("project.toml"),
        "rate_limit_notifications = true\n",
    )
    .unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    // First warning is sent.
    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "42_story_reload".to_string(),
            agent_name: "coder-1".to_string(),
        })
        .unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    // Disable notifications and trigger hot-reload.
    std::fs::write(
        sk_dir.join("project.toml"),
        "rate_limit_notifications = false\n",
    )
    .unwrap();
    watcher_tx.send(WatcherEvent::ConfigChanged).unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    // Second warning (different agent to bypass debounce) should be suppressed.
    watcher_tx
        .send(WatcherEvent::RateLimitWarning {
            story_id: "42_story_reload".to_string(),
            agent_name: "coder-2".to_string(),
        })
        .unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(
        calls.len(),
        1,
        "Only the first warning should be sent; second should be suppressed after hot-reload"
    );
}

// ── OAuthAccountSwapped / OAuthAccountsExhausted ─────────────────────────────

/// AC1: OAuthAccountSwapped fires a notification naming the new account.
#[tokio::test]
async fn oauth_account_swapped_sends_notification_with_new_email() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::OAuthAccountSwapped {
            new_email: "alice@example.com".to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (room_id, plain, _html) = &calls[0];
    assert_eq!(room_id, "!room1:example.org");
    assert!(
        plain.contains("alice@example.com"),
        "notification should name the new account; got: {plain}"
    );
}

/// AC2: OAuthAccountsExhausted fires a notification with the reset message.
#[tokio::test]
async fn oauth_accounts_exhausted_sends_notification_with_reset_msg() {
    let tmp = tempfile::tempdir().unwrap();

    let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
    let (transport, calls) = MockTransport::new();

    spawn_notification_listener(
        transport,
        || vec!["!room1:example.org".to_string()],
        watcher_rx,
        tmp.path().to_path_buf(),
    );

    watcher_tx
        .send(WatcherEvent::OAuthAccountsExhausted {
            earliest_reset_msg: "All OAuth accounts are rate-limited; earliest reset in 3h"
                .to_string(),
        })
        .unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    let calls = calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "Expected exactly one notification");
    let (room_id, plain, _html) = &calls[0];
    assert_eq!(room_id, "!room1:example.org");
    assert!(
        plain.contains("rate-limited"),
        "notification should contain reset message; got: {plain}"
    );
}
