use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

use kbolt_types::{
    KboltError, ScheduleDefinition, ScheduleIntervalUnit, ScheduleTrigger, ScheduleWeekday,
};

use super::{command_failure, write_if_changed, BackendInspection, CommandRunner};
use crate::schedule_support::{parse_canonical_schedule_time, schedule_id_sort_key};
use crate::Result;

const MANAGED_UNIT_PREFIX: &str = "kbolt-schedule-";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemdUserPaths {
    pub unit_dir: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemdServiceUnit {
    pub schedule_id: String,
    pub name: String,
    pub path: PathBuf,
    pub contents: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemdTimerUnit {
    pub schedule_id: String,
    pub name: String,
    pub path: PathBuf,
    pub contents: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemdPlan {
    pub services: Vec<SystemdServiceUnit>,
    pub timers: Vec<SystemdTimerUnit>,
    pub stale_paths: Vec<PathBuf>,
}

pub(crate) fn plan_systemd_user(
    paths: &SystemdUserPaths,
    schedules: &[ScheduleDefinition],
    executable: &Path,
) -> Result<SystemdPlan> {
    let mut services = Vec::with_capacity(schedules.len());
    let mut timers = Vec::with_capacity(schedules.len());
    for schedule in schedules {
        let service_name = systemd_service_name(&schedule.id);
        let timer_name = systemd_timer_name(&schedule.id);
        services.push(SystemdServiceUnit {
            schedule_id: schedule.id.clone(),
            name: service_name.clone(),
            path: paths.unit_dir.join(&service_name),
            contents: render_systemd_service(schedule, executable),
        });
        timers.push(SystemdTimerUnit {
            schedule_id: schedule.id.clone(),
            name: timer_name.clone(),
            path: paths.unit_dir.join(&timer_name),
            contents: render_systemd_timer(schedule)?,
        });
    }
    services.sort_by(|left, right| left.name.cmp(&right.name));
    timers.sort_by(|left, right| left.name.cmp(&right.name));

    let desired_paths = services
        .iter()
        .map(|unit| unit.path.clone())
        .chain(timers.iter().map(|unit| unit.path.clone()))
        .collect::<HashSet<_>>();

    let mut stale_paths = Vec::new();
    if let Ok(entries) = fs::read_dir(&paths.unit_dir) {
        for entry in entries {
            let entry = entry?;
            let path = entry.path();
            if !is_managed_systemd_unit(&path) {
                continue;
            }
            if !desired_paths.contains(&path) {
                stale_paths.push(path);
            }
        }
    }
    stale_paths.sort();

    Ok(SystemdPlan {
        services,
        timers,
        stale_paths,
    })
}

pub(crate) fn inspect_systemd_user(
    paths: &SystemdUserPaths,
    schedules: &[ScheduleDefinition],
    executable: &Path,
    runner: &dyn CommandRunner,
) -> Result<BackendInspection> {
    let plan = plan_systemd_user(paths, schedules, executable)?;
    let mut drifted_ids = HashSet::new();
    let planned_services = plan
        .services
        .iter()
        .map(|service| (service.schedule_id.as_str(), service))
        .collect::<HashMap<_, _>>();
    let planned_timers = plan
        .timers
        .iter()
        .map(|timer| (timer.schedule_id.as_str(), timer))
        .collect::<HashMap<_, _>>();

    for schedule in schedules {
        let service = planned_services.get(schedule.id.as_str()).ok_or_else(|| {
            KboltError::Internal(format!(
                "planned systemd service missing for schedule {}",
                schedule.id
            ))
        })?;
        let timer = planned_timers.get(schedule.id.as_str()).ok_or_else(|| {
            KboltError::Internal(format!(
                "planned systemd timer missing for schedule {}",
                schedule.id
            ))
        })?;
        if systemd_unit_is_drifted(&service.path, &service.contents)?
            || systemd_unit_is_drifted(&timer.path, &timer.contents)?
            || !is_systemd_timer_enabled(runner, &timer.name)?
        {
            drifted_ids.insert(schedule.id.clone());
        }
    }

    let orphan_ids = plan
        .stale_paths
        .iter()
        .filter_map(|path| schedule_id_from_systemd_path(path))
        .collect::<HashSet<_>>()
        .into_iter()
        .collect::<Vec<_>>();
    let mut orphan_ids = orphan_ids;
    orphan_ids.sort_by_key(|id| schedule_id_sort_key(id));

    Ok(BackendInspection {
        drifted_ids,
        orphan_ids,
    })
}

pub(crate) fn reconcile_systemd_user(
    paths: &SystemdUserPaths,
    schedules: &[ScheduleDefinition],
    executable: &Path,
    runner: &dyn CommandRunner,
) -> Result<()> {
    fs::create_dir_all(&paths.unit_dir)?;

    let plan = plan_systemd_user(paths, schedules, executable)?;
    let mut changed_backend = false;
    let stale_ids = plan
        .stale_paths
        .iter()
        .filter_map(|path| schedule_id_from_systemd_path(path))
        .collect::<HashSet<_>>();

    for schedule_id in &stale_ids {
        disable_systemd_timer(runner, &systemd_timer_name(schedule_id))?;
    }

    for stale_path in &plan.stale_paths {
        match fs::remove_file(stale_path) {
            Ok(()) => changed_backend = true,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => return Err(err.into()),
        }
    }

    for service in &plan.services {
        changed_backend |= write_if_changed(&service.path, &service.contents)?;
    }

    for timer in &plan.timers {
        changed_backend |= write_if_changed(&timer.path, &timer.contents)?;
    }

    if changed_backend {
        let output = runner.run("systemctl", &["--user", "daemon-reload"])?;
        if !output.success {
            return Err(command_failure("systemctl", &["--user", "daemon-reload"], &output).into());
        }
    }

    for timer in &plan.timers {
        let output = runner.run("systemctl", &["--user", "enable", "--now", &timer.name])?;
        if !output.success {
            return Err(command_failure(
                "systemctl",
                &["--user", "enable", "--now", &timer.name],
                &output,
            )
            .into());
        }
    }

    Ok(())
}

pub(crate) fn render_systemd_service(schedule: &ScheduleDefinition, executable: &Path) -> String {
    format!(
        "[Unit]\nDescription=Run kbolt schedule {schedule_id}\n\n[Service]\nType=oneshot\nExecStart={} __schedule-run {}\n",
        shell_escape(executable),
        shell_escape_arg(&schedule.id),
        schedule_id = schedule.id,
    )
}

pub(crate) fn render_systemd_timer(schedule: &ScheduleDefinition) -> Result<String> {
    let trigger = match &schedule.trigger {
        ScheduleTrigger::Every { interval } => {
            let value = interval.value;
            let unit = match interval.unit {
                ScheduleIntervalUnit::Minutes => "min",
                ScheduleIntervalUnit::Hours => "h",
            };
            format!("OnBootSec={value}{unit}\nOnUnitActiveSec={value}{unit}\n")
        }
        ScheduleTrigger::Daily { time } => {
            let calendar = systemd_calendar(None, time)?;
            format!("OnCalendar={calendar}\nPersistent=true\n")
        }
        ScheduleTrigger::Weekly { weekdays, time } => {
            let calendar = systemd_calendar(Some(weekdays), time)?;
            format!("OnCalendar={calendar}\nPersistent=true\n")
        }
    };

    Ok(format!(
        "[Unit]\nDescription=Run kbolt schedule {schedule_id}\n\n[Timer]\n{trigger}Unit={service_name}\n\n[Install]\nWantedBy=timers.target\n",
        schedule_id = schedule.id,
        service_name = systemd_service_name(&schedule.id),
    ))
}

fn systemd_service_name(schedule_id: &str) -> String {
    format!("{MANAGED_UNIT_PREFIX}{schedule_id}.service")
}

fn systemd_timer_name(schedule_id: &str) -> String {
    format!("{MANAGED_UNIT_PREFIX}{schedule_id}.timer")
}

fn is_managed_systemd_unit(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| {
            name.starts_with(MANAGED_UNIT_PREFIX)
                && (name.ends_with(".service") || name.ends_with(".timer"))
        })
}

fn systemd_unit_is_drifted(path: &Path, expected: &str) -> Result<bool> {
    match fs::read_to_string(path) {
        Ok(existing) => Ok(existing != expected),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(true),
        Err(err) => Err(err.into()),
    }
}

fn is_systemd_timer_enabled(runner: &dyn CommandRunner, timer_name: &str) -> Result<bool> {
    let output = runner.run("systemctl", &["--user", "is-enabled", timer_name])?;
    if output.success {
        return Ok(true);
    }

    if is_missing_systemd_unit(&output.stdout) || is_missing_systemd_unit(&output.stderr) {
        return Ok(false);
    }

    if output.stdout.trim() == "disabled" {
        return Ok(false);
    }

    Err(command_failure("systemctl", &["--user", "is-enabled", timer_name], &output).into())
}

fn disable_systemd_timer(runner: &dyn CommandRunner, timer_name: &str) -> Result<()> {
    let output = runner.run("systemctl", &["--user", "disable", "--now", timer_name])?;
    if output.success
        || is_missing_systemd_unit(&output.stdout)
        || is_missing_systemd_unit(&output.stderr)
    {
        return Ok(());
    }

    Err(command_failure(
        "systemctl",
        &["--user", "disable", "--now", timer_name],
        &output,
    )
    .into())
}

fn is_missing_systemd_unit(output: &str) -> bool {
    let normalized = output.to_ascii_lowercase();
    normalized.contains("not loaded")
        || normalized.contains("not found")
        || normalized.contains("no such file")
}

fn schedule_id_from_systemd_path(path: &Path) -> Option<String> {
    path.file_name()
        .and_then(|name| name.to_str())
        .and_then(|name| {
            name.strip_prefix(MANAGED_UNIT_PREFIX).and_then(|rest| {
                rest.strip_suffix(".service")
                    .or_else(|| rest.strip_suffix(".timer"))
            })
        })
        .map(ToString::to_string)
}

fn systemd_calendar(weekdays: Option<&[ScheduleWeekday]>, time: &str) -> Result<String> {
    let (hour, minute) = parse_canonical_schedule_time(time)?;
    let hhmmss = format!("{hour:02}:{minute:02}:00");
    match weekdays {
        Some(days) => Ok(format!(
            "{} *-*-* {hhmmss}",
            days.iter()
                .map(systemd_weekday)
                .collect::<Vec<_>>()
                .join(",")
        )),
        None => Ok(format!("*-*-* {hhmmss}")),
    }
}

fn systemd_weekday(weekday: &ScheduleWeekday) -> &'static str {
    match weekday {
        ScheduleWeekday::Mon => "Mon",
        ScheduleWeekday::Tue => "Tue",
        ScheduleWeekday::Wed => "Wed",
        ScheduleWeekday::Thu => "Thu",
        ScheduleWeekday::Fri => "Fri",
        ScheduleWeekday::Sat => "Sat",
        ScheduleWeekday::Sun => "Sun",
    }
}

fn shell_escape(path: &Path) -> String {
    shell_escape_arg(&path.display().to_string())
}

fn shell_escape_arg(value: &str) -> String {
    let escaped = value.replace('\'', "'\\''");
    format!("'{escaped}'")
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;
    use std::path::Path;
    use std::sync::Mutex;

    use tempfile::tempdir;

    use super::{
        inspect_systemd_user, plan_systemd_user, reconcile_systemd_user, render_systemd_service,
        render_systemd_timer, SystemdUserPaths,
    };
    use crate::schedule_backend::{CommandOutput, CommandRunner};
    use kbolt_types::{
        ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleScope, ScheduleTrigger,
        ScheduleWeekday,
    };

    #[test]
    fn render_systemd_service_and_interval_timer() {
        let schedule = ScheduleDefinition {
            id: "s1".to_string(),
            trigger: ScheduleTrigger::Every {
                interval: ScheduleInterval {
                    value: 30,
                    unit: ScheduleIntervalUnit::Minutes,
                },
            },
            scope: ScheduleScope::All,
        };

        let service = render_systemd_service(&schedule, Path::new("/usr/local/bin/kbolt"));
        let timer = render_systemd_timer(&schedule).expect("render timer");

        assert!(service.contains("ExecStart='/usr/local/bin/kbolt' __schedule-run 's1'"));
        assert!(timer.contains("OnBootSec=30min"));
        assert!(timer.contains("OnUnitActiveSec=30min"));
        assert!(timer.contains("Unit=kbolt-schedule-s1.service"));
        assert!(timer.contains("WantedBy=timers.target"));
    }

    #[test]
    fn render_systemd_timer_for_weekly_schedule_uses_on_calendar() {
        let timer = render_systemd_timer(&ScheduleDefinition {
            id: "s2".to_string(),
            trigger: ScheduleTrigger::Weekly {
                weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
                time: "15:00".to_string(),
            },
            scope: ScheduleScope::Space {
                space: "work".to_string(),
            },
        })
        .expect("render weekly timer");

        assert!(timer.contains("OnCalendar=Mon,Fri *-*-* 15:00:00"));
        assert!(timer.contains("Persistent=true"));
    }

    #[test]
    fn plan_systemd_user_detects_stale_managed_units() {
        let tmp = tempdir().expect("create tempdir");
        let paths = SystemdUserPaths {
            unit_dir: tmp.path().join("systemd-user"),
        };
        std::fs::create_dir_all(&paths.unit_dir).expect("create unit dir");
        std::fs::write(
            paths.unit_dir.join("kbolt-schedule-s9.service"),
            "stale service",
        )
        .expect("write stale service");
        std::fs::write(
            paths.unit_dir.join("kbolt-schedule-s9.timer"),
            "stale timer",
        )
        .expect("write stale timer");
        std::fs::write(paths.unit_dir.join("foreign.timer"), "foreign timer")
            .expect("write foreign timer");

        let plan = plan_systemd_user(
            &paths,
            &[ScheduleDefinition {
                id: "s1".to_string(),
                trigger: ScheduleTrigger::Daily {
                    time: "09:00".to_string(),
                },
                scope: ScheduleScope::All,
            }],
            Path::new("/usr/local/bin/kbolt"),
        )
        .expect("plan systemd units");

        assert_eq!(plan.services.len(), 1);
        assert_eq!(plan.timers.len(), 1);
        assert_eq!(plan.services[0].name, "kbolt-schedule-s1.service");
        assert_eq!(plan.timers[0].name, "kbolt-schedule-s1.timer");
        assert_eq!(
            plan.stale_paths,
            vec![
                paths.unit_dir.join("kbolt-schedule-s9.service"),
                paths.unit_dir.join("kbolt-schedule-s9.timer"),
            ]
        );
    }

    #[test]
    fn inspect_systemd_marks_missing_units_as_drifted_and_extra_units_as_orphans() {
        let tmp = tempdir().expect("create tempdir");
        let schedule = ScheduleDefinition {
            id: "s1".to_string(),
            trigger: ScheduleTrigger::Daily {
                time: "09:00".to_string(),
            },
            scope: ScheduleScope::All,
        };
        let paths = SystemdUserPaths {
            unit_dir: tmp.path().join("systemd-user"),
        };
        std::fs::create_dir_all(&paths.unit_dir).expect("create unit dir");
        std::fs::write(
            paths.unit_dir.join("kbolt-schedule-s9.timer"),
            "orphan timer",
        )
        .expect("write orphan timer");

        let inspection = inspect_systemd_user(
            &paths,
            &[schedule],
            Path::new("/usr/local/bin/kbolt"),
            &NoopRunner,
        )
        .expect("inspect systemd");

        assert_eq!(inspection.drifted_ids, HashSet::from(["s1".to_string()]));
        assert_eq!(inspection.orphan_ids, vec!["s9".to_string()]);
    }

    #[test]
    fn inspect_systemd_matches_units_by_schedule_id_when_ids_reach_double_digits() {
        let tmp = tempdir().expect("create tempdir");
        let schedules = vec![
            ScheduleDefinition {
                id: "s2".to_string(),
                trigger: ScheduleTrigger::Daily {
                    time: "09:00".to_string(),
                },
                scope: ScheduleScope::All,
            },
            ScheduleDefinition {
                id: "s10".to_string(),
                trigger: ScheduleTrigger::Daily {
                    time: "10:00".to_string(),
                },
                scope: ScheduleScope::All,
            },
        ];
        let paths = SystemdUserPaths {
            unit_dir: tmp.path().join("systemd-user"),
        };
        std::fs::create_dir_all(&paths.unit_dir).expect("create unit dir");

        let plan =
            plan_systemd_user(&paths, &schedules, Path::new("/usr/local/bin/kbolt")).expect("plan");
        let s2_service = plan
            .services
            .iter()
            .find(|service| service.schedule_id == "s2")
            .expect("s2 service");
        let s2_timer = plan
            .timers
            .iter()
            .find(|timer| timer.schedule_id == "s2")
            .expect("s2 timer");
        std::fs::write(&s2_service.path, &s2_service.contents).expect("write s2 service");
        std::fs::write(&s2_timer.path, &s2_timer.contents).expect("write s2 timer");

        let inspection = inspect_systemd_user(
            &paths,
            &schedules,
            Path::new("/usr/local/bin/kbolt"),
            &NoopRunner,
        )
        .expect("inspect systemd");

        assert_eq!(inspection.drifted_ids, HashSet::from(["s10".to_string()]));
    }

    #[test]
    fn reconcile_systemd_writes_units_and_enables_timers() {
        let tmp = tempdir().expect("create tempdir");
        let schedule = ScheduleDefinition {
            id: "s1".to_string(),
            trigger: ScheduleTrigger::Every {
                interval: ScheduleInterval {
                    value: 30,
                    unit: ScheduleIntervalUnit::Minutes,
                },
            },
            scope: ScheduleScope::All,
        };
        let paths = SystemdUserPaths {
            unit_dir: tmp.path().join("systemd-user"),
        };
        let runner = RecordingRunner::new();

        reconcile_systemd_user(
            &paths,
            &[schedule],
            Path::new("/usr/local/bin/kbolt"),
            &runner,
        )
        .expect("reconcile systemd");

        assert!(paths.unit_dir.join("kbolt-schedule-s1.service").exists());
        assert!(paths.unit_dir.join("kbolt-schedule-s1.timer").exists());
        assert_eq!(
            runner.commands(),
            vec![
                vec![
                    "systemctl".to_string(),
                    "--user".to_string(),
                    "daemon-reload".to_string(),
                ],
                vec![
                    "systemctl".to_string(),
                    "--user".to_string(),
                    "enable".to_string(),
                    "--now".to_string(),
                    "kbolt-schedule-s1.timer".to_string(),
                ],
            ]
        );
    }

    struct NoopRunner;

    impl CommandRunner for NoopRunner {
        fn run(&self, _program: &str, _args: &[&str]) -> crate::Result<CommandOutput> {
            Ok(CommandOutput {
                success: true,
                stdout: String::new(),
                stderr: String::new(),
            })
        }
    }

    struct RecordingRunner {
        commands: Mutex<Vec<Vec<String>>>,
    }

    impl RecordingRunner {
        fn new() -> Self {
            Self {
                commands: Mutex::new(Vec::new()),
            }
        }

        fn commands(&self) -> Vec<Vec<String>> {
            self.commands.lock().expect("lock runner").clone()
        }
    }

    impl CommandRunner for RecordingRunner {
        fn run(&self, program: &str, args: &[&str]) -> crate::Result<CommandOutput> {
            self.commands.lock().expect("lock runner").push(
                std::iter::once(program.to_string())
                    .chain(args.iter().map(|arg| arg.to_string()))
                    .collect(),
            );

            Ok(CommandOutput {
                success: true,
                stdout: String::new(),
                stderr: String::new(),
            })
        }
    }
}
