use std::collections::HashMap;
use std::path::Path;

use crate::agent_context::incident_detector;
use crate::{ai, decisions, skills};

pub(crate) const DECISION_COOLDOWN_SECS: i64 = 3600;
/// Notification cooldown: suppress duplicate Telegram/Slack/webhook alerts for the
/// same detector+entity within this window. Prevents alert spam when the same attacker
/// triggers multiple incidents in rapid succession.
pub(crate) const NOTIFICATION_COOLDOWN_SECS: i64 = 600;

/// Storage retention for decision-cooldown rows (must be >= the LONGEST
/// cooldown semantic that consults `state.store.get_cooldown(Decision, ...)`).
///
/// The slow loop trims cooldown rows older than this — anything past the
/// horizon is gone, so consumers that check `is_some_and(|ts| ts > cutoff)`
/// see `None` even when the IP hit the gate well within its semantic
/// window. Operator's prod 2026-05-08 had `repeat-offender:136.216.0.0/16`
/// firing every 2h instead of every 24h because the trim was hard-coded
/// to 2h while `check_repeat_offenders` uses a 24h cooldown_cutoff. The
/// shorter retention silently defeated the longer cooldown.
///
/// Current decision-cooldown consumers and their durations:
///   - correlation chain (correlation_response.rs:117): 600s
///   - DECISION_COOLDOWN_SECS used by evaluate_pre_ai_flow:    3600s
///   - check_multi_technique (correlation_response.rs:511):    3600s
///   - check_repeat_offenders (correlation_response.rs:341): **86400s** ← max
///
/// Bumping retention to 24h costs ~12x more cooldown rows but they're
/// 8 bytes each — bounded by IP cardinality, not problematic at any
/// practical scale.
pub(crate) const DECISION_COOLDOWN_RETENTION_SECS: i64 = 86400;

/// Storage retention for notification-cooldown rows. Notification cooldowns
/// only need to survive the longest notification window; 2h covers
/// NOTIFICATION_COOLDOWN_SECS (600s) with margin.
pub(crate) const NOTIFICATION_COOLDOWN_RETENTION_SECS: i64 = 7200;
/// Max block actions per minute - prevents false-positive cascades.
pub(crate) const MAX_BLOCKS_PER_MINUTE: usize = 20;
/// Default XDP blocklist TTL (24h) - retained as reference; adaptive TTL now per-IP.
#[allow(dead_code)]
pub(crate) const XDP_BLOCK_TTL_SECS: i64 = 86400;
/// AbuseIPDB reports are delayed by this many seconds to allow false-positive correction.
pub(crate) const ABUSEIPDB_REPORT_DELAY_SECS: i64 = 300;

/// Returns notification cooldown keys for an incident.
/// One key per entity (IP or user): `detector:entity_kind:entity_value`.
pub(crate) fn notification_cooldown_keys(
    incident: &innerwarden_core::incident::Incident,
) -> Vec<String> {
    let detector = incident_detector(&incident.incident_id);
    incident
        .entities
        .iter()
        .filter(|e| {
            matches!(
                e.r#type,
                innerwarden_core::entities::EntityType::Ip
                    | innerwarden_core::entities::EntityType::User
            )
        })
        .map(|e| {
            let kind = match e.r#type {
                innerwarden_core::entities::EntityType::Ip => "ip",
                innerwarden_core::entities::EntityType::User => "user",
                _ => "other",
            };
            format!("{detector}:{kind}:{}", e.value)
        })
        .collect()
}

fn decision_cooldown_key(action: &str, detector: &str, entity_kind: &str, entity: &str) -> String {
    format!("{action}:{detector}:{entity_kind}:{entity}")
}

pub(crate) fn decision_cooldown_candidates(
    incident: &innerwarden_core::incident::Incident,
) -> Vec<String> {
    let detector = incident_detector(&incident.incident_id);
    let mut keys = Vec::new();

    for entity in &incident.entities {
        match entity.r#type {
            innerwarden_core::entities::EntityType::Ip => {
                keys.push(decision_cooldown_key(
                    "block_ip",
                    detector,
                    "ip",
                    &entity.value,
                ));
                keys.push(decision_cooldown_key(
                    "monitor",
                    detector,
                    "ip",
                    &entity.value,
                ));
                keys.push(decision_cooldown_key(
                    "honeypot",
                    detector,
                    "ip",
                    &entity.value,
                ));
            }
            innerwarden_core::entities::EntityType::User => {
                keys.push(decision_cooldown_key(
                    "suspend_user_sudo",
                    detector,
                    "user",
                    &entity.value,
                ));
            }
            _ => {}
        }
    }

    keys
}

pub(crate) fn decision_cooldown_key_for_decision(
    incident: &innerwarden_core::incident::Incident,
    decision: &ai::AiDecision,
) -> Option<String> {
    let detector = incident_detector(&incident.incident_id);
    match &decision.action {
        ai::AiAction::BlockIp { ip, .. } => {
            Some(decision_cooldown_key("block_ip", detector, "ip", ip))
        }
        ai::AiAction::Monitor { ip } => Some(decision_cooldown_key("monitor", detector, "ip", ip)),
        ai::AiAction::Honeypot { ip } => {
            Some(decision_cooldown_key("honeypot", detector, "ip", ip))
        }
        ai::AiAction::SuspendUserSudo { user, .. } => Some(decision_cooldown_key(
            "suspend_user_sudo",
            detector,
            "user",
            user,
        )),
        ai::AiAction::KillProcess { user, .. } => Some(decision_cooldown_key(
            "kill_process",
            detector,
            "user",
            user,
        )),
        ai::AiAction::BlockContainer { container_id, .. } => Some(decision_cooldown_key(
            "block_container",
            detector,
            "container",
            container_id,
        )),
        ai::AiAction::KillChainResponse { .. } => Some(decision_cooldown_key(
            "kill_chain_response",
            detector,
            "pid",
            "-",
        )),
        ai::AiAction::Ignore { .. }
        | ai::AiAction::Dismiss { .. }
        | ai::AiAction::RequestConfirmation { .. } => None,
    }
}

pub(crate) fn decision_cooldown_key_from_entry(entry: &decisions::DecisionEntry) -> Option<String> {
    let detector = incident_detector(&entry.incident_id);
    match entry.action_type.as_str() {
        "block_ip" | "monitor" | "honeypot" => entry
            .target_ip
            .as_ref()
            .map(|ip| decision_cooldown_key(&entry.action_type, detector, "ip", ip)),
        "suspend_user_sudo" => entry
            .target_user
            .as_ref()
            .map(|user| decision_cooldown_key("suspend_user_sudo", detector, "user", user)),
        _ => None,
    }
}

pub(crate) fn recent_decision_dates() -> Vec<String> {
    let today = chrono::Local::now().date_naive();
    let mut dates = vec![today.format("%Y-%m-%d").to_string()];
    if let Some(prev) = today.pred_opt() {
        dates.push(prev.format("%Y-%m-%d").to_string());
    }
    dates
}

pub(crate) fn load_startup_decision_state(
    data_dir: &Path,
    preload_blocklist_from_system: bool,
) -> (
    skills::Blocklist,
    HashMap<String, chrono::DateTime<chrono::Utc>>,
) {
    let mut blocklist = skills::Blocklist::default();
    let mut cooldowns: HashMap<String, chrono::DateTime<chrono::Utc>> = HashMap::new();

    if preload_blocklist_from_system {
        // Caller is responsible for awaiting the async ufw load and inserting later.
    }

    // Phase 7: Try loading from dated graph snapshots (today + yesterday).
    let dates = recent_decision_dates();
    let mut loaded_from_graph = false;
    for date in &dates {
        if let Some(graph) = crate::knowledge_graph::KnowledgeGraph::load_dated(data_dir, date) {
            use crate::knowledge_graph::types::{Node, NodeType};
            for id in graph.nodes_of_type(NodeType::Incident) {
                if let Some(Node::Incident {
                    incident_id,
                    decision: Some(action),
                    decision_target,
                    ts,
                    ..
                }) = graph.get_node(id)
                {
                    if action == "block_ip" {
                        if let Some(ip) = decision_target {
                            blocklist.insert(ip.clone());
                        }
                    }
                    // Build cooldown key from graph data
                    let detector = crate::agent_context::incident_detector(incident_id);
                    let key = match action.as_str() {
                        "block_ip" | "monitor" | "honeypot" => decision_target
                            .as_ref()
                            .map(|ip| decision_cooldown_key(action, detector, "ip", ip)),
                        "suspend_user_sudo" => decision_target.as_ref().map(|user| {
                            decision_cooldown_key("suspend_user_sudo", detector, "user", user)
                        }),
                        _ => None,
                    };
                    if let Some(key) = key {
                        cooldowns
                            .entry(key)
                            .and_modify(|existing| {
                                if *ts > *existing {
                                    *existing = *ts;
                                }
                            })
                            .or_insert(*ts);
                    }
                }
            }
            loaded_from_graph = true;
        }
    }

    if loaded_from_graph {
        tracing::info!(
            blocklist = blocklist.len(),
            cooldowns = cooldowns.len(),
            "startup decision state loaded from graph snapshots"
        );
        return (blocklist, cooldowns);
    }

    // Fallback: read from decisions JSONL (legacy or if no snapshots yet).
    const MAX_DECISION_READ: u64 = 512 * 1024;
    for date in &dates {
        let decisions_path = data_dir.join(format!("decisions-{date}.jsonl"));
        let file_size = std::fs::metadata(&decisions_path)
            .map(|m| m.len())
            .unwrap_or(0);
        let content = if file_size > MAX_DECISION_READ {
            let Ok(full) = std::fs::read(&decisions_path) else {
                continue;
            };
            let start = full.len().saturating_sub(MAX_DECISION_READ as usize);
            String::from_utf8_lossy(&full[start..]).to_string()
        } else {
            let Ok(c) = std::fs::read_to_string(&decisions_path) else {
                continue;
            };
            c
        };
        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let Ok(entry) = serde_json::from_str::<decisions::DecisionEntry>(line) else {
                continue;
            };
            if entry.action_type == "block_ip" {
                if let Some(ip) = &entry.target_ip {
                    blocklist.insert(ip.clone());
                }
            }
            if let Some(key) = decision_cooldown_key_from_entry(&entry) {
                cooldowns
                    .entry(key)
                    .and_modify(|existing| {
                        if entry.ts > *existing {
                            *existing = entry.ts;
                        }
                    })
                    .or_insert(entry.ts);
            }
        }
    }

    (blocklist, cooldowns)
}

pub(crate) fn load_last_narrative_instant(data_dir: &Path) -> Option<std::time::Instant> {
    let today = chrono::Local::now()
        .date_naive()
        .format("%Y-%m-%d")
        .to_string();
    let path = data_dir.join(format!("summary-{today}.md"));
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    let elapsed = modified.elapsed().ok()?;
    std::time::Instant::now().checked_sub(elapsed)
}

#[allow(dead_code)]
pub(crate) fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ai::{AiAction, AiDecision};
    use crate::decisions::DecisionEntry;
    use innerwarden_core::entities::EntityRef;
    use innerwarden_core::event::Severity;
    use innerwarden_core::incident::Incident;

    fn mock_incident(incident_id: &str, entities: Vec<EntityRef>) -> Incident {
        Incident {
            ts: chrono::Utc::now(),
            host: "test-host".to_string(),
            incident_id: incident_id.to_string(),
            severity: Severity::High,
            title: "Test Incident".to_string(),
            summary: "".to_string(),
            evidence: serde_json::json!({}),
            recommended_checks: vec![],
            tags: vec![],
            entities,
        }
    }

    #[test]
    fn test_notification_cooldown_keys() {
        let inc = mock_incident(
            "sshd:bruteforce:123",
            vec![
                EntityRef::ip("1.2.3.4"),
                EntityRef::user("root"),
                EntityRef::container("c1"),
            ],
        );
        let keys = notification_cooldown_keys(&inc);
        assert_eq!(keys.len(), 2);
        assert!(keys.contains(&"sshd:ip:1.2.3.4".to_string()));
        assert!(keys.contains(&"sshd:user:root".to_string()));
    }

    #[test]
    fn test_decision_cooldown_candidates() {
        let inc = mock_incident(
            "pam:sudo_fail:456",
            vec![EntityRef::ip("5.6.7.8"), EntityRef::user("admin")],
        );
        let keys = decision_cooldown_candidates(&inc);
        assert_eq!(keys.len(), 4);
        assert!(keys.contains(&"block_ip:pam:ip:5.6.7.8".to_string()));
        assert!(keys.contains(&"monitor:pam:ip:5.6.7.8".to_string()));
        assert!(keys.contains(&"honeypot:pam:ip:5.6.7.8".to_string()));
        assert!(keys.contains(&"suspend_user_sudo:pam:user:admin".to_string()));
    }

    #[test]
    fn test_decision_cooldown_key_for_decision() {
        let inc = mock_incident("kernel:oops:789", vec![]);
        let decision_block = AiDecision {
            action: AiAction::BlockIp {
                ip: "9.9.9.9".to_string(),
                skill_id: "block-ip-xdp".to_string(),
            },
            confidence: 0.9,
            reason: "test".to_string(),
            auto_execute: true,
            estimated_threat: "high".to_string(),
            alternatives: vec![],
        };
        let key = decision_cooldown_key_for_decision(&inc, &decision_block);
        assert_eq!(key, Some("block_ip:kernel:ip:9.9.9.9".to_string()));

        let decision_monitor = AiDecision {
            action: AiAction::Monitor {
                ip: "9.9.9.9".to_string(),
            },
            confidence: 0.5,
            reason: "test".to_string(),
            auto_execute: true,
            estimated_threat: "medium".to_string(),
            alternatives: vec![],
        };
        let key2 = decision_cooldown_key_for_decision(&inc, &decision_monitor);
        assert_eq!(key2, Some("monitor:kernel:ip:9.9.9.9".to_string()));

        let decision_ignore = AiDecision {
            action: AiAction::Ignore {
                reason: "benign".to_string(),
            },
            confidence: 1.0,
            reason: "test".to_string(),
            auto_execute: false,
            estimated_threat: "low".to_string(),
            alternatives: vec![],
        };
        assert_eq!(
            decision_cooldown_key_for_decision(&inc, &decision_ignore),
            None
        );
    }

    // ─── Spec 024 contract tests ───────────────────────────────────────
    //
    // The decision_cooldown module is the join key generator for every
    // cooldown-gated branch in the agent. Its contract is threefold:
    //
    //   1. Key format is stable: `{action}:{detector}:{entity_kind}:{entity}`.
    //      Any rename here invalidates every persisted cooldown on disk,
    //      silently turning cooldowns into misses and re-opening the
    //      dedup-broken re-block loop that triggered spec 015.
    //
    //   2. Pure function: `decision_cooldown_key_for_decision` is pure in
    //      its inputs. It MUST NOT read or mutate the caller's AgentState.
    //
    //   3. No-action decisions return `None`. `Ignore` and
    //      `RequestConfirmation` must not allocate cooldown keys —
    //      otherwise benign decisions are locked out for DECISION_COOLDOWN_SECS.

    #[test]
    fn contract_key_format_is_canonical() {
        // Directly probe the private helper via the public key_for_decision
        // to pin the format.
        let inc = mock_incident("ssh_bruteforce:1", vec![EntityRef::ip("198.51.100.10")]);
        let dec = AiDecision {
            action: AiAction::BlockIp {
                ip: "198.51.100.10".to_string(),
                skill_id: "block-ip-ufw".to_string(),
            },
            confidence: 0.9,
            reason: "test".to_string(),
            auto_execute: true,
            estimated_threat: "high".to_string(),
            alternatives: vec![],
        };
        let key = decision_cooldown_key_for_decision(&inc, &dec).unwrap();
        assert_eq!(
            key, "block_ip:ssh_bruteforce:ip:198.51.100.10",
            "cooldown key format is part of the disk ABI — see spec 024 contract"
        );
    }

    #[test]
    fn contract_ignore_actions_return_no_key() {
        let inc = mock_incident("port_scan:1", vec![EntityRef::ip("1.1.1.1")]);
        let dec = AiDecision {
            action: AiAction::Ignore {
                reason: "fp".into(),
            },
            confidence: 1.0,
            reason: "".into(),
            auto_execute: false,
            estimated_threat: "low".into(),
            alternatives: vec![],
        };
        assert_eq!(decision_cooldown_key_for_decision(&inc, &dec), None);
    }

    #[test]
    fn contract_request_confirmation_returns_no_key() {
        // Operator pending actions are NOT cooldown-keyed — they have their
        // own TTL. Confirming this stops the "pending confirmation locks out
        // real decisions for an hour" class of bug.
        let inc = mock_incident("any:1", vec![]);
        let dec = AiDecision {
            action: AiAction::RequestConfirmation {
                summary: "x".into(),
            },
            confidence: 0.5,
            reason: "".into(),
            auto_execute: false,
            estimated_threat: "medium".into(),
            alternatives: vec![],
        };
        assert_eq!(decision_cooldown_key_for_decision(&inc, &dec), None);
    }

    #[test]
    fn contract_notification_keys_include_every_eligible_entity() {
        // Notification cooldown keys are generated per-entity so two
        // attackers from different IPs on the same detector do not shadow
        // each other's alerts. Verify the invariant.
        let inc = mock_incident(
            "ssh_bruteforce:1",
            vec![
                EntityRef::ip("1.1.1.1"),
                EntityRef::ip("2.2.2.2"),
                EntityRef::user("attacker"),
            ],
        );
        let keys = notification_cooldown_keys(&inc);
        assert_eq!(keys.len(), 3, "one key per eligible entity, no dedup");
        assert!(keys.contains(&"ssh_bruteforce:ip:1.1.1.1".to_string()));
        assert!(keys.contains(&"ssh_bruteforce:ip:2.2.2.2".to_string()));
        assert!(keys.contains(&"ssh_bruteforce:user:attacker".to_string()));
    }

    #[test]
    fn test_decision_cooldown_key_from_entry() {
        let entry = DecisionEntry {
            ts: chrono::Utc::now(),
            incident_id: "nginx:404:000".to_string(),
            host: "test".to_string(),
            ai_provider: "test".to_string(),
            target_ip: Some("10.0.0.1".to_string()),
            action_type: "block_ip".to_string(),
            target_user: None,
            skill_id: None,
            confidence: 0.9,
            auto_executed: true,
            dry_run: false,
            execution_result: "success".to_string(),
            reason: "test".to_string(),
            estimated_threat: "high".to_string(),
            prev_hash: None,
            decision_layer: None,
        };
        let key = decision_cooldown_key_from_entry(&entry);
        assert_eq!(key, Some("block_ip:nginx:ip:10.0.0.1".to_string()));
    }

    /// 2026-05-08 anchor (fix/cooldown-retention-matches-longest-semantic):
    /// `DECISION_COOLDOWN_RETENTION_SECS` must be at least as long as the
    /// LONGEST cooldown_cutoff used by any decision-cooldown consumer.
    /// Today the longest is `check_repeat_offenders` at 86400s (24h).
    /// Pre-fix the slow-loop trim hard-coded 2h, which silently nuked
    /// repeat-offender's 24h cooldown rows after only 2h — operator's
    /// prod 2026-05-08 had `136.216.0.0/16` re-firing every 2h instead
    /// of every 24h because of this mismatch. If a future patch adds a
    /// 7-day decision cooldown without updating this constant, this
    /// test fires and the patch author has to choose: bump retention
    /// or accept the silent-defeat regression.
    #[test]
    fn decision_cooldown_retention_covers_longest_consumer() {
        const REPEAT_OFFENDER_COOLDOWN_SECS: i64 = 86400;
        assert!(
            DECISION_COOLDOWN_RETENTION_SECS >= REPEAT_OFFENDER_COOLDOWN_SECS,
            "DECISION_COOLDOWN_RETENTION_SECS ({}) must be >= the longest \
             cooldown_cutoff in any decision-cooldown consumer ({}s for \
             repeat-offender). Pre-fix prod regression: /16 firing every \
             2h instead of 24h because retention < cooldown_cutoff.",
            DECISION_COOLDOWN_RETENTION_SECS,
            REPEAT_OFFENDER_COOLDOWN_SECS
        );
        // Also pin that NOTIFICATION retention covers its own consumer.
        assert!(
            NOTIFICATION_COOLDOWN_RETENTION_SECS >= NOTIFICATION_COOLDOWN_SECS,
            "NOTIFICATION_COOLDOWN_RETENTION_SECS ({}) must be >= \
             NOTIFICATION_COOLDOWN_SECS ({})",
            NOTIFICATION_COOLDOWN_RETENTION_SECS,
            NOTIFICATION_COOLDOWN_SECS
        );
    }
}
