//! Canonical `SessionEvent` schema — the single source of truth referenced
//! by foundation spec. Each variant carries base fields (`timestamp`,
//! optional `note`) plus variant-specific fields.
//!
//! `RejectReason` is the 5-value enum for rejected source attempts.
//!
//! The jsonl reader is **line-tolerant**: malformed lines and unknown event
//! values are skipped with stderr warnings (see `read_events`).

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::Path;

fn is_false(value: &bool) -> bool {
    !*value
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectReason {
    FetchFailed,
    WrongUrl,
    EmptyContent,
    ApiError,
    Duplicate,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
    Ok,
    Error,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactCheckOutcome {
    Supported,
    Refuted,
    Uncertain,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum SessionEvent {
    SessionCreated {
        timestamp: DateTime<Utc>,
        slug: String,
        topic: String,
        preset: String,
        session_dir_abs: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SourceAttempted {
        timestamp: DateTime<Utc>,
        url: String,
        route_decision: RouteDecision,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SourceAccepted {
        timestamp: DateTime<Utc>,
        url: String,
        kind: String,
        executor: String,
        raw_path: String,
        bytes: u64,
        trust_score: f64,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SourceRejected {
        timestamp: DateTime<Utc>,
        url: String,
        kind: String,
        executor: String,
        reason: RejectReason,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        observed_url: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        observed_bytes: Option<u64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        rejected_raw_path: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    ToolCallStarted {
        timestamp: DateTime<Utc>,
        call_id: String,
        hand: String,
        tool: String,
        input_summary: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    ToolCallCompleted {
        timestamp: DateTime<Utc>,
        call_id: String,
        status: ToolCallStatus,
        duration_ms: u64,
        output_summary: String,
        artifact_refs: Vec<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        error_code: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SynthesizeStarted {
        timestamp: DateTime<Utc>,
        #[serde(default, skip_serializing_if = "is_false")]
        no_render: bool,
        #[serde(default, skip_serializing_if = "is_false")]
        open: bool,
        #[serde(default, skip_serializing_if = "is_false")]
        bilingual: bool,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        bilingual_provider: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SynthesizeCompleted {
        timestamp: DateTime<Utc>,
        report_json_path: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        report_html_path: Option<String>,
        accepted_sources: u32,
        rejected_sources: u32,
        duration_ms: u64,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SynthesizeFailed {
        timestamp: DateTime<Utc>,
        stage: SynthesizeStage,
        reason: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SessionClosed {
        timestamp: DateTime<Utc>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SessionRemoved {
        timestamp: DateTime<Utc>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    SessionResumed {
        timestamp: DateTime<Utc>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    // ── Autoresearch loop events ─────────────────────────────────────────
    // These are written only when `research loop` is invoked (feature:
    // autoresearch), but live in the canonical SessionEvent enum so the
    // event log stays closed — readers match all variants exhaustively.
    LoopStarted {
        timestamp: DateTime<Utc>,
        provider: String,
        iterations: u32,
        max_actions: u32,
        dry_run: bool,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    LoopStep {
        timestamp: DateTime<Utc>,
        iteration: u32,
        reasoning: String,
        actions_requested: u32,
        actions_executed: u32,
        actions_rejected: u32,
        duration_ms: u64,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    LoopCompleted {
        timestamp: DateTime<Utc>,
        reason: String,
        iterations_run: u32,
        actions_executed_total: u32,
        report_ready: bool,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v2: a previously-fetched source has been digested into a specific
    /// section of session.md. Subsequent prompt builds filter these URLs
    /// out of the "unread sources" block so Claude doesn't re-summarize
    /// the same paper every iteration.
    SourceDigested {
        timestamp: DateTime<Utc>,
        iteration: u32,
        url: String,
        into_section: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
    FactChecked {
        timestamp: DateTime<Utc>,
        iteration: u32,
        claim: String,
        query: String,
        sources: Vec<String>,
        outcome: FactCheckOutcome,
        into_section: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v2: a `## Plan` block was authored by the agent (or overwritten).
    /// The body itself lives in `session.md` — this event records *that*
    /// and *when* a plan landed, plus its size for audit.
    PlanWritten {
        timestamp: DateTime<Utc>,
        iteration: u32,
        body_chars: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v2: an SVG passed `svg_safety::validate` and was written to
    /// `<session>/diagrams/<path>`.
    DiagramAuthored {
        timestamp: DateTime<Utc>,
        iteration: u32,
        path: String,
        bytes: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v2: an SVG failed `svg_safety::validate` or path safety — no file
    /// was written. `reason` carries the specific rejection (script tag,
    /// missing xmlns, oversize, path escape, etc.).
    DiagramRejected {
        timestamp: DateTime<Utc>,
        iteration: u32,
        path: String,
        reason: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v3: a wiki page was created, replaced, or appended to via
    /// `WriteWikiPage` / `AppendWikiPage`. `mode` is "create" | "replace"
    /// | "append". `body_chars` helps coverage judge page size without
    /// reading the file.
    WikiPageWritten {
        timestamp: DateTime<Utc>,
        iteration: u32,
        slug: String,
        mode: String,
        body_chars: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v3: the session `SCHEMA.md` was created or edited (by `research
    /// new` seed, `research schema edit`, or direct user edit detected
    /// via mtime change). Loop readers re-read SCHEMA.md on the next
    /// iteration; recording the write lets `research status` surface
    /// "schema touched since last loop step." `body_chars` gauges how
    /// much schema guidance is in play.
    SchemaUpdated {
        timestamp: DateTime<Utc>,
        body_chars: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v3: `research wiki query <question>` ran. `relevant_pages` is the
    /// slug list chosen by the retrieval step; `answer_slug` is set iff
    /// `--save-as <slug>` persisted the answer as a new wiki page. The
    /// event is output-only (it doesn't block coverage) and surfaces in
    /// `research status` as "queries asked."
    WikiQuery {
        timestamp: DateTime<Utc>,
        question: String,
        relevant_pages: Vec<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        answer_slug: Option<String>,
        answer_chars: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },

    /// v3: `research wiki lint` ran. Counts only — the full diagnostic
    /// payload lives in the CLI envelope (and optionally stdout), not
    /// in the event log. Non-blocker: lint never fails a `coverage`
    /// blocker, it's a health-check for humans.
    WikiLintRan {
        timestamp: DateTime<Utc>,
        issues: u32,
        orphans: u32,
        broken_links: u32,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        note: Option<String>,
    },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteDecision {
    pub executor: String,
    pub kind: String,
    pub command_template: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SynthesizeStage {
    Build,
    Render,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventLogDiagnostics {
    pub malformed_lines: usize,
    pub unknown_events: usize,
    pub parse_errors: usize,
}

#[derive(Debug, Clone, Default)]
pub struct EventLogRead {
    pub events: Vec<SessionEvent>,
    pub diagnostics: EventLogDiagnostics,
}

/// Read a session.jsonl file and return valid events. Malformed lines and
/// unknown event types are **skipped with a stderr warning**; I/O errors
/// against the file itself bubble up.
pub fn read_events(path: &Path) -> std::io::Result<Vec<SessionEvent>> {
    let f = std::fs::File::open(path)?;
    let mut events = Vec::new();
    let reader = BufReader::new(f);
    for (idx, line_res) in reader.lines().enumerate() {
        let line_no = idx + 1;
        let line = match line_res {
            Ok(l) => l,
            Err(e) => {
                eprintln!("⚠ session.jsonl line {line_no} read error: {e}, skipped");
                continue;
            }
        };
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str::<SessionEvent>(&line) {
            Ok(ev) => events.push(ev),
            Err(e) => {
                eprintln!(
                    "⚠ session.jsonl line {line_no} malformed or unknown event: {e}, skipped"
                );
            }
        }
    }
    Ok(events)
}

/// Read a session.jsonl file and return valid events plus evidence-loss
/// diagnostics. Unlike `read_events`, this is meant for audit surfaces that
/// must report skipped lines instead of only warning to stderr.
pub fn read_events_with_diagnostics(path: &Path) -> std::io::Result<EventLogRead> {
    let f = std::fs::File::open(path)?;
    let mut out = EventLogRead::default();
    let reader = BufReader::new(f);
    for line_res in reader.lines() {
        let line = match line_res {
            Ok(line) => line,
            Err(_) => {
                out.diagnostics.parse_errors += 1;
                continue;
            }
        };
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str::<SessionEvent>(&line) {
            Ok(ev) => out.events.push(ev),
            Err(e) => {
                out.diagnostics.parse_errors += 1;
                if serde_json::from_str::<serde_json::Value>(&line).is_err() {
                    out.diagnostics.malformed_lines += 1;
                } else if e.to_string().contains("unknown variant") {
                    out.diagnostics.unknown_events += 1;
                }
            }
        }
    }
    Ok(out)
}

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

    fn ts() -> DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 4, 19, 12, 0, 0).unwrap()
    }

    #[test]
    fn round_trip_all_variants() {
        let events = vec![
            SessionEvent::SessionCreated {
                timestamp: ts(),
                slug: "foo".into(),
                topic: "topic".into(),
                preset: "tech".into(),
                session_dir_abs: "/tmp/foo".into(),
                note: None,
            },
            SessionEvent::SourceAttempted {
                timestamp: ts(),
                url: "https://example.com".into(),
                route_decision: RouteDecision {
                    executor: "postagent".into(),
                    kind: "hn-item".into(),
                    command_template: "...".into(),
                },
                note: None,
            },
            SessionEvent::SourceAccepted {
                timestamp: ts(),
                url: "https://example.com".into(),
                kind: "hn-item".into(),
                executor: "postagent".into(),
                raw_path: "raw/1-hn-item.json".into(),
                bytes: 1234,
                trust_score: 2.0,
                note: None,
            },
            SessionEvent::SourceRejected {
                timestamp: ts(),
                url: "https://example.com".into(),
                kind: "browser-fallback".into(),
                executor: "browser".into(),
                reason: RejectReason::WrongUrl,
                observed_url: Some("about:blank".into()),
                observed_bytes: Some(0),
                rejected_raw_path: None,
                note: None,
            },
            SessionEvent::SynthesizeStarted {
                timestamp: ts(),
                no_render: false,
                open: false,
                bilingual: true,
                bilingual_provider: Some("codex".into()),
                note: None,
            },
            SessionEvent::SynthesizeCompleted {
                timestamp: ts(),
                report_json_path: "report.json".into(),
                report_html_path: Some("report.html".into()),
                accepted_sources: 3,
                rejected_sources: 1,
                duration_ms: 500,
                note: None,
            },
            SessionEvent::SynthesizeFailed {
                timestamp: ts(),
                stage: SynthesizeStage::Render,
                reason: "json-ui not found".into(),
                note: None,
            },
            SessionEvent::SessionClosed {
                timestamp: ts(),
                note: None,
            },
            SessionEvent::SessionRemoved {
                timestamp: ts(),
                note: None,
            },
            SessionEvent::SessionResumed {
                timestamp: ts(),
                note: None,
            },
        ];
        assert_eq!(events.len(), 10, "must have 10 variants");
        for ev in events {
            let s = serde_json::to_string(&ev).unwrap();
            let back: SessionEvent = serde_json::from_str(&s).unwrap();
            assert_eq!(back, ev);
        }
    }

    #[test]
    fn reject_reason_has_5_values() {
        let all = [
            RejectReason::FetchFailed,
            RejectReason::WrongUrl,
            RejectReason::EmptyContent,
            RejectReason::ApiError,
            RejectReason::Duplicate,
        ];
        // Round-trip each.
        for r in all {
            let s = serde_json::to_string(&r).unwrap();
            let back: RejectReason = serde_json::from_str(&s).unwrap();
            assert_eq!(back, r);
        }
    }

    #[test]
    fn roundtrips_agent_os_audit_events() {
        let events = vec![
            SessionEvent::ToolCallStarted {
                timestamp: ts(),
                call_id: "fetch-1".into(),
                hand: "postagent".into(),
                tool: "postagent send".into(),
                input_summary: "url=https://example.test/".into(),
                note: None,
            },
            SessionEvent::ToolCallCompleted {
                timestamp: ts(),
                call_id: "fetch-1".into(),
                status: ToolCallStatus::Ok,
                duration_ms: 42,
                output_summary: "bytes=1234 warnings=0".into(),
                artifact_refs: vec!["raw/1-example.json".into()],
                error_code: None,
                note: None,
            },
            SessionEvent::FactChecked {
                timestamp: ts(),
                iteration: 2,
                claim: "Example claim".into(),
                query: "Example claim source".into(),
                sources: vec!["https://example.test/".into()],
                outcome: FactCheckOutcome::Supported,
                into_section: "## 02 - Facts".into(),
                note: Some("official source".into()),
            },
        ];

        for ev in events {
            let s = serde_json::to_string(&ev).unwrap();
            let back: SessionEvent = serde_json::from_str(&s).unwrap();
            assert_eq!(back, ev);
        }
    }

    #[test]
    fn read_events_is_line_tolerant() {
        use std::io::Write;
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let mut f = tmp.reopen().unwrap();
        writeln!(f, r#"{{"event":"session_created","timestamp":"2026-04-19T12:00:00Z","slug":"foo","topic":"t","preset":"tech","session_dir_abs":"/tmp"}}"#).unwrap();
        writeln!(
            f,
            r#"{{"event":"source_accepted","timestamp":"2026-04-19T12:00:00Z""#
        )
        .unwrap(); // truncated
        writeln!(f, r#"{{"event":"source_accepted","timestamp":"2026-04-19T12:00:00Z","url":"u","kind":"k","executor":"postagent","raw_path":"r","bytes":1,"trust_score":2.0}}"#).unwrap();
        writeln!(
            f,
            r#"{{"event":"unknown_future_event","timestamp":"2026-04-19T12:00:00Z"}}"#
        )
        .unwrap();
        let events = read_events(tmp.path()).unwrap();
        assert_eq!(events.len(), 2, "only 2 valid events should come through");
    }
}
