//! Durable OS-level scheduling — survive a daemon/app restart.
//!
//! The in-process [`Executor`](crate::Executor) drives tasks with tokio timers:
//! if the daemon is down when a task is due, the run is simply missed. This
//! module delegates the *trigger* to the operating system's own scheduler
//! (`launchd` on macOS, `crontab` on Linux), which fires a command even when no
//! CAR process is running. That command — supplied by the caller (typically a
//! `car`/`car-server` invocation that runs the task once) — is what re-enters
//! CAR.
//!
//! The rendering ([`render_launchd_plist`](OsScheduleSpec::render_launchd_plist),
//! [`render_crontab_line`](OsScheduleSpec::render_crontab_line)) is pure and
//! platform-independent, so it is unit-tested directly. The install layer
//! ([`install`](OsScheduleSpec::install) / [`uninstall`] / [`list_installed`])
//! is cfg-gated per OS and shells out.

use std::collections::BTreeSet;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::task::{Task, TaskTrigger};

/// Prefix for every CAR-managed OS schedule label / crontab tag, so install,
/// uninstall, and list only ever touch entries CAR created.
pub const LABEL_PREFIX: &str = "ai.parslee.car.task.";

/// Errors from rendering or installing an OS schedule.
#[derive(Debug, thiserror::Error)]
pub enum OsScheduleError {
    /// The task's trigger can't map to a recurring OS schedule (Once / Manual /
    /// FileWatch have no cron/launchd analogue here).
    #[error("trigger {0:?} is not OS-schedulable (use Interval or Cron)")]
    NotSchedulable(TaskTrigger),
    /// The schedule is valid but not expressible on this backend (e.g. a
    /// sub-minute interval under cron).
    #[error("schedule not expressible: {0}")]
    UnsupportedSchedule(String),
    /// A command value (program/arg/working_dir/log_path) is empty or carries a
    /// control character that could break out of a crontab line.
    #[error("invalid command value: {0}")]
    InvalidValue(String),
    /// This OS has no supported backend.
    #[error("no OS scheduling backend for this platform")]
    UnsupportedPlatform,
    /// A `launchctl` / `crontab` invocation failed.
    #[error("{0} failed: {1}")]
    Command(String, String),
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

/// How the OS should fire the command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OsTrigger {
    /// Every N seconds (launchd `StartInterval`; cron only when N is a whole
    /// number of minutes that divides cleanly).
    Interval { seconds: u64 },
    /// A standard 5-field cron expression (`min hour dom month dow`).
    Cron { expr: String },
}

/// A fully-resolved OS schedule: the command to run and when.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsScheduleSpec {
    /// Unique label (`ai.parslee.car.task.<id>`). The launchd `Label` and the
    /// crontab tag.
    pub label: String,
    /// Absolute path to the program the OS runs (e.g. the `car` binary).
    pub program: String,
    /// Arguments passed to `program` (e.g. `["task", "run", "<id>"]`).
    #[serde(default)]
    pub args: Vec<String>,
    /// Working directory for the run, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub working_dir: Option<String>,
    /// File to redirect stdout/stderr to, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub log_path: Option<String>,
    /// When to fire.
    pub trigger: OsTrigger,
}

impl OsScheduleSpec {
    /// Build a schedule that runs `program args...` on the cadence of `task`.
    /// The caller owns the command: `program` is the binary the OS launches and
    /// `args` is what re-enters CAR to run this task once.
    ///
    /// Note: the OS-schedule layer does not consult `task.enabled` — installing a
    /// schedule for a disabled task still registers it with the OS. Enable/disable
    /// is the in-process executor's concern; uninstall to stop OS firing.
    pub fn from_task(
        task: &Task,
        program: impl Into<String>,
        args: Vec<String>,
    ) -> Result<Self, OsScheduleError> {
        let trigger = match task.trigger {
            TaskTrigger::Interval => OsTrigger::Interval {
                seconds: crate::task::parse_interval(&task.schedule).round().max(1.0) as u64,
            },
            TaskTrigger::Cron => OsTrigger::Cron {
                expr: task.schedule.trim().to_string(),
            },
            // Spell the non-schedulable triggers out (rather than `_`) so a new
            // TaskTrigger variant forces a decision here instead of silently
            // becoming "not schedulable".
            t @ (TaskTrigger::Once | TaskTrigger::FileWatch | TaskTrigger::Manual) => {
                return Err(OsScheduleError::NotSchedulable(t))
            }
        };
        let program = program.into();
        validate_command_value("program", &program)?;
        for arg in &args {
            validate_command_value("arg", arg)?;
        }
        if let OsTrigger::Cron { expr } = &trigger {
            validate_cron(expr)?;
        }
        Ok(Self {
            label: format!("{LABEL_PREFIX}{}", task.id),
            program,
            args,
            working_dir: None,
            log_path: None,
            trigger,
        })
    }

    /// Validate every command value (program, args, working_dir, log_path) for
    /// emptiness and control characters. The render paths call this so that a
    /// spec constructed directly (bypassing [`from_task`](Self::from_task), or
    /// with `working_dir`/`log_path` set afterward) can't smuggle a newline into
    /// a crontab line — crontab parses line-by-line, so an embedded `\n` would
    /// inject a second, attacker-controlled entry.
    fn validate_values(&self) -> Result<(), OsScheduleError> {
        validate_command_value("program", &self.program)?;
        for arg in &self.args {
            validate_command_value("arg", arg)?;
        }
        if let Some(dir) = &self.working_dir {
            validate_command_value("working_dir", dir)?;
        }
        if let Some(log) = &self.log_path {
            validate_command_value("log_path", log)?;
        }
        Ok(())
    }

    /// Render a macOS `launchd` property list. `StartInterval` for an interval
    /// trigger; `StartCalendarInterval` for the cron subset launchd can express
    /// (plain integers and `*` per field — step/list/range expressions return
    /// [`OsScheduleError::UnsupportedSchedule`]).
    pub fn render_launchd_plist(&self) -> Result<String, OsScheduleError> {
        self.validate_values()?;
        let mut body = String::new();
        body.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
        body.push_str("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n");
        body.push_str("<plist version=\"1.0\">\n<dict>\n");
        body.push_str("  <key>Label</key>\n");
        body.push_str(&format!("  <string>{}</string>\n", xml_escape(&self.label)));
        body.push_str("  <key>ProgramArguments</key>\n  <array>\n");
        body.push_str(&format!(
            "    <string>{}</string>\n",
            xml_escape(&self.program)
        ));
        for arg in &self.args {
            body.push_str(&format!("    <string>{}</string>\n", xml_escape(arg)));
        }
        body.push_str("  </array>\n");

        match &self.trigger {
            OsTrigger::Interval { seconds } => {
                body.push_str("  <key>StartInterval</key>\n");
                body.push_str(&format!("  <integer>{seconds}</integer>\n"));
            }
            OsTrigger::Cron { expr } => {
                body.push_str("  <key>StartCalendarInterval</key>\n");
                body.push_str(&render_calendar_interval(expr)?);
            }
        }

        if let Some(dir) = &self.working_dir {
            body.push_str("  <key>WorkingDirectory</key>\n");
            body.push_str(&format!("  <string>{}</string>\n", xml_escape(dir)));
        }
        if let Some(log) = &self.log_path {
            body.push_str("  <key>StandardOutPath</key>\n");
            body.push_str(&format!("  <string>{}</string>\n", xml_escape(log)));
            body.push_str("  <key>StandardErrorPath</key>\n");
            body.push_str(&format!("  <string>{}</string>\n", xml_escape(log)));
        }
        // Don't fire on load — only on the schedule. (For StartInterval, launchd
        // would otherwise run once immediately at load.)
        body.push_str("  <key>RunAtLoad</key>\n  <false/>\n");
        body.push_str("</dict>\n</plist>\n");
        Ok(body)
    }

    /// Render a single crontab line tagged with the label so install/uninstall
    /// can find it. An interval trigger maps to `*/M` (or hourly/daily) only
    /// when the period divides evenly; otherwise
    /// [`OsScheduleError::UnsupportedSchedule`].
    pub fn render_crontab_line(&self) -> Result<String, OsScheduleError> {
        self.validate_values()?;
        let schedule = match &self.trigger {
            OsTrigger::Cron { expr } => {
                validate_cron(expr)?;
                expr.clone()
            }
            OsTrigger::Interval { seconds } => interval_to_cron(*seconds)?,
        };
        let mut cmd = shell_quote(&self.program);
        for arg in &self.args {
            cmd.push(' ');
            cmd.push_str(&shell_quote(arg));
        }
        if let Some(dir) = &self.working_dir {
            cmd = format!("cd {} && {cmd}", shell_quote(dir));
        }
        if let Some(log) = &self.log_path {
            cmd = format!("{cmd} >> {} 2>&1", shell_quote(log));
        }
        // Trailing tag comment is how `uninstall`/`list` recognize our lines.
        Ok(format!("{schedule} {cmd} # {}", self.label))
    }

    /// Path of the launchd agent plist for this label.
    pub fn launchd_plist_path(&self) -> PathBuf {
        launch_agents_dir().join(format!("{}.plist", self.label))
    }
}

/// Validate a 5-field cron expression's shape (field count + characters). Does
/// not evaluate ranges — just rejects obviously malformed input early.
fn validate_cron(expr: &str) -> Result<(), OsScheduleError> {
    let fields: Vec<&str> = expr.split_whitespace().collect();
    if fields.len() != 5 {
        return Err(OsScheduleError::UnsupportedSchedule(format!(
            "expected a 5-field cron expression, got {} field(s): {expr:?}",
            fields.len()
        )));
    }
    for f in fields {
        if !f
            .chars()
            .all(|c| c.is_ascii_digit() || matches!(c, '*' | '/' | ',' | '-'))
        {
            return Err(OsScheduleError::UnsupportedSchedule(format!(
                "cron field {f:?} has unsupported characters"
            )));
        }
    }
    Ok(())
}

/// Render a `<dict>` of `StartCalendarInterval` keys for the cron subset launchd
/// can represent: each field is either `*` (omit — "every") or a plain integer.
fn render_calendar_interval(expr: &str) -> Result<String, OsScheduleError> {
    validate_cron(expr)?;
    let fields: Vec<&str> = expr.split_whitespace().collect();
    // POSIX cron ORs day-of-month and day-of-week when both are constrained
    // ("the 5th OR a Monday"); launchd ANDs every key in a StartCalendarInterval
    // dict ("the 5th AND a Monday"). Emitting both would silently change the
    // firing days, so refuse rather than mis-render — cron can still express it.
    if fields[2] != "*" && fields[4] != "*" {
        return Err(OsScheduleError::UnsupportedSchedule(
            "launchd can't express cron's OR of day-of-month and day-of-week; install on Linux/cron or split into two schedules".into(),
        ));
    }
    let keys = ["Minute", "Hour", "Day", "Month", "Weekday"];
    let mut dict = String::from("  <dict>\n");
    let mut any = false;
    for (field, key) in fields.iter().zip(keys) {
        if *field == "*" {
            continue;
        }
        let n: i64 = field.parse().map_err(|_| {
            OsScheduleError::UnsupportedSchedule(format!(
                "launchd StartCalendarInterval supports only `*` or a plain integer per field; {field:?} is not (use Interval, or install on Linux/cron)"
            ))
        })?;
        dict.push_str(&format!(
            "    <key>{key}</key>\n    <integer>{n}</integer>\n"
        ));
        any = true;
    }
    // An all-`*` cron ("* * * * *") means every minute → an empty calendar dict
    // would run constantly; require at least one constraint.
    if !any {
        return Err(OsScheduleError::UnsupportedSchedule(
            "an all-`*` cron has no calendar constraint; use Interval { seconds: 60 }".into(),
        ));
    }
    dict.push_str("  </dict>\n");
    Ok(dict)
}

/// Map an interval in seconds to a cron schedule, only when it divides evenly
/// enough to keep predictable spacing. Cron's floor is one minute.
fn interval_to_cron(seconds: u64) -> Result<String, OsScheduleError> {
    if seconds < 60 || seconds % 60 != 0 {
        return Err(OsScheduleError::UnsupportedSchedule(format!(
            "cron granularity is whole minutes; {seconds}s is not (use launchd StartInterval on macOS)"
        )));
    }
    let mins = seconds / 60;
    if mins < 60 {
        // `*/M` is only evenly spaced across the hour boundary when M divides 60.
        if 60 % mins == 0 {
            return Ok(format!("*/{mins} * * * *"));
        }
        return Err(OsScheduleError::UnsupportedSchedule(format!(
            "{mins}-minute interval doesn't divide 60 evenly (1,2,3,4,5,6,10,12,15,20,30 do); spacing would be irregular"
        )));
    }
    if mins == 60 {
        return Ok("0 * * * *".to_string());
    }
    if mins % 60 == 0 {
        let hours = mins / 60;
        if hours < 24 && 24 % hours == 0 {
            return Ok(format!("0 */{hours} * * *"));
        }
        if hours == 24 {
            return Ok("0 0 * * *".to_string());
        }
        return Err(OsScheduleError::UnsupportedSchedule(format!(
            "{hours}-hour interval doesn't divide 24 evenly"
        )));
    }
    Err(OsScheduleError::UnsupportedSchedule(format!(
        "{seconds}s isn't expressible as an evenly-spaced cron schedule; use launchd StartInterval"
    )))
}

/// Insert or remove a tagged line in an existing crontab body. Pure so the
/// crontab-editing logic is testable without touching the real crontab.
///
/// All lines bearing `# <label>` are first removed (idempotent replace); then,
/// unless `remove`, `line` is appended. Returns the new crontab text.
pub fn apply_crontab_edit(existing: &str, label: &str, line: Option<&str>) -> String {
    let tag = format!("# {label}");
    let mut out: Vec<String> = existing
        .lines()
        .filter(|l| !l.trim_end().ends_with(&tag))
        .map(|l| l.to_string())
        .collect();
    if let Some(line) = line {
        out.push(line.to_string());
    }
    let mut body = out.join("\n");
    if !body.is_empty() && !body.ends_with('\n') {
        body.push('\n');
    }
    body
}

/// Reject an empty command value or one carrying a control character. The
/// newline check is the load-bearing one: crontab parses line-by-line, so a
/// `\n` in any embedded value would split one logical entry into a second,
/// caller-controlled crontab line (command injection). `\r` and other control
/// chars are refused for the same class of reason and because they're never
/// legitimate in a program path or argument here.
fn validate_command_value(what: &str, value: &str) -> Result<(), OsScheduleError> {
    if value.is_empty() {
        return Err(OsScheduleError::InvalidValue(format!("{what} is empty")));
    }
    if let Some(c) = value.chars().find(|c| c.is_control()) {
        return Err(OsScheduleError::InvalidValue(format!(
            "{what} contains a control character (U+{:04X})",
            c as u32
        )));
    }
    Ok(())
}

/// Minimal XML text escaping for plist string values.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

/// Single-quote a token for a `/bin/sh` command line (cron runs via the shell).
fn shell_quote(s: &str) -> String {
    if !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '.' | '=' | ':'))
    {
        return s.to_string();
    }
    format!("'{}'", s.replace('\'', r"'\''"))
}

/// `~/Library/LaunchAgents` (macOS) — also the search dir for `list_installed`.
fn launch_agents_dir() -> PathBuf {
    home_dir().join("Library").join("LaunchAgents")
}

fn home_dir() -> PathBuf {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/tmp"))
}

// ---------------------------------------------------------------------------
// Install layer (side-effecting, cfg-gated per OS).
// ---------------------------------------------------------------------------

/// Where an OS schedule landed after [`OsScheduleSpec::install`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledSchedule {
    pub label: String,
    /// `"launchd"` or `"cron"`.
    pub backend: String,
    /// The plist path (launchd) or the crontab line (cron).
    pub detail: String,
}

#[cfg(target_os = "macos")]
impl OsScheduleSpec {
    /// Write the plist to `~/Library/LaunchAgents` and load it with `launchctl`.
    /// Idempotent: an existing agent for this label is unloaded and replaced.
    pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
        let plist = self.render_launchd_plist()?;
        let dir = launch_agents_dir();
        std::fs::create_dir_all(&dir)?;
        let path = self.launchd_plist_path();
        // Replace any prior load before rewriting the file.
        let _ = run_cmd("launchctl", &["unload".into(), path_str(&path)]);
        std::fs::write(&path, plist)?;
        run_cmd("launchctl", &["load".into(), "-w".into(), path_str(&path)])?;
        Ok(InstalledSchedule {
            label: self.label.clone(),
            backend: "launchd".into(),
            detail: path.display().to_string(),
        })
    }
}

#[cfg(target_os = "macos")]
pub fn uninstall(label: &str) -> Result<bool, OsScheduleError> {
    let path = launch_agents_dir().join(format!("{label}.plist"));
    if !path.exists() {
        return Ok(false);
    }
    let _ = run_cmd("launchctl", &["unload".into(), path_str(&path)]);
    std::fs::remove_file(&path)?;
    Ok(true)
}

#[cfg(target_os = "macos")]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
    let dir = launch_agents_dir();
    let mut labels = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for entry in entries.flatten() {
            if let Some(stem) = entry.path().file_stem().and_then(|s| s.to_str()) {
                if stem.starts_with(LABEL_PREFIX) {
                    labels.push(stem.to_string());
                }
            }
        }
    }
    labels.sort();
    Ok(labels)
}

#[cfg(all(unix, not(target_os = "macos")))]
impl OsScheduleSpec {
    /// Replace any prior crontab line for this label and append the new one via
    /// `crontab -`. Idempotent.
    pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
        let line = self.render_crontab_line()?;
        let existing = current_crontab();
        let updated = apply_crontab_edit(&existing, &self.label, Some(&line));
        write_crontab(&updated)?;
        Ok(InstalledSchedule {
            label: self.label.clone(),
            backend: "cron".into(),
            detail: line,
        })
    }
}

#[cfg(all(unix, not(target_os = "macos")))]
pub fn uninstall(label: &str) -> Result<bool, OsScheduleError> {
    let existing = current_crontab();
    let tag = format!("# {label}");
    let had = existing.lines().any(|l| l.trim_end().ends_with(&tag));
    if had {
        let updated = apply_crontab_edit(&existing, label, None);
        write_crontab(&updated)?;
    }
    Ok(had)
}

#[cfg(all(unix, not(target_os = "macos")))]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
    let mut labels: Vec<String> = current_crontab()
        .lines()
        .filter_map(|l| l.rsplit_once("# ").map(|(_, tag)| tag.trim().to_string()))
        .filter(|tag| tag.starts_with(LABEL_PREFIX))
        .collect();
    labels.sort();
    labels.dedup();
    Ok(labels)
}

#[cfg(all(unix, not(target_os = "macos")))]
fn current_crontab() -> String {
    std::process::Command::new("crontab")
        .arg("-l")
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
        .unwrap_or_default()
}

#[cfg(all(unix, not(target_os = "macos")))]
fn write_crontab(body: &str) -> Result<(), OsScheduleError> {
    use std::io::Write;
    let mut child = std::process::Command::new("crontab")
        .arg("-")
        .stdin(std::process::Stdio::piped())
        .spawn()?;
    child
        .stdin
        .take()
        .ok_or_else(|| OsScheduleError::Command("crontab".into(), "no stdin".into()))?
        .write_all(body.as_bytes())?;
    let status = child.wait()?;
    if !status.success() {
        return Err(OsScheduleError::Command(
            "crontab".into(),
            format!("exited with {status}"),
        ));
    }
    Ok(())
}

#[cfg(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
impl OsScheduleSpec {
    pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
        Err(OsScheduleError::UnsupportedPlatform)
    }
}

#[cfg(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
pub fn uninstall(_label: &str) -> Result<bool, OsScheduleError> {
    Err(OsScheduleError::UnsupportedPlatform)
}

#[cfg(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
    Err(OsScheduleError::UnsupportedPlatform)
}

#[cfg(target_os = "macos")]
fn path_str(p: &std::path::Path) -> String {
    p.display().to_string()
}

#[cfg(target_os = "macos")]
fn run_cmd(bin: &str, args: &[String]) -> Result<(), OsScheduleError> {
    let output = std::process::Command::new(bin).args(args).output()?;
    if !output.status.success() {
        return Err(OsScheduleError::Command(
            bin.to_string(),
            String::from_utf8_lossy(&output.stderr).trim().to_string(),
        ));
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Reconciliation — reap orphaned OS schedules whose backing task is gone or no
// longer OS-schedulable.
// ---------------------------------------------------------------------------

/// Outcome of a [`reconcile`] pass.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReconcileReport {
    /// Labels uninstalled because no live OS-schedulable task backs them.
    pub removed: Vec<String>,
    /// Installed CAR labels left in place (still backed by a schedulable task).
    pub kept: usize,
    /// Per-label uninstall failures (`"<label>: <error>"`). Best-effort: one
    /// failure doesn't abort the pass.
    pub errors: Vec<String>,
}

/// The OS-schedule label for `task` if its trigger is still OS-schedulable
/// (`interval`/`cron`); `None` for `once`/`manual`/`file_watch`. The labels a
/// reconcile pass keeps are exactly these.
pub fn schedulable_label(task: &Task) -> Option<String> {
    matches!(task.trigger, TaskTrigger::Interval | TaskTrigger::Cron)
        .then(|| format!("{LABEL_PREFIX}{}", task.id))
}

/// The set of labels that should stay installed for `tasks`. Pure — the
/// testable core of reconciliation.
pub fn schedulable_labels(tasks: &[Task]) -> BTreeSet<String> {
    tasks.iter().filter_map(schedulable_label).collect()
}

/// Of the `installed` labels, the CAR-managed ones (our prefix) not in `keep`.
/// Foreign labels are never returned, so reconciliation can't touch a schedule
/// CAR didn't create. Pure.
pub fn labels_to_remove(installed: &[String], keep: &BTreeSet<String>) -> Vec<String> {
    installed
        .iter()
        .filter(|l| l.starts_with(LABEL_PREFIX) && !keep.contains(*l))
        .cloned()
        .collect()
}

/// Uninstall every CAR-managed OS schedule not in `keep`. Best-effort: a failed
/// uninstall is recorded in [`ReconcileReport::errors`] and the pass continues.
/// On a platform with no scheduling backend this is a no-op (empty report).
///
/// `keep` is the set of labels that *should* remain — typically
/// [`schedulable_labels`] over the live [`TaskStore`](crate::TaskStore). For the
/// keep set to be authoritative, a schedule must only be installed for a task
/// that also lives in the store; the FFI install path enforces that by
/// persisting the task on install, so a label with no stored task is normally a
/// genuine orphan (its task was deleted), not an unpersisted-but-valid one.
///
/// Caveat: this function trusts `keep` — it does not itself read the store, so
/// it can't tell a legitimately-empty keep set from one produced by a failed
/// store read. Callers must distinguish those *before* calling (the FFI
/// `reconcile_os_schedules` uses [`TaskStore::try_list`](crate::TaskStore::try_list)
/// and refuses to reap on a read error). A store that is readable-but-empty
/// while CAR schedules exist (e.g. a restore that recovered `~/Library/LaunchAgents`
/// but not `~/.car/tasks`) will still reap — an accepted residual of the
/// store-is-authoritative design.
pub fn reconcile(keep: &BTreeSet<String>) -> Result<ReconcileReport, OsScheduleError> {
    let installed = match list_installed() {
        Ok(v) => v,
        // No backend on this platform → nothing to reconcile.
        Err(OsScheduleError::UnsupportedPlatform) => return Ok(ReconcileReport::default()),
        Err(e) => return Err(e),
    };
    let mut report = ReconcileReport {
        kept: installed.iter().filter(|l| keep.contains(*l)).count(),
        ..Default::default()
    };
    for label in labels_to_remove(&installed, keep) {
        match uninstall(&label) {
            Ok(_) => report.removed.push(label),
            Err(e) => report.errors.push(format!("{label}: {e}")),
        }
    }
    Ok(report)
}

/// Reconcile installed OS schedules against a live task list — the boot/daemon
/// entry point. Removes schedules whose task was deleted or whose trigger is no
/// longer OS-schedulable.
pub fn reconcile_with_tasks(tasks: &[Task]) -> Result<ReconcileReport, OsScheduleError> {
    reconcile(&schedulable_labels(tasks))
}

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

    fn spec(trigger: OsTrigger) -> OsScheduleSpec {
        OsScheduleSpec {
            label: "ai.parslee.car.task.abc123".into(),
            program: "/usr/local/bin/car".into(),
            args: vec!["task".into(), "run".into(), "abc123".into()],
            working_dir: None,
            log_path: Some("/tmp/car/abc123.log".into()),
            trigger,
        }
    }

    #[test]
    fn from_task_rejects_non_recurring_triggers() {
        let t = Task::new("x", "p").with_trigger(TaskTrigger::Manual, "");
        assert!(matches!(
            OsScheduleSpec::from_task(&t, "car", vec![]),
            Err(OsScheduleError::NotSchedulable(TaskTrigger::Manual))
        ));
        let once = Task::new("x", "p"); // defaults to Manual; force Once
        let mut once = once;
        once.trigger = TaskTrigger::Once;
        assert!(OsScheduleSpec::from_task(&once, "car", vec![]).is_err());
    }

    #[test]
    fn from_task_maps_interval_and_cron() {
        let iv = Task::new("x", "p").with_trigger(TaskTrigger::Interval, "5m");
        let s = OsScheduleSpec::from_task(&iv, "car", vec!["run".into()]).unwrap();
        assert_eq!(s.trigger, OsTrigger::Interval { seconds: 300 });
        assert_eq!(s.label, format!("{LABEL_PREFIX}{}", iv.id));

        let cr = Task::new("x", "p").with_trigger(TaskTrigger::Cron, "0 9 * * 1");
        let s = OsScheduleSpec::from_task(&cr, "car", vec![]).unwrap();
        assert_eq!(
            s.trigger,
            OsTrigger::Cron {
                expr: "0 9 * * 1".into()
            }
        );
    }

    #[test]
    fn launchd_interval_uses_start_interval() {
        let plist = spec(OsTrigger::Interval { seconds: 300 })
            .render_launchd_plist()
            .unwrap();
        assert!(plist.contains("<key>StartInterval</key>"));
        assert!(plist.contains("<integer>300</integer>"));
        assert!(plist.contains("<string>ai.parslee.car.task.abc123</string>"));
        assert!(plist.contains("<string>/usr/local/bin/car</string>"));
        assert!(plist.contains("<key>StandardErrorPath</key>"));
        assert!(plist.contains("<key>RunAtLoad</key>\n  <false/>"));
    }

    #[test]
    fn launchd_cron_renders_calendar_interval() {
        let plist = spec(OsTrigger::Cron {
            expr: "30 9 * * *".into(),
        })
        .render_launchd_plist()
        .unwrap();
        assert!(plist.contains("<key>StartCalendarInterval</key>"));
        assert!(plist.contains("<key>Minute</key>\n    <integer>30</integer>"));
        assert!(plist.contains("<key>Hour</key>\n    <integer>9</integer>"));
        // `*` day/month/weekday are omitted.
        assert!(!plist.contains("<key>Day</key>"));
    }

    #[test]
    fn launchd_cron_rejects_step_expressions() {
        let err = spec(OsTrigger::Cron {
            expr: "*/15 * * * *".into(),
        })
        .render_launchd_plist()
        .unwrap_err();
        assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
    }

    #[test]
    fn launchd_cron_rejects_all_star() {
        let err = spec(OsTrigger::Cron {
            expr: "* * * * *".into(),
        })
        .render_launchd_plist()
        .unwrap_err();
        assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
    }

    #[test]
    fn crontab_line_for_cron_is_verbatim_and_tagged() {
        let line = spec(OsTrigger::Cron {
            expr: "0 9 * * 1".into(),
        })
        .render_crontab_line()
        .unwrap();
        assert!(line.starts_with("0 9 * * 1 "));
        assert!(line.contains("/usr/local/bin/car task run abc123"));
        assert!(line.ends_with("# ai.parslee.car.task.abc123"));
        assert!(line.contains(">> /tmp/car/abc123.log 2>&1"));
    }

    #[test]
    fn interval_to_cron_divisors() {
        assert_eq!(interval_to_cron(300).unwrap(), "*/5 * * * *");
        assert_eq!(interval_to_cron(60).unwrap(), "*/1 * * * *");
        assert_eq!(interval_to_cron(1800).unwrap(), "*/30 * * * *");
        assert_eq!(interval_to_cron(3600).unwrap(), "0 * * * *");
        assert_eq!(interval_to_cron(7200).unwrap(), "0 */2 * * *");
        assert_eq!(interval_to_cron(86400).unwrap(), "0 0 * * *");
    }

    #[test]
    fn interval_to_cron_rejects_inexpressible() {
        assert!(interval_to_cron(30).is_err()); // sub-minute
        assert!(interval_to_cron(90).is_err()); // not whole minutes
        assert!(interval_to_cron(2520).is_err()); // 42 min, doesn't divide 60
        assert!(interval_to_cron(18000).is_err()); // 5h, doesn't divide 24
    }

    #[test]
    fn crontab_edit_replaces_idempotently() {
        let label = "ai.parslee.car.task.x";
        let base = "MAILTO=me\n0 0 * * * /bin/true # ai.parslee.car.task.x\n@reboot /bin/other\n";
        // Replace: the old tagged line is gone, the new one present, untagged kept.
        let line = "*/5 * * * * /usr/bin/car run x # ai.parslee.car.task.x";
        let out = apply_crontab_edit(base, label, Some(line));
        assert_eq!(out.matches("# ai.parslee.car.task.x").count(), 1);
        assert!(out.contains("*/5 * * * * /usr/bin/car run x"));
        assert!(out.contains("MAILTO=me"));
        assert!(out.contains("@reboot /bin/other"));

        // Remove: drops our line, leaves the rest.
        let removed = apply_crontab_edit(&out, label, None);
        assert!(!removed.contains("car run x"));
        assert!(removed.contains("@reboot /bin/other"));
    }

    #[test]
    fn shell_quote_escapes_specials() {
        assert_eq!(shell_quote("car"), "car");
        assert_eq!(shell_quote("/usr/bin/car"), "/usr/bin/car");
        assert_eq!(shell_quote("a b"), "'a b'");
        assert_eq!(shell_quote("it's"), r"'it'\''s'");
    }

    #[test]
    fn xml_escape_handles_entities() {
        assert_eq!(xml_escape("a & b < c"), "a &amp; b &lt; c");
    }

    #[test]
    fn schedulable_labels_only_includes_interval_and_cron() {
        let iv = Task::new("a", "p").with_trigger(TaskTrigger::Interval, "5m");
        let cr = Task::new("b", "p").with_trigger(TaskTrigger::Cron, "0 9 * * *");
        let manual = Task::new("c", "p"); // defaults to Manual
        let mut once = Task::new("d", "p");
        once.trigger = TaskTrigger::Once;

        let labels = schedulable_labels(&[iv.clone(), cr.clone(), manual, once]);
        assert_eq!(labels.len(), 2);
        assert!(labels.contains(&format!("{LABEL_PREFIX}{}", iv.id)));
        assert!(labels.contains(&format!("{LABEL_PREFIX}{}", cr.id)));
    }

    #[test]
    fn labels_to_remove_reaps_only_orphaned_car_labels() {
        let keep: BTreeSet<String> = [format!("{LABEL_PREFIX}live")].into_iter().collect();
        let installed = vec![
            format!("{LABEL_PREFIX}live"),          // backed by a task → kept
            format!("{LABEL_PREFIX}gone"),          // no task → reaped
            "com.example.someone-else".to_string(), // foreign → never touched
        ];
        let remove = labels_to_remove(&installed, &keep);
        assert_eq!(remove, vec![format!("{LABEL_PREFIX}gone")]);
    }

    #[test]
    fn labels_to_remove_empty_keep_reaps_all_car_labels_but_not_foreign() {
        let installed = vec![
            format!("{LABEL_PREFIX}x"),
            format!("{LABEL_PREFIX}y"),
            "other.tool.job".to_string(),
        ];
        let remove = labels_to_remove(&installed, &BTreeSet::new());
        assert_eq!(remove.len(), 2);
        assert!(!remove.iter().any(|l| l == "other.tool.job"));
    }

    #[test]
    fn crontab_rejects_newline_injection() {
        let mut s = spec(OsTrigger::Cron {
            expr: "0 9 * * *".into(),
        });
        s.args = vec!["x\n*/1 * * * * /bin/evil".into()];
        let err = s.render_crontab_line().unwrap_err();
        assert!(matches!(err, OsScheduleError::InvalidValue(_)));
        // The plist path rejects it too (defense in depth, even though XML is
        // structurally safe).
        assert!(matches!(
            s.render_launchd_plist().unwrap_err(),
            OsScheduleError::InvalidValue(_)
        ));
    }

    #[test]
    fn empty_program_rejected_at_construction() {
        let t = Task::new("x", "p").with_trigger(TaskTrigger::Interval, "5m");
        assert!(matches!(
            OsScheduleSpec::from_task(&t, "", vec![]),
            Err(OsScheduleError::InvalidValue(_))
        ));
    }

    #[test]
    fn launchd_rejects_dom_and_dow_both_constrained() {
        // cron "9am on the 5th OR a Monday" can't be ANDed by launchd.
        let err = spec(OsTrigger::Cron {
            expr: "0 9 5 * 1".into(),
        })
        .render_launchd_plist()
        .unwrap_err();
        assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
        // But cron renders it fine (OR semantics).
        assert!(spec(OsTrigger::Cron {
            expr: "0 9 5 * 1".into()
        })
        .render_crontab_line()
        .is_ok());
        // Only one of dom/dow constrained is still fine for launchd.
        assert!(spec(OsTrigger::Cron {
            expr: "0 9 5 * *".into()
        })
        .render_launchd_plist()
        .is_ok());
    }
}
