//! `arai sync` — read a canonical `arai.toml` and update per-tool
//! instruction files (CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules,
//! .github/copilot-instructions.md) in place.
//!
//! Hand-edits are preserved: content outside the
//! `<!-- BEGIN ARAI MANAGED RULES -->` / `<!-- END ARAI MANAGED RULES -->`
//! markers is left untouched.  A first sync against a file without
//! markers prepends the managed block at the top.  Only existing files
//! are touched — sync never creates a tool's instruction file the team
//! didn't already choose to use.

use crate::canonicalize::{CanonicalFile, CanonicalRule};
use std::path::{Path, PathBuf};

pub const BEGIN_MARKER: &str = "<!-- BEGIN ARAI MANAGED RULES -->";
pub const END_MARKER: &str = "<!-- END ARAI MANAGED RULES -->";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetAction {
    /// File didn't have markers; the managed block was prepended.
    Inserted,
    /// File had markers; the managed block content was replaced.
    Replaced,
    /// File had markers and the managed block already matched what we
    /// would have written; nothing changed.
    Unchanged,
}

#[derive(Debug)]
pub struct TargetResult {
    pub path: PathBuf,
    pub action: TargetAction,
}

#[derive(Debug)]
pub struct Summary {
    pub results: Vec<TargetResult>,
    /// Files that exist on disk but weren't writable / readable.  Carries
    /// the error string so the CLI can surface it without aborting the
    /// whole run.
    pub errors: Vec<(PathBuf, String)>,
}

impl Summary {
    pub fn files_changed(&self) -> usize {
        self.results
            .iter()
            .filter(|r| !matches!(r.action, TargetAction::Unchanged))
            .count()
    }
}

/// Resolve the set of per-tool target files we would update if they
/// existed.  Ordering matches the discovery surface so the audit-side
/// view stays consistent.
fn target_candidates(project_root: &Path) -> Vec<PathBuf> {
    vec![
        project_root.join("CLAUDE.md"),
        project_root.join("AGENTS.md"),
        project_root.join("Agents.md"),
        project_root.join("AGENT.md"),
        project_root.join("agents.md"),
        project_root.join(".cursorrules"),
        project_root.join(".windsurfrules"),
        project_root.join(".github").join("copilot-instructions.md"),
    ]
}

/// Run the sync flow end-to-end against an in-memory `CanonicalFile`.
/// `project_root` is the directory whose target files are updated.
pub fn run(
    canonical: &CanonicalFile,
    project_root: &Path,
    dry_run: bool,
) -> Result<Summary, String> {
    let block = render_managed_block(canonical);

    let mut results = Vec::new();
    let mut errors = Vec::new();

    for target in target_candidates(project_root) {
        if !target.exists() {
            continue;
        }
        let existing = match std::fs::read_to_string(&target) {
            Ok(s) => s,
            Err(e) => {
                errors.push((target.clone(), format!("read failed: {e}")));
                continue;
            }
        };

        let (next, action) = apply_managed_block(&existing, &block);

        if !dry_run && action != TargetAction::Unchanged {
            if let Err(e) = std::fs::write(&target, &next) {
                errors.push((target.clone(), format!("write failed: {e}")));
                continue;
            }
        }

        results.push(TargetResult {
            path: target,
            action,
        });
    }

    Ok(Summary { results, errors })
}

/// Compose the full managed block (markers + body) from a canonical
/// rule set.  The body is a markdown list of rules grouped by severity
/// so a reader scanning the per-tool file sees the strongest signals
/// first.
pub fn render_managed_block(canonical: &CanonicalFile) -> String {
    let mut s = String::new();
    s.push_str(BEGIN_MARKER);
    s.push('\n');
    s.push_str(
        "<!-- Generated by `arai sync` from arai.toml.  Edits inside this block are\n     \
         overwritten on the next sync — make your changes in arai.toml instead. -->\n\n",
    );

    if canonical.rules.is_empty() {
        s.push_str("_No rules defined in `arai.toml`._\n\n");
    } else {
        for sev in ["block", "warn", "inform"] {
            let group: Vec<&CanonicalRule> = canonical
                .rules
                .iter()
                .filter(|r| r.severity == sev)
                .collect();
            if group.is_empty() {
                continue;
            }
            s.push_str(&format!("## {}\n\n", section_heading(sev)));
            for rule in group {
                render_rule(&mut s, rule);
            }
        }
    }

    s.push_str(END_MARKER);
    s.push('\n');
    s
}

fn section_heading(severity: &str) -> &'static str {
    match severity {
        "block" => "Block",
        "warn" => "Warn",
        "inform" => "Inform",
        _ => "Other",
    }
}

fn render_rule(out: &mut String, rule: &CanonicalRule) {
    out.push_str(&format!("### {}\n\n", rule.id));
    if !rule.description.is_empty() {
        out.push_str(&format!("> {}\n\n", rule.description));
    }

    // Trigger summary line: tool(s), and a path/command/content qualifier
    // if one is present.  Kept compact — the agent only needs the gist.
    let tools = if rule.when.tool.is_empty() {
        "*".to_string()
    } else {
        rule.when.tool.join(", ")
    };
    let mut trigger = format!("**Trigger:** `{}`", tools);
    if !rule.when.path.is_empty() {
        trigger.push_str(&format!(" on path `{}`", rule.when.path.join(" | ")));
    }
    if let Some(p) = rule.when.command_pattern.as_deref() {
        trigger.push_str(&format!(" matching command `{}`", p));
    }
    if let Some(p) = rule.when.content_pattern.as_deref() {
        trigger.push_str(&format!(" matching content `{}`", p));
    }
    out.push_str(&trigger);
    out.push_str("\n\n");

    out.push_str(&rule.then.message);
    out.push_str("\n\n");
}

/// Inject or replace the managed block in the existing file content.
///
/// - No markers → prepend the block followed by a blank line.
/// - Both markers present (first pair wins on duplicates) → replace
///   only what's between them, preserving everything outside.
/// - Block content identical to what we'd write → `Unchanged`, no
///   string churn so the caller skips the write.
///
/// The marker line must appear on its own (no surrounding text on the
/// same line) — we match line-by-line to avoid clobbering prose that
/// happens to contain the marker text inside a code fence or paragraph.
pub fn apply_managed_block(existing: &str, new_block: &str) -> (String, TargetAction) {
    let lines: Vec<&str> = existing.lines().collect();
    let begin_idx = lines.iter().position(|l| l.trim() == BEGIN_MARKER);
    let end_idx = lines.iter().position(|l| l.trim() == END_MARKER);

    match (begin_idx, end_idx) {
        (Some(b), Some(e)) if e > b => {
            let before = lines[..b].join("\n");
            let after = if e + 1 < lines.len() {
                lines[e + 1..].join("\n")
            } else {
                String::new()
            };

            let trailing = if existing.ends_with('\n') { "\n" } else { "" };
            let mut next = String::new();
            if !before.is_empty() {
                next.push_str(&before);
                next.push('\n');
            }
            next.push_str(new_block);
            if !after.is_empty() {
                next.push_str(&after);
                next.push_str(trailing);
            } else if !trailing.is_empty() && !next.ends_with('\n') {
                next.push('\n');
            }

            if next == existing {
                (next, TargetAction::Unchanged)
            } else {
                (next, TargetAction::Replaced)
            }
        }
        _ => {
            // No (valid) marker pair — prepend the block, separating it
            // from existing content by a blank line so the existing
            // prose doesn't collide with the END_MARKER comment.
            let mut next = String::new();
            next.push_str(new_block);
            if !existing.is_empty() {
                if !existing.starts_with('\n') {
                    next.push('\n');
                }
                next.push_str(existing);
            }
            (next, TargetAction::Inserted)
        }
    }
}

/// Load a canonical TOML file from disk and parse it.
pub fn load_canonical(path: &Path) -> Result<CanonicalFile, String> {
    let body =
        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    toml::from_str::<CanonicalFile>(&body)
        .map_err(|e| format!("parse {} as canonical TOML: {e}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canonicalize::{CanonicalRule, Meta, Then, When};

    fn rule(id: &str, severity: &str, msg: &str) -> CanonicalRule {
        CanonicalRule {
            id: id.to_string(),
            description: format!("{id} description"),
            when: When {
                tool: vec!["Bash".to_string()],
                path: Vec::new(),
                command_pattern: None,
                content_pattern: None,
                session_lacks: Vec::new(),
            },
            then: Then {
                action: severity.to_string(),
                message: msg.to_string(),
            },
            severity: severity.to_string(),
            expires: None,
        }
    }

    fn cfile(rules: Vec<CanonicalRule>) -> CanonicalFile {
        CanonicalFile {
            meta: Meta {
                schema_version: 1,
                project: Some("test".to_string()),
                extends: Vec::new(),
            },
            rules,
        }
    }

    #[test]
    fn no_markers_prepends_block() {
        let existing = "# Project rules\n\nSome hand-written prose.\n";
        let block = render_managed_block(&cfile(vec![rule("a", "block", "no")]));
        let (out, action) = apply_managed_block(existing, &block);
        assert_eq!(action, TargetAction::Inserted);
        assert!(out.starts_with(BEGIN_MARKER));
        assert!(out.contains("Some hand-written prose."));
    }

    #[test]
    fn markers_present_replaces_only_between() {
        let existing = format!(
            "# Top matter\nHand-edit preserved above.\n\n{BEGIN_MARKER}\nold rules\n{END_MARKER}\n\nHand-edit preserved below.\n"
        );
        let block = render_managed_block(&cfile(vec![rule("a", "block", "Block this")]));
        let (out, action) = apply_managed_block(&existing, &block);
        assert_eq!(action, TargetAction::Replaced);
        assert!(out.contains("Hand-edit preserved above."));
        assert!(out.contains("Hand-edit preserved below."));
        assert!(out.contains("Block this"));
        assert!(!out.contains("old rules"));
    }

    #[test]
    fn identical_block_is_unchanged() {
        let canonical = cfile(vec![rule("a", "block", "Block this")]);
        let block = render_managed_block(&canonical);
        let existing = format!("# header\n\n{block}\nfooter\n");
        let (out, action) = apply_managed_block(&existing, &block);
        assert_eq!(action, TargetAction::Unchanged);
        assert_eq!(out, existing);
    }

    #[test]
    fn rules_grouped_by_severity_in_block() {
        let block = render_managed_block(&cfile(vec![
            rule("inform-a", "inform", "soft suggestion"),
            rule("block-a", "block", "hard block"),
            rule("warn-a", "warn", "advisory"),
        ]));
        let block_idx = block.find("## Block").expect("Block heading");
        let warn_idx = block.find("## Warn").expect("Warn heading");
        let inform_idx = block.find("## Inform").expect("Inform heading");
        assert!(block_idx < warn_idx, "Block should appear before Warn");
        assert!(warn_idx < inform_idx, "Warn should appear before Inform");
    }

    #[test]
    fn empty_rule_set_emits_placeholder() {
        let block = render_managed_block(&cfile(Vec::new()));
        assert!(block.contains("No rules defined"));
        assert!(block.contains(BEGIN_MARKER));
        assert!(block.contains(END_MARKER));
    }

    #[test]
    fn run_only_touches_existing_files() {
        let tmp = tempdir();
        // Seed CLAUDE.md only; AGENTS.md / .cursorrules absent.
        let claude_path = tmp.join("CLAUDE.md");
        std::fs::write(&claude_path, "# starter\n").unwrap();

        let summary =
            run(&cfile(vec![rule("a", "block", "Block X")]), &tmp, false).expect("sync run");
        assert_eq!(
            summary.results.len(),
            1,
            "only CLAUDE.md exists, so only one target should be touched"
        );
        assert_eq!(summary.results[0].path, claude_path);
        assert_eq!(summary.results[0].action, TargetAction::Inserted);

        let written = std::fs::read_to_string(&claude_path).unwrap();
        assert!(written.contains("Block X"));
        assert!(written.contains("# starter"));

        cleanup(&tmp);
    }

    #[test]
    fn dry_run_does_not_write() {
        let tmp = tempdir();
        let claude_path = tmp.join("CLAUDE.md");
        std::fs::write(&claude_path, "# starter\n").unwrap();

        let _ = run(&cfile(vec![rule("a", "block", "Block X")]), &tmp, true).unwrap();
        let after = std::fs::read_to_string(&claude_path).unwrap();
        assert_eq!(after, "# starter\n", "dry_run should not modify the file");

        cleanup(&tmp);
    }

    // Tiny no-deps tempdir helper — gives us per-test isolation without
    // pulling in the `tempfile` crate.  Pid + nanos avoid collisions on
    // parallel-test runs.
    fn tempdir() -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.subsec_nanos())
            .unwrap_or(0);
        let p =
            std::env::temp_dir().join(format!("arai-sync-test-{}-{}", std::process::id(), nanos));
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    fn cleanup(p: &Path) {
        let _ = std::fs::remove_dir_all(p);
    }
}
