//! Skill install backends. Two install models per K4:
//!
//! 1. **File copy** (Claude Code, Cursor, Antigravity) — write the skill
//!    content verbatim to a per-agent path under the project (or user-
//!    scope) directory. Different agents use different extensions and
//!    layouts; the path is the agent's responsibility, not ours.
//!
//! 2. **AGENTS.md section injection** (Codex, OpenCode) — append or
//!    replace a marker-delimited block inside an existing `AGENTS.md`
//!    file, preserving everything outside the markers. Lets users
//!    hand-edit the rest of AGENTS.md without losing their work on
//!    `aristo install-skills --update`.
//!
//! The CLI dispatcher in slice 13 calls these helpers; this module has no
//! CLI surface of its own.

use std::fs;
use std::io;
use std::path::Path;

use super::Skill;

/// Marker boundaries for the AGENTS.md-style section. Versioned in the
/// START marker so a future format bump can detect old blocks during
/// `--update`.
pub(crate) const SECTION_START: &str = "<!-- ARISTO-SKILLS START v1 -->";
pub(crate) const SECTION_END: &str = "<!-- ARISTO-SKILLS END -->";

/// Outcome of an install operation, so callers can emit the right
/// "ok: wrote ..." vs "ok: updated ..." vs "note: unchanged" message.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InstallOutcome {
    /// File didn't exist; we wrote it.
    Created,
    /// File existed with different content; we replaced it.
    Updated,
    /// File existed with identical content; nothing changed.
    Unchanged,
}

#[aristo::intent(
    "A second invocation with identical content leaves the target \
     byte-identical and returns `Unchanged`. Created (file did not \
     exist) and Updated (content differed) are distinct outcomes; \
     idempotence is the Unchanged case specifically.",
    verify = "test",
    id = "file_copy_install_idempotent"
)]
pub(crate) fn file_copy_install(target: &Path, skill: &Skill) -> io::Result<InstallOutcome> {
    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent)?;
    }

    let resolved = skill.resolved_content();
    if target.exists() {
        let existing = fs::read_to_string(target)?;
        if existing == resolved {
            return Ok(InstallOutcome::Unchanged);
        }
        fs::write(target, &resolved)?;
        return Ok(InstallOutcome::Updated);
    }

    fs::write(target, &resolved)?;
    Ok(InstallOutcome::Created)
}

/// Read-only staleness verdict for an installed skill artifact, judged
/// against what the running binary would write.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SkillState {
    /// Nothing installed at the target — file absent / unreadable, or (for
    /// AGENTS.md) no Aristo block present.
    Missing,
    /// Installed and byte-identical to this binary's output.
    UpToDate,
    /// Installed but differs from this binary's output — generated by a
    /// different (typically older) aristo. Carries the `sdk_version` read
    /// back from the artifact, when present.
    Stale { installed_version: Option<String> },
}

#[aristo::intent(
    "Classifying an installed skill is READ-ONLY: file_copy_state and \
     agents_md_state read the target and compare, they never write. The \
     post-command update notice and `aristo status` call these on every \
     interactive run; a write here would mutate the user's skill files as \
     a side effect of an unrelated command.",
    verify = "test",
    id = "skill_state_audit_is_read_only"
)]
pub(crate) fn file_copy_state(target: &Path, skill: &Skill) -> SkillState {
    match fs::read_to_string(target) {
        Err(_) => SkillState::Missing,
        Ok(existing) => classify(&existing, &skill.resolved_content()),
    }
}

/// Read-only counterpart of [`agents_md_install`]: classify the Aristo
/// block inside an AGENTS.md-style file without touching it.
pub(crate) fn agents_md_state(target: &Path, skills: &[&Skill]) -> SkillState {
    let Ok(existing) = fs::read_to_string(target) else {
        return SkillState::Missing;
    };
    let Some((start, end)) = find_block(&existing) else {
        return SkillState::Missing;
    };
    // `find_block` spans START..(after END marker); the install writes the
    // block as `render_agents_md_block(...).trim_end()` on the replace
    // path, so compare against the same trimmed canonical form.
    classify(
        &existing[start..end],
        render_agents_md_block(skills).trim_end(),
    )
}

/// Shared comparison: identical → `UpToDate`; otherwise `Stale`, tagged
/// with the `sdk_version` parsed back from the installed text.
fn classify(installed: &str, current: &str) -> SkillState {
    if installed == current {
        SkillState::UpToDate
    } else {
        SkillState::Stale {
            installed_version: parse_sdk_version(installed),
        }
    }
}

/// Pull the first non-empty `sdk_version: X` value out of an installed
/// skill's frontmatter (or AGENTS.md block). `None` if absent.
pub(crate) fn parse_sdk_version(content: &str) -> Option<String> {
    content.lines().find_map(|line| {
        line.trim()
            .strip_prefix("sdk_version:")
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty())
    })
}

#[aristo::intent(
    "Removes only the file we wrote — no sibling deletion, no \
     parent-dir cleanup. Absence of the target is not an error; \
     uninstall-of-already-uninstalled is the idempotent case.",
    verify = "test",
    id = "file_copy_uninstall_idempotent"
)]
pub(crate) fn file_copy_uninstall(target: &Path) -> io::Result<bool> {
    if !target.exists() {
        return Ok(false);
    }
    fs::remove_file(target)?;
    Ok(true)
}

/// Inject (or update) the marker-delimited Aristo block inside an
/// AGENTS.md-style file. Content outside the markers is preserved
/// verbatim; if the file doesn't exist, it's created with just the block.
#[aristo::intent(
    "Content outside the marker boundaries is preserved byte-for-byte \
     across install and update. Users who hand-edit AGENTS.md alongside \
     the auto-generated block don't lose their work to a normalization \
     or reformat pass.",
    verify = "test",
    id = "agents_md_install_preserves_outside_markers"
)]
pub(crate) fn agents_md_install(target: &Path, skills: &[&Skill]) -> io::Result<InstallOutcome> {
    let new_block = render_agents_md_block(skills);

    if !target.exists() {
        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(target, &new_block)?;
        return Ok(InstallOutcome::Created);
    }

    let existing = fs::read_to_string(target)?;
    let updated = match find_block(&existing) {
        Some((start, end)) => {
            let mut buf = String::with_capacity(existing.len() + new_block.len());
            buf.push_str(&existing[..start]);
            buf.push_str(new_block.trim_end());
            buf.push_str(&existing[end..]);
            buf
        }
        None => {
            let mut buf = existing.clone();
            if !buf.ends_with('\n') {
                buf.push('\n');
            }
            buf.push('\n');
            buf.push_str(&new_block);
            buf
        }
    };

    if updated == existing {
        return Ok(InstallOutcome::Unchanged);
    }
    fs::write(target, updated)?;
    Ok(InstallOutcome::Updated)
}

/// Strip the marker-delimited Aristo block from an AGENTS.md-style file.
/// Returns `Ok(false)` if the file or block is absent (idempotent).
#[aristo::intent(
    "Only the marker-delimited block is stripped; surrounding content \
     is preserved byte-for-byte. Absent file or absent block is not an \
     error — idempotent.",
    verify = "test",
    id = "agents_md_uninstall_preserves_outside_markers"
)]
pub(crate) fn agents_md_uninstall(target: &Path) -> io::Result<bool> {
    if !target.exists() {
        return Ok(false);
    }
    let existing = fs::read_to_string(target)?;
    let Some((start, end)) = find_block(&existing) else {
        return Ok(false);
    };

    // Trim a trailing newline immediately after the block so removal
    // doesn't leave a doubled blank line.
    let mut trim_end = end;
    if existing[trim_end..].starts_with('\n') {
        trim_end += 1;
    }
    // And a leading newline before, for the same reason.
    let mut trim_start = start;
    if trim_start > 0 && existing[..trim_start].ends_with('\n') {
        trim_start -= 1;
    }

    let mut buf = String::with_capacity(existing.len());
    buf.push_str(&existing[..trim_start]);
    buf.push_str(&existing[trim_end..]);
    fs::write(target, buf)?;
    Ok(true)
}

fn render_agents_md_block(skills: &[&Skill]) -> String {
    let mut buf = String::new();
    buf.push_str(SECTION_START);
    buf.push('\n');
    for s in skills {
        buf.push_str("\n## ");
        buf.push_str(s.name);
        buf.push_str("\n\n");
        buf.push_str(s.resolved_content().trim());
        buf.push('\n');
    }
    buf.push('\n');
    buf.push_str(SECTION_END);
    buf.push('\n');
    buf
}

/// Locate the byte range of the Aristo block in an AGENTS.md file.
/// Returns `(start_of_marker, end_after_end_marker)` byte offsets.
fn find_block(content: &str) -> Option<(usize, usize)> {
    let start = content.find(SECTION_START)?;
    let end_marker_start = content[start..].find(SECTION_END)? + start;
    let end = end_marker_start + SECTION_END.len();
    Some((start, end))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills;
    use tempfile::TempDir;

    fn skill() -> &'static Skill {
        skills::bundled()
            .iter()
            .find(|s| s.name == "aristo-authoring")
            .expect("authoring skill must be bundled")
    }

    // ---- template resolution ----

    #[test]
    fn installed_skill_has_real_sdk_version_not_placeholder() {
        // The smoking-gun bug from v0.0.5: skill body shipped with
        // `sdk_version: 0.0.4` hardcoded, drifting on every release.
        // Install must resolve `{{SDK_VERSION}}` to the running binary's
        // version; the placeholder must never reach disk.
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("SKILL.md");
        file_copy_install(&target, skill()).unwrap();
        let on_disk = fs::read_to_string(&target).unwrap();
        assert!(
            !on_disk.contains("{{SDK_VERSION}}"),
            "placeholder leaked to installed file"
        );
        let expected = format!("sdk_version: {}", env!("CARGO_PKG_VERSION"));
        assert!(
            on_disk.contains(&expected),
            "installed frontmatter missing `{expected}`"
        );
    }

    // ---- staleness audit (read-only) ----

    #[test]
    fn file_copy_state_missing_when_absent() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("SKILL.md");
        assert_eq!(file_copy_state(&target, skill()), SkillState::Missing);
    }

    #[test]
    fn file_copy_state_uptodate_after_install() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("SKILL.md");
        file_copy_install(&target, skill()).unwrap();
        assert_eq!(file_copy_state(&target, skill()), SkillState::UpToDate);
    }

    #[test]
    fn file_copy_state_stale_reports_installed_version() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("SKILL.md");
        // An older install: frontmatter pinned to a prior version + old body.
        fs::write(
            &target,
            "---\nname: aristo-authoring\nsdk_version: 0.0.1\n---\nold body\n",
        )
        .unwrap();
        match file_copy_state(&target, skill()) {
            SkillState::Stale { installed_version } => {
                assert_eq!(installed_version.as_deref(), Some("0.0.1"));
            }
            other => panic!("expected Stale, got {other:?}"),
        }
    }

    #[test]
    fn skill_state_audit_is_read_only() {
        // Backs the `skill_state_audit_is_read_only` intent: classifying an
        // installed (and a stale) skill must not mutate the file on disk.
        let tmp = TempDir::new().unwrap();
        let fresh = tmp.path().join("fresh/SKILL.md");
        file_copy_install(&fresh, skill()).unwrap();
        let fresh_before = fs::read_to_string(&fresh).unwrap();

        let stale = tmp.path().join("stale/SKILL.md");
        fs::create_dir_all(stale.parent().unwrap()).unwrap();
        let stale_text = "---\nname: aristo-authoring\nsdk_version: 0.0.1\n---\nold\n";
        fs::write(&stale, stale_text).unwrap();

        // Audit both — no writes allowed.
        let _ = file_copy_state(&fresh, skill());
        let _ = file_copy_state(&stale, skill());

        assert_eq!(fs::read_to_string(&fresh).unwrap(), fresh_before);
        assert_eq!(fs::read_to_string(&stale).unwrap(), stale_text);
    }

    #[test]
    fn agents_md_state_tracks_install_then_drift() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");

        assert_eq!(agents_md_state(&target, &[skill()]), SkillState::Missing);

        agents_md_install(&target, &[skill()]).unwrap();
        assert_eq!(agents_md_state(&target, &[skill()]), SkillState::UpToDate);

        // A hand-rolled stale block with an older version pin.
        let stale = format!(
            "{SECTION_START}\n## aristo-authoring\n\nsdk_version: 0.0.1\nold\n\n{SECTION_END}\n"
        );
        fs::write(&target, stale).unwrap();
        match agents_md_state(&target, &[skill()]) {
            SkillState::Stale { installed_version } => {
                assert_eq!(installed_version.as_deref(), Some("0.0.1"));
            }
            other => panic!("expected Stale, got {other:?}"),
        }
    }

    #[test]
    fn parse_sdk_version_extracts_first_value_or_none() {
        assert_eq!(
            parse_sdk_version("name: x\nsdk_version: 0.2.1\nbody").as_deref(),
            Some("0.2.1")
        );
        assert_eq!(parse_sdk_version("no version anywhere"), None);
        // Empty value after the colon is treated as absent.
        assert_eq!(parse_sdk_version("sdk_version:   "), None);
    }

    // ---- file_copy ----

    #[test]
    fn file_copy_creates_then_unchanged_then_updated() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("a/b/c/SKILL.md");

        let r1 = file_copy_install(&target, skill()).unwrap();
        assert_eq!(r1, InstallOutcome::Created);
        assert!(target.is_file());

        let r2 = file_copy_install(&target, skill()).unwrap();
        assert_eq!(r2, InstallOutcome::Unchanged);

        // Mutate the file and re-install — should report Updated.
        fs::write(&target, "tampered").unwrap();
        let r3 = file_copy_install(&target, skill()).unwrap();
        assert_eq!(r3, InstallOutcome::Updated);
        assert_eq!(
            fs::read_to_string(&target).unwrap(),
            skill().resolved_content()
        );
    }

    #[test]
    fn file_copy_uninstall_idempotent() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("SKILL.md");

        // Absent — no error, returns false.
        assert!(!file_copy_uninstall(&target).unwrap());

        // Present — removes, returns true.
        file_copy_install(&target, skill()).unwrap();
        assert!(file_copy_uninstall(&target).unwrap());
        assert!(!target.exists());

        // Absent again — false.
        assert!(!file_copy_uninstall(&target).unwrap());
    }

    // ---- agents_md ----

    #[test]
    fn agents_md_creates_when_file_absent() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");

        let r = agents_md_install(&target, &[skill()]).unwrap();
        assert_eq!(r, InstallOutcome::Created);

        let content = fs::read_to_string(&target).unwrap();
        assert!(content.contains(SECTION_START));
        assert!(content.contains(SECTION_END));
        assert!(content.contains("## aristo-authoring"));
    }

    #[test]
    fn agents_md_appends_block_to_existing_file_preserving_user_content() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");
        let user_text = "# My agent rules\n\nUse 4-space indent.\n";
        fs::write(&target, user_text).unwrap();

        agents_md_install(&target, &[skill()]).unwrap();

        let content = fs::read_to_string(&target).unwrap();
        assert!(
            content.starts_with(user_text),
            "user content must be preserved at the start"
        );
        assert!(content.contains(SECTION_START));
    }

    #[test]
    fn agents_md_replaces_only_marker_block_on_update() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");
        let user_before = "# Before\n\n";
        let stale_block =
            format!("{SECTION_START}\n## aristo-authoring\n\nold content\n\n{SECTION_END}\n");
        let user_after = "\n# After\n\nMore user notes.\n";
        fs::write(&target, format!("{user_before}{stale_block}{user_after}")).unwrap();

        let r = agents_md_install(&target, &[skill()]).unwrap();
        assert_eq!(r, InstallOutcome::Updated);

        let content = fs::read_to_string(&target).unwrap();
        assert!(content.contains("# Before"));
        assert!(content.contains("# After"));
        assert!(content.contains("More user notes."));
        assert!(
            !content.contains("old content"),
            "stale content must be replaced"
        );
        assert!(content.contains(SECTION_START));
    }

    #[test]
    fn agents_md_install_idempotent() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");

        agents_md_install(&target, &[skill()]).unwrap();
        let r = agents_md_install(&target, &[skill()]).unwrap();
        assert_eq!(r, InstallOutcome::Unchanged);
    }

    #[test]
    fn agents_md_uninstall_strips_block_preserves_surrounding() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");
        let user_before = "# My rules\n\nUse 4-space indent.\n";
        fs::write(&target, user_before).unwrap();

        agents_md_install(&target, &[skill()]).unwrap();
        let removed = agents_md_uninstall(&target).unwrap();
        assert!(removed);

        let content = fs::read_to_string(&target).unwrap();
        assert!(!content.contains(SECTION_START));
        assert!(!content.contains(SECTION_END));
        assert!(content.contains("# My rules"));
        assert!(content.contains("Use 4-space indent."));
    }

    #[test]
    fn agents_md_uninstall_idempotent_when_file_absent_or_block_absent() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("AGENTS.md");

        // File absent.
        assert!(!agents_md_uninstall(&target).unwrap());

        // File present, no block.
        fs::write(&target, "just user content\n").unwrap();
        assert!(!agents_md_uninstall(&target).unwrap());
        assert_eq!(fs::read_to_string(&target).unwrap(), "just user content\n");
    }
}
