//! Persistent issue storage — reads and writes `.oo/issues.yaml`.
//!
//! This module is the **only** place in the IDE that performs disk I/O on
//! behalf of the issue system.  The crate::issue_registry::IssueRegistry itself is purely
//! in-memory; this module bridges the gap between disk and registry by:
//!
//! * **Loading** at IDE startup: reading `.oo/issues.yaml`, converting each
//!   record to a [`NewIssue`] with `marker: None` (persistent), and sending
//!   [`crate::operation::Operation::AddIssue`] operations via `op_tx`.
//!
//! * **Saving** after mutations: whenever a persistent issue changes, the
//!   caller (in `apply_operation`) calls [`save_atomic`] with a snapshot of
//!   all current persistent issues.  The write is fire-and-forget inside a
//!   `tokio::task::spawn_blocking` closure.
//!
//! # File format
//!
//! ```yaml
//! format_version: 1
//! issues:
//!   - source: "user"
//!     severity: "warning"
//!     message: "TODO: improve error handling"
//!     dismissed: false
//!     resolved: false
//!     created_at_secs: 1706745600
//!     # optional fields:
//!     path: "src/main.rs"
//!     range_start_line: 42
//!     range_start_col: 0
//!     range_end_line: 42
//!     range_end_col: 10
//! ```
//!
//! # Error handling
//!
//! * Missing file → `Ok(vec![])` (first-run friendly).
//! * Unreadable or corrupt YAML → `log::warn!` then `Ok(vec![])` (no crash).
//! * Save failures → the caller logs a warning; the registry state is never
//!   rolled back.

use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};

use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::editor::position::Position;
use crate::issue_registry::{Issue, NewIssue, Severity};

// ---------------------------------------------------------------------------
// Public input type for the PersistentIssueOp::Add operation
// ---------------------------------------------------------------------------

/// Input data for creating a new persistent issue via
/// [`crate::operation::PersistentIssueOp::Add`].
///
/// Unlike [`NewIssue`] there is no `marker` field — persistent issues are
/// never ephemeral and cannot be batch-cleared by a marker.
#[derive(Debug, Clone)]
pub struct PersistentNewIssue {
    /// Human-readable source tag, e.g. `"user"`, `"note"`.
    pub source: String,
    pub path: Option<std::path::PathBuf>,
    pub range: Option<(Position, Position)>,
    pub message: String,
    pub severity: Severity,
}

impl PersistentNewIssue {
    /// Convert to a [`NewIssue`] with `marker: None`.
    pub(crate) fn into_new_issue(self) -> NewIssue {
        NewIssue {
            marker: None,
            source: self.source,
            path: self.path,
            range: self.range,
            message: self.message,
            severity: self.severity,
        }
    }
}

// ---------------------------------------------------------------------------
// On-disk representation
// ---------------------------------------------------------------------------

const FORMAT_VERSION: u32 = 1;

/// Top-level container written to `issues.yaml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueFile {
    pub format_version: u32,
    pub issues: Vec<IssueRecord>,
}

impl Default for IssueFile {
    fn default() -> Self {
        Self {
            format_version: FORMAT_VERSION,
            issues: Vec::new(),
        }
    }
}

/// A single serialisable issue record.  All fields that reference internal
/// types use plain primitives so no extra serde derives are needed on
/// [`Position`] or [`Severity`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueRecord {
    pub source: String,
    pub severity: String,
    pub message: String,
    pub dismissed: bool,
    pub resolved: bool,
    /// Unix timestamp (seconds since epoch).
    pub created_at_secs: u64,

    // ── Optional location ────────────────────────────────────────────────────
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub range_start_line: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub range_start_col: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub range_end_line: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub range_end_col: Option<usize>,
}

// ---------------------------------------------------------------------------
// Conversions between IssueRecord and NewIssue / Issue
// ---------------------------------------------------------------------------

impl IssueRecord {
    /// Build a [`NewIssue`] with `marker: None` (persistent) from this record.
    pub fn to_new_issue(&self) -> NewIssue {
        let severity = match self.severity.to_ascii_lowercase().as_str() {
            "error" => Severity::Error,
            "warning" | "warn" => Severity::Warning,
            _ => Severity::Info,
        };

        let range = match (
            self.range_start_line,
            self.range_start_col,
            self.range_end_line,
            self.range_end_col,
        ) {
            (Some(sl), Some(sc), Some(el), Some(ec)) => Some((
                Position { line: sl, column: sc },
                Position { line: el, column: ec },
            )),
            _ => None,
        };

        NewIssue {
            marker: None, // persistent — immune to ClearIssuesByMarker
            source: self.source.clone(),
            path: self.path.clone(),
            range,
            message: self.message.clone(),
            severity,
        }
    }

    /// Build a record from an in-memory [`Issue`].
    pub fn from_issue(issue: &Issue) -> Self {
        let severity = match issue.severity {
            Severity::Error => "error",
            Severity::Warning => "warning",
            Severity::Info => "info",
            // Todo issues are always ephemeral; they are never written to
            // .oo/issues.yaml so this branch is unreachable in practice.
            // Fall back to "info" so the serialisation doesn't panic.
            Severity::Todo => "info",
        }
        .to_string();

        let created_at_secs = issue
            .created_at
            .duration_since(UNIX_EPOCH)
            .unwrap_or(Duration::ZERO)
            .as_secs();

        let (rsl, rsc, rel, rec) = match &issue.range {
            Some((s, e)) => (Some(s.line), Some(s.column), Some(e.line), Some(e.column)),
            None => (None, None, None, None),
        };

        Self {
            source: issue.source.clone(),
            severity,
            message: issue.message.clone(),
            dismissed: issue.dismissed,
            resolved: issue.resolved,
            created_at_secs,
            path: issue.path.clone(),
            range_start_line: rsl,
            range_start_col: rsc,
            range_end_line: rel,
            range_end_col: rec,
        }
    }
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Load persistent issues from `path`.
///
/// * If `path` does not exist, returns `Ok(vec![])` (first-run friendly).
/// * If the file is unreadable or the YAML is malformed, logs a warning and
///   returns `Ok(vec![])` — the IDE continues with an empty persistent list.
pub fn load(path: &Path) -> Result<Vec<NewIssue>> {
    let data = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => {
            log::warn!("persistent issues: failed to read {:?}: {}", path, e);
            return Ok(Vec::new());
        }
    };

    let file: IssueFile = match serde_saphyr::from_str(&data) {
        Ok(f) => f,
        Err(e) => {
            log::warn!("persistent issues: YAML parse error in {:?}: {}", path, e);
            return Ok(Vec::new());
        }
    };

    if file.format_version != FORMAT_VERSION {
        log::warn!(
            "persistent issues: unknown format_version {} in {:?}, loading anyway",
            file.format_version,
            path
        );
    }

    Ok(file.issues.iter().map(IssueRecord::to_new_issue).collect())
}

/// Atomically write `issues` to `path`.
///
/// Writes to `path` with a `.tmp` extension, then renames to `path` so that
/// readers never see a half-written file.  The parent directory is created if
/// it does not exist.
pub fn save_atomic(path: &Path, issues: &[Issue]) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let records: Vec<IssueRecord> = issues.iter().map(IssueRecord::from_issue).collect();
    let file = IssueFile {
        format_version: FORMAT_VERSION,
        issues: records,
    };

    let yaml = serde_saphyr::to_string(&file)?;

    let tmp_path = path.with_extension("yaml.tmp");
    std::fs::write(&tmp_path, &yaml)?;
    std::fs::rename(&tmp_path, path)?;

    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::issue_registry::Severity;
    use std::time::SystemTime;
    use tempfile::tempdir;

    fn make_issue(id: u64, source: &str, sev: Severity, msg: &str, marker: Option<&str>) -> Issue {
        Issue {
            id,
            marker: marker.map(|s| s.to_string()),
            source: source.to_string(),
            path: None,
            range: None,
            message: msg.to_string(),
            severity: sev,
            dismissed: false,
            resolved: false,
            created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_706_745_600),
        }
    }

    // ── load ────────────────────────────────────────────────────────────────

    #[test]
    fn load_missing_file_returns_empty() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        let result = load(&path).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn load_corrupt_yaml_returns_empty() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        std::fs::write(&path, b"not: valid: yaml: :::").unwrap();
        let result = load(&path).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn load_empty_issues_list() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        std::fs::write(&path, b"format_version: 1\nissues: []\n").unwrap();
        let result = load(&path).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn load_parses_issues() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        let yaml = r#"
format_version: 1
issues:
  - source: "user"
    severity: "warning"
    message: "Fix me"
    dismissed: false
    resolved: true
    created_at_secs: 1000
    path: "src/main.rs"
    range_start_line: 5
    range_start_col: 0
    range_end_line: 5
    range_end_col: 20
"#;
        std::fs::write(&path, yaml).unwrap();
        let issues = load(&path).unwrap();
        assert_eq!(issues.len(), 1);
        let ni = &issues[0];
        assert_eq!(ni.marker, None);
        assert_eq!(ni.source, "user");
        assert_eq!(ni.severity, Severity::Warning);
        assert_eq!(ni.message, "Fix me");
        assert_eq!(ni.path, Some(PathBuf::from("src/main.rs")));
        let (s, e) = ni.range.unwrap();
        assert_eq!(s.line, 5);
        assert_eq!(s.column, 0);
        assert_eq!(e.line, 5);
        assert_eq!(e.column, 20);
    }

    #[test]
    fn load_severity_fallback_to_info() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        let yaml = "format_version: 1\nissues:\n  - source: x\n    severity: banana\n    message: hi\n    dismissed: false\n    resolved: false\n    created_at_secs: 0\n";
        std::fs::write(&path, yaml).unwrap();
        let issues = load(&path).unwrap();
        assert_eq!(issues[0].severity, Severity::Info);
    }

    // ── save_atomic ─────────────────────────────────────────────────────────

    #[test]
    fn save_atomic_creates_file() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        let issue = make_issue(1, "user", Severity::Error, "oops", None);
        save_atomic(&path, &[issue]).unwrap();
        assert!(path.exists());
        assert!(!dir.path().join("issues.yaml.tmp").exists(), "tmp file should be renamed away");
    }

    #[test]
    fn save_atomic_creates_parent_dir() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("subdir").join("issues.yaml");
        let issue = make_issue(1, "user", Severity::Info, "note", None);
        save_atomic(&path, &[issue]).unwrap();
        assert!(path.exists());
    }

    #[test]
    fn round_trip_preserves_fields() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");

        let mut issue = make_issue(1, "lsp", Severity::Error, "null ptr", None);
        issue.dismissed = true;
        issue.resolved = false;
        issue.path = Some(PathBuf::from("src/lib.rs"));
        issue.range = Some((
            Position { line: 10, column: 3 },
            Position { line: 10, column: 15 },
        ));

        save_atomic(&path, &[issue]).unwrap();
        let loaded = load(&path).unwrap();

        assert_eq!(loaded.len(), 1);
        let ni = &loaded[0];
        assert_eq!(ni.marker, None);
        assert_eq!(ni.source, "lsp");
        assert_eq!(ni.severity, Severity::Error);
        assert_eq!(ni.message, "null ptr");
        assert_eq!(ni.path, Some(PathBuf::from("src/lib.rs")));
        let (s, e) = ni.range.unwrap();
        assert_eq!(s, Position { line: 10, column: 3 });
        assert_eq!(e, Position { line: 10, column: 15 });
    }

    #[test]
    fn save_empty_issues_then_load() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        save_atomic(&path, &[]).unwrap();
        let loaded = load(&path).unwrap();
        assert!(loaded.is_empty());
    }

    #[test]
    fn ephemeral_issue_not_persisted_by_design() {
        // Callers in apply_operation filter to persistent (marker=None) before calling
        // save_atomic. This test verifies the from_issue round-trip ignores the marker
        // field (it is not stored in IssueRecord — persistent issues never have a marker).
        let dir = tempdir().unwrap();
        let path = dir.path().join("issues.yaml");
        let ephemeral = make_issue(2, "build", Severity::Warning, "unused", Some("build"));
        // A well-behaved caller would never pass ephemeral issues to save_atomic.
        // But if it did, the record has no marker field so it would reload as persistent.
        save_atomic(&path, &[ephemeral]).unwrap();
        let loaded = load(&path).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].marker, None, "records always load as persistent");
    }
}
