use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;

const MAX_EVENTS_PER_PROJECT: usize = 500;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleEventKind {
    SessionStart,
    UserTurn,
    AssistantTurn,
    ToolActivity,
    PreCompact,
    IdleFlush,
    SessionStop,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEvent {
    pub timestamp: DateTime<Utc>,
    pub session_id: String,
    pub source: String,
    pub kind: LifecycleEventKind,
    pub text: String,
    pub tool: Option<String>,
    pub command: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainFactCandidate {
    pub key: String,
    pub value: String,
    pub kind: String,
    pub category: String,
    pub source_type: String,
    pub source_scope: String,
    pub promotion_identity: String,
    pub logical_key: String,
    pub confidence: f32,
    pub evidence: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DurableMemoryCandidate {
    pub category: String,
    pub key: String,
    pub value: String,
    pub confidence: f32,
    pub source_type: String,
    pub source_scope: String,
    pub promotion_identity: String,
    pub logical_key: String,
    pub evidence: String,
}

#[derive(Debug, Default, Clone)]
pub struct FlushOutcome {
    pub journal_events: usize,
    pub derived_facts: usize,
}

pub fn append_event(project_root: &str, mut event: JournalEvent) -> Result<(), String> {
    if project_root.trim().is_empty() {
        return Ok(());
    }

    if event.timestamp == DateTime::<Utc>::default() {
        event.timestamp = Utc::now();
    }

    let mut events = load_events(project_root)?;
    events.push(event);
    if events.len() > MAX_EVENTS_PER_PROJECT {
        events.drain(0..events.len() - MAX_EVENTS_PER_PROJECT);
    }
    save_events(project_root, &events)
}

pub fn record_user_turn(
    project_root: &str,
    session_id: Option<&str>,
    source: &str,
    text: &str,
) -> Result<(), String> {
    append_event(
        project_root,
        JournalEvent {
            timestamp: Utc::now(),
            session_id: session_id.unwrap_or("sessionless").to_string(),
            source: source.to_string(),
            kind: LifecycleEventKind::UserTurn,
            text: truncate_text(text, 4000),
            tool: None,
            command: None,
        },
    )
}

pub fn record_assistant_turn(
    project_root: &str,
    session_id: Option<&str>,
    source: &str,
    text: &str,
) -> Result<(), String> {
    append_event(
        project_root,
        JournalEvent {
            timestamp: Utc::now(),
            session_id: session_id.unwrap_or("sessionless").to_string(),
            source: source.to_string(),
            kind: LifecycleEventKind::AssistantTurn,
            text: truncate_text(text, 4000),
            tool: None,
            command: None,
        },
    )
}

pub fn record_tool_activity(
    project_root: &str,
    session_id: Option<&str>,
    source: &str,
    tool: &str,
    command: Option<&str>,
    output_preview: Option<&str>,
) -> Result<(), String> {
    let mut text = String::new();
    if let Some(preview) = output_preview {
        text = truncate_text(preview, 600);
    }

    append_event(
        project_root,
        JournalEvent {
            timestamp: Utc::now(),
            session_id: session_id.unwrap_or("sessionless").to_string(),
            source: source.to_string(),
            kind: LifecycleEventKind::ToolActivity,
            text,
            tool: Some(tool.to_string()),
            command: command.map(|value| truncate_text(value, 400)),
        },
    )
}

pub fn record_lifecycle_marker(
    project_root: &str,
    session_id: Option<&str>,
    source: &str,
    kind: LifecycleEventKind,
    text: &str,
) -> Result<(), String> {
    append_event(
        project_root,
        JournalEvent {
            timestamp: Utc::now(),
            session_id: session_id.unwrap_or("sessionless").to_string(),
            source: source.to_string(),
            kind,
            text: truncate_text(text, 400),
            tool: None,
            command: None,
        },
    )
}

pub fn load_events(project_root: &str) -> Result<Vec<JournalEvent>, String> {
    let path = journal_file_path(project_root)?;
    if !path.exists() {
        return Ok(Vec::new());
    }

    let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
    let mut events = Vec::new();
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        if let Ok(event) = serde_json::from_str::<JournalEvent>(trimmed) {
            events.push(event);
        }
    }

    Ok(events)
}

pub fn flush_to_brain(project_root: &str, source_type: &str) -> Result<FlushOutcome, String> {
    let events = load_events(project_root)?;
    let facts = derive_fact_candidates(project_root, source_type, &events);
    if !facts.is_empty() {
        crate::server_client::post_brain_facts_to_server(project_root, &facts);
    }

    Ok(FlushOutcome {
        journal_events: events.len(),
        derived_facts: facts.len(),
    })
}

pub fn derive_durable_memory_candidates(
    project_root: &str,
    source_type: &str,
    events: &[JournalEvent],
) -> Vec<DurableMemoryCandidate> {
    let mut candidates = Vec::new();
    let mut seen = HashSet::new();
    let session = crate::core::session::SessionState::load_latest_for_project_root(project_root);
    let session_scope = session
        .as_ref()
        .map(|value| value.id.clone())
        .unwrap_or_else(|| "sessionless".to_string());

    if let Some(session) = session {
        for finding in session.findings.iter().rev().take(12) {
            if let Some(candidate) = infer_durable_candidate(
                source_type,
                &session_scope,
                &finding.summary,
                "session_finding",
            ) {
                push_memory_candidate(&mut candidates, &mut seen, candidate);
            }
        }

        for decision in session.decisions.iter().rev().take(8) {
            let combined = match &decision.rationale {
                Some(rationale) if !rationale.trim().is_empty() => format!("{} because {}", decision.summary, rationale),
                _ => decision.summary.clone(),
            };
            if let Some(candidate) = infer_durable_candidate(
                source_type,
                &session_scope,
                &combined,
                "session_decision",
            ) {
                push_memory_candidate(&mut candidates, &mut seen, candidate);
            }
        }
    }

    for event in events.iter().rev().take(40) {
        if event.text.is_empty() {
            continue;
        }
        if let Some(candidate) = infer_durable_candidate(source_type, &event.session_id, &event.text, event.source.as_str()) {
            push_memory_candidate(&mut candidates, &mut seen, candidate);
        }
    }

    candidates.truncate(5);
    candidates
}

pub fn derive_fact_candidates(
    project_root: &str,
    source_type: &str,
    events: &[JournalEvent],
) -> Vec<BrainFactCandidate> {
    let mut facts = Vec::new();
    let mut seen = HashSet::new();
    let session = crate::core::session::SessionState::load_latest_for_project_root(project_root);
    let session_scope = session
        .as_ref()
        .map(|value| value.id.clone())
        .unwrap_or_else(|| "sessionless".to_string());

    if let Some(session) = session {
        if let Some(task) = session.task {
            push_fact(
                &mut facts,
                &mut seen,
                build_fact_candidate(
                    source_type,
                    &session_scope,
                    "current-task",
                    &task.description,
                    "task",
                    "task",
                    0.95,
                    "derived from session task",
                ),
            );
        }

        for decision in session.decisions.iter().rev().take(5) {
            let key = format!("decision-{}", slug_key(&decision.summary, 48));
            push_fact(
                &mut facts,
                &mut seen,
                build_fact_candidate(
                    source_type,
                    &session_scope,
                    &key,
                    &decision.summary,
                    "decision",
                    "decision",
                    0.9,
                    "derived from session decision",
                ),
            );
        }

        for finding in session.findings.iter().rev().take(8) {
            let key = finding
                .file
                .as_ref()
                .map(|file| {
                    finding
                        .line
                        .map(|line| format!("finding-{}-{line}", slug_key(file, 36)))
                        .unwrap_or_else(|| format!("finding-{}", slug_key(file, 36)))
                })
                .unwrap_or_else(|| format!("finding-{}", slug_key(&finding.summary, 48)));
            push_fact(
                &mut facts,
                &mut seen,
                build_fact_candidate(
                    source_type,
                    &session_scope,
                    &key,
                    &finding.summary,
                    "fact",
                    "finding",
                    0.78,
                    "derived from session finding",
                ),
            );
        }
    }

    for event in events.iter().rev().take(40) {
        if event.text.is_empty() {
            continue;
        }

        for candidate in infer_facts_from_event(source_type, event) {
            push_fact(&mut facts, &mut seen, candidate);
        }
    }

    facts
}

fn infer_facts_from_event(source_type: &str, event: &JournalEvent) -> Vec<BrainFactCandidate> {
    let mut facts = Vec::new();
    let lowered = event.text.to_lowercase();

    if event.kind == LifecycleEventKind::UserTurn {
        if lowered.contains("opencode")
            && (lowered.contains("go to ide")
                || lowered.contains("primary ide")
                || lowered.contains("hoofd ide")
                || lowered.contains("nu onze")
                || lowered.contains("nu onze go to ide"))
        {
            facts.push(build_fact_candidate(
                source_type,
                &event.session_id,
                "primary-ide",
                "OpenCode",
                "preference",
                "workflow",
                0.98,
                "derived from user-stated primary IDE",
            ));
        }

        if lowered.contains("no shortcuts") || lowered.contains("geen shortcuts") {
            facts.push(build_fact_candidate(
                source_type,
                &event.session_id,
                "no-shortcuts",
                "No shortcuts; implement the full memory behavior.",
                "constraint",
                "quality",
                0.97,
                "derived from user quality constraint",
            ));
        }

        if lowered.contains("brain")
            && (lowered.contains("echte feiten")
                || lowered.contains("fact-only")
                || lowered.contains("geen log")
                || lowered.contains("not log")
                || lowered.contains("journal"))
        {
            facts.push(build_fact_candidate(
                source_type,
                &event.session_id,
                "brain-fact-only",
                "Brain stores derived facts only; raw journal remains local on the client.",
                "constraint",
                "memory",
                0.99,
                "derived from user memory-model constraint",
            ));
        }
    }

    facts
}

fn push_fact(
    facts: &mut Vec<BrainFactCandidate>,
    seen: &mut HashSet<String>,
    candidate: BrainFactCandidate,
) {
    if seen.insert(candidate.promotion_identity.clone()) {
        facts.push(candidate);
    }
}

fn push_memory_candidate(
    facts: &mut Vec<DurableMemoryCandidate>,
    seen: &mut HashSet<String>,
    candidate: DurableMemoryCandidate,
) {
    if seen.insert(candidate.promotion_identity.clone()) {
        facts.push(candidate);
    }
}

fn infer_durable_candidate(
    source_type: &str,
    source_scope: &str,
    text: &str,
    evidence_source: &str,
) -> Option<DurableMemoryCandidate> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }

    let lowered = trimmed.to_lowercase();
    let (category, confidence) = if lowered.contains("root cause")
        || lowered.contains("caused by")
        || lowered.contains("not bad")
        || lowered.contains("mismatch was caused")
    {
        ("root_cause", 0.95f32)
    } else if lowered.contains("live verified")
        || lowered.contains("known-good")
        || lowered.contains("live behavior")
    {
        ("live_verification", 0.92f32)
    } else if lowered.contains("confirmed")
        || lowered.contains("verified")
    {
        ("verified_behavior", 0.88f32)
    } else if lowered.contains("runtime behaves differently")
        || lowered.contains("persisted config overrides")
        || lowered.contains("override manifest defaults")
        || lowered.contains("caveat")
    {
        ("runtime_caveat", 0.86f32)
    } else if lowered.contains("contract")
        || lowered.contains("external behavior")
        || lowered.contains("decision")
    {
        ("contract_decision", 0.82f32)
    } else if lowered.contains("because")
        || lowered.contains("faster")
        || lowered.contains("slower")
        || lowered.contains("one ha /states snapshot")
    {
        ("runtime_caveat", 0.8f32)
    } else {
        return None;
    };

    let key = slug_key(trimmed, 64);
    let logical_key = format!("{}:{}", normalize_token(category), key);
    Some(DurableMemoryCandidate {
        category: category.to_string(),
        key,
        value: truncate_text(trimmed, 400),
        confidence,
        source_type: source_type.to_string(),
        source_scope: source_scope.to_string(),
        promotion_identity: crate::server_client::deterministic_promotion_identity(source_type, source_scope, category, &logical_key),
        logical_key,
        evidence: format!("derived_from={evidence_source}"),
    })
}

fn build_fact_candidate(
    source_type: &str,
    source_scope: &str,
    key: &str,
    value: &str,
    kind: &str,
    category: &str,
    confidence: f32,
    evidence: &str,
) -> BrainFactCandidate {
    let logical_key = normalize_token(key);
    BrainFactCandidate {
        key: logical_key.clone(),
        value: value.trim().to_string(),
        kind: kind.to_string(),
        category: category.to_string(),
        source_type: source_type.to_string(),
        source_scope: source_scope.to_string(),
        promotion_identity: crate::server_client::deterministic_promotion_identity(
            source_type,
            source_scope,
            category,
            &logical_key,
        ),
        logical_key,
        confidence,
        evidence: evidence.to_string(),
    }
}

fn save_events(project_root: &str, events: &[JournalEvent]) -> Result<(), String> {
    let path = journal_file_path(project_root)?;
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
    }

    let mut serialized = String::new();
    for event in events {
        let line = serde_json::to_string(event).map_err(|e| e.to_string())?;
        serialized.push_str(&line);
        serialized.push('\n');
    }

    std::fs::write(path, serialized).map_err(|e| e.to_string())
}

fn journal_file_path(project_root: &str) -> Result<PathBuf, String> {
    let project_hash = crate::core::project_hash::hash_project_root(project_root);
    Ok(crate::core::data_dir::nebu_ctx_data_dir()?
        .join("journal")
        .join(format!("{project_hash}.jsonl")))
}

fn truncate_text(text: &str, limit: usize) -> String {
    text.chars().take(limit).collect()
}

fn normalize_token(value: &str) -> String {
    let lowered = value.trim().to_lowercase();
    let mut normalized = lowered
        .chars()
        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
        .collect::<String>();

    while normalized.contains("--") {
        normalized = normalized.replace("--", "-");
    }

    let trimmed = normalized.trim_matches('-');
    if trimmed.is_empty() {
        "unknown".to_string()
    } else {
        trimmed.to_string()
    }
}

fn slug_key(value: &str, limit: usize) -> String {
    let slug = normalize_token(value);
    if slug.len() <= limit {
        slug
    } else {
        slug[..limit].trim_matches('-').to_string()
    }
}

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

    #[test]
    fn journal_roundtrip_keeps_recent_events() {
        let _lock = crate::core::data_dir::test_env_lock();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("NEBU_CTX_DATA_DIR", tmp.path());
        let root = tmp.path().join("project");
        std::fs::create_dir_all(&root).unwrap();
        let root = root.to_string_lossy().to_string();

        record_user_turn(&root, Some("s1"), "test", "OpenCode is now our go to IDE").unwrap();
        let events = load_events(&root).unwrap();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].kind, LifecycleEventKind::UserTurn);
    }

    #[test]
    fn derive_facts_infers_primary_ide_and_brain_constraint() {
        let events = vec![JournalEvent {
            timestamp: Utc::now(),
            session_id: "s1".to_string(),
            source: "test".to_string(),
            kind: LifecycleEventKind::UserTurn,
            text: "OpenCode is now our go to IDE and brain should store echte feiten, geen log."
                .to_string(),
            tool: None,
            command: None,
        }];

        let facts = derive_fact_candidates("/tmp/project", "stop", &events);
        assert!(facts
            .iter()
            .any(|fact| fact.key == "primary-ide" && fact.value == "OpenCode"));
        assert!(facts.iter().any(|fact| fact.key == "brain-fact-only"));
    }

    #[test]
    fn journal_retention_discards_oldest_events_over_limit() {
        let _lock = crate::core::data_dir::test_env_lock();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("NEBU_CTX_DATA_DIR", tmp.path());
        let root = tmp.path().join("project");
        std::fs::create_dir_all(&root).unwrap();
        let root = root.to_string_lossy().to_string();

        for index in 0..(MAX_EVENTS_PER_PROJECT + 5) {
            record_user_turn(&root, Some("s1"), "test", &format!("event-{index}")).unwrap();
        }

        let events = load_events(&root).unwrap();
        assert_eq!(events.len(), MAX_EVENTS_PER_PROJECT);
        assert_eq!(
            events.first().map(|event| event.text.as_str()),
            Some("event-5")
        );
        assert_eq!(
            events.last().map(|event| event.text.as_str()),
            Some("event-504")
        );
    }

    #[test]
    fn derive_durable_memory_candidates_extracts_root_cause_and_verified_behavior() {
        let events = vec![
            JournalEvent {
                timestamp: Utc::now(),
                session_id: "s1".to_string(),
                source: "assistant".to_string(),
                kind: LifecycleEventKind::AssistantTurn,
                text: "Root cause: HA add-on visibility was caused by invalid schema entry modbus_entities: dict?".to_string(),
                tool: None,
                command: None,
            },
            JournalEvent {
                timestamp: Utc::now(),
                session_id: "s1".to_string(),
                source: "assistant".to_string(),
                kind: LifecycleEventKind::AssistantTurn,
                text: "Confirmed runtime behavior: persisted config overrides manifest defaults.".to_string(),
                tool: None,
                command: None,
            },
        ];

        let candidates = derive_durable_memory_candidates("/tmp/project", "idle_flush", &events);
        assert!(candidates.iter().any(|candidate| candidate.category == "root_cause"));
        assert!(candidates.iter().any(|candidate| candidate.category == "verified_behavior"));
    }
}
