//! Structured file edits with reviewable diffs. `write_file` and `edit_file`
//! apply a change to disk and return a line diff so the TUI can render a
//! Codex/Claude-Code-style colored diff cell instead of opaque shell output.

use serde::Serialize;
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffKind {
    Context,
    Added,
    Removed,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiffLine {
    pub kind: DiffKind,
    pub old_no: Option<usize>,
    pub new_no: Option<usize>,
    pub text: String,
}

#[derive(Debug, Clone)]
pub struct EditResult {
    pub op: &'static str, // "Create" | "Update"
    pub added: usize,
    pub removed: usize,
    pub lines: Vec<DiffLine>,
}

const CONTEXT: usize = 3;

/// Split into lines, treating a single trailing newline as a line terminator
/// (not an extra empty line).
fn split_lines(s: &str) -> Vec<&str> {
    if s.is_empty() {
        return vec![];
    }
    let mut v: Vec<&str> = s.split('\n').collect();
    if v.last() == Some(&"") {
        v.pop();
    }
    v
}

/// Line-based diff: trim common prefix/suffix, show the changed hunk with a
/// few context lines and gutter line numbers.
pub fn line_diff(old: &str, new: &str) -> (Vec<DiffLine>, usize, usize) {
    let old_lines = split_lines(old);
    let new_lines = split_lines(new);

    // Common prefix.
    let mut p = 0;
    while p < old_lines.len() && p < new_lines.len() && old_lines[p] == new_lines[p] {
        p += 1;
    }
    // Common suffix (not overlapping the prefix).
    let mut s = 0;
    while s < old_lines.len() - p.min(old_lines.len())
        && s < new_lines.len() - p.min(new_lines.len())
        && old_lines[old_lines.len() - 1 - s] == new_lines[new_lines.len() - 1 - s]
    {
        s += 1;
    }

    let removed = &old_lines[p..old_lines.len() - s];
    let added = &new_lines[p..new_lines.len() - s];

    let mut lines = Vec::new();
    // Leading context.
    let ctx_start = p.saturating_sub(CONTEXT);
    for i in ctx_start..p {
        lines.push(DiffLine {
            kind: DiffKind::Context,
            old_no: Some(i + 1),
            new_no: Some(i + 1),
            text: old_lines[i].to_string(),
        });
    }
    // Removed (old numbering).
    for (i, l) in removed.iter().enumerate() {
        lines.push(DiffLine {
            kind: DiffKind::Removed,
            old_no: Some(p + i + 1),
            new_no: None,
            text: l.to_string(),
        });
    }
    // Added (new numbering).
    for (i, l) in added.iter().enumerate() {
        lines.push(DiffLine {
            kind: DiffKind::Added,
            old_no: None,
            new_no: Some(p + i + 1),
            text: l.to_string(),
        });
    }
    // Trailing context.
    let suffix_start_new = new_lines.len() - s;
    for i in 0..s.min(CONTEXT) {
        let idx = suffix_start_new + i;
        lines.push(DiffLine {
            kind: DiffKind::Context,
            old_no: Some(old_lines.len() - s + i + 1),
            new_no: Some(idx + 1),
            text: new_lines[idx].to_string(),
        });
    }
    (lines, added.len(), removed.len())
}

/// Overwrite (or create) a file with `content`, returning the diff.
pub fn write_file(path: &Path, content: &str) -> std::io::Result<EditResult> {
    let existed = path.exists();
    let old = if existed { std::fs::read_to_string(path).unwrap_or_default() } else { String::new() };
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)?;
        }
    }
    std::fs::write(path, content)?;
    let (lines, added, removed) = line_diff(&old, content);
    Ok(EditResult { op: if existed { "Update" } else { "Create" }, added, removed, lines })
}

/// Replace `old_string` with `new_string` in a file, returning the diff.
/// Errors if `old_string` is absent, or ambiguous when `replace_all` is false.
pub fn edit_file(
    path: &Path,
    old_string: &str,
    new_string: &str,
    replace_all: bool,
) -> Result<EditResult, String> {
    if !path.exists() {
        return Err(format!("file not found: {}", path.display()));
    }
    let old = std::fs::read_to_string(path).map_err(|e| format!("read failed: {e}"))?;
    if old_string.is_empty() {
        return Err("old_string must not be empty (use write_file to create a file)".into());
    }
    let count = old.matches(old_string).count();
    if count == 0 {
        return Err("old_string not found in file".into());
    }
    if count > 1 && !replace_all {
        return Err(format!(
            "old_string occurs {count} times — make it unique or pass replace_all: true"
        ));
    }
    let new_content = if replace_all {
        old.replace(old_string, new_string)
    } else {
        old.replacen(old_string, new_string, 1)
    };
    std::fs::write(path, &new_content).map_err(|e| format!("write failed: {e}"))?;
    let (lines, added, removed) = line_diff(&old, &new_content);
    Ok(EditResult { op: "Update", added, removed, lines })
}

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

    #[test]
    fn diff_detects_insert() {
        let old = "a\nb\nc";
        let new = "a\nb\nX\nc";
        let (lines, added, removed) = line_diff(old, new);
        assert_eq!(added, 1);
        assert_eq!(removed, 0);
        assert!(lines.iter().any(|l| l.kind == DiffKind::Added && l.text == "X"));
    }

    #[test]
    fn diff_detects_replace() {
        let (lines, added, removed) = line_diff("hello\nworld", "hello\nrust");
        assert_eq!(added, 1);
        assert_eq!(removed, 1);
        assert!(lines.iter().any(|l| l.kind == DiffKind::Removed && l.text == "world"));
        assert!(lines.iter().any(|l| l.kind == DiffKind::Added && l.text == "rust"));
    }

    #[test]
    fn write_then_edit_roundtrip() {
        let dir = std::env::temp_dir().join(format!("lucid-edit-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let f = dir.join("a.txt");
        let r = write_file(&f, "one\ntwo\n").unwrap();
        assert_eq!(r.op, "Create");
        assert_eq!(r.added, 2);

        let r2 = edit_file(&f, "two", "TWO", false).unwrap();
        assert_eq!(r2.op, "Update");
        assert_eq!(std::fs::read_to_string(&f).unwrap(), "one\nTWO\n");
        assert_eq!(r2.added, 1);
        assert_eq!(r2.removed, 1);

        assert!(edit_file(&f, "missing", "x", false).is_err());
        std::fs::remove_dir_all(&dir).ok();
    }
}
