use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;

use serde::Serialize;

use super::error::{LoreError, Result};
use super::paths::get_data_dir;

const CRON_TAG: &str = "# lore-sync";

// ── File-based sync lock (fcntl F_SETLK) ──

/// RAII guard that holds an `fcntl` write lock on a file.
/// The lock is released when the guard is dropped.
pub struct SyncLockGuard {
    _file: File,
}

/// Try to acquire an exclusive file lock (non-blocking).
///
/// Returns `Ok(Some(guard))` if the lock was acquired, `Ok(None)` if another
/// process holds it, or `Err` on I/O failure.
#[cfg(unix)]
pub fn acquire_sync_lock() -> Result<Option<SyncLockGuard>> {
    acquire_sync_lock_at(&lock_path())
}

fn lock_path() -> PathBuf {
    get_data_dir().join("sync.lock")
}

#[cfg(unix)]
fn acquire_sync_lock_at(path: &Path) -> Result<Option<SyncLockGuard>> {
    use std::os::unix::io::AsRawFd;

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let file = File::options()
        .create(true)
        .truncate(false)
        .write(true)
        .open(path)?;

    let fd = file.as_raw_fd();

    // SAFETY: zeroed memory is valid for libc::flock (all-zero is a valid
    // representation on every Unix platform). We then set only the fields we need.
    let mut flock = unsafe { std::mem::zeroed::<libc::flock>() };
    flock.l_type = libc::F_WRLCK as libc::c_short;
    flock.l_whence = libc::SEEK_SET as libc::c_short;

    // SAFETY: fd is a valid open file descriptor; flock is stack-allocated.
    let rc = unsafe { libc::fcntl(fd, libc::F_SETLK, &mut flock) };
    if rc == -1 {
        let err = io::Error::last_os_error();
        if err.kind() == io::ErrorKind::WouldBlock
            || err.raw_os_error() == Some(libc::EAGAIN)
            || err.raw_os_error() == Some(libc::EACCES)
        {
            return Ok(None);
        }
        return Err(LoreError::Io(err));
    }

    Ok(Some(SyncLockGuard { _file: file }))
}

// ── Crontab management ──

/// The crontab entry that `lore cron install` writes.
///
/// Paths are single-quoted so spaces in binary or log paths don't break
/// the cron expression.
pub fn build_cron_entry(interval_minutes: u32) -> String {
    let binary = std::env::current_exe()
        .unwrap_or_else(|_| PathBuf::from("lore"))
        .display()
        .to_string();
    let log_path = sync_log_path();
    format!(
        "*/{interval_minutes} * * * * '{binary}' sync -q --lock >> '{log}' 2>&1 {CRON_TAG}",
        log = log_path.display(),
    )
}

/// Path where cron-triggered sync output is appended.
pub fn sync_log_path() -> PathBuf {
    get_data_dir().join("sync.log")
}

/// Read the current user crontab. Returns empty string when no crontab exists.
fn read_crontab() -> Result<String> {
    let output = Command::new("crontab").arg("-l").output()?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
    } else {
        // exit 1 with "no crontab for <user>" is normal — treat as empty
        Ok(String::new())
    }
}

/// Write a full crontab string. Replaces the current crontab entirely.
fn write_crontab(content: &str) -> Result<()> {
    let mut child = Command::new("crontab")
        .arg("-")
        .stdin(std::process::Stdio::piped())
        .spawn()?;
    if let Some(ref mut stdin) = child.stdin {
        stdin.write_all(content.as_bytes())?;
    }
    let status = child.wait()?;
    if !status.success() {
        return Err(LoreError::Other(format!(
            "crontab exited with status {status}"
        )));
    }
    Ok(())
}

/// Install (or update) the lore-sync crontab entry.
pub fn install_cron(interval_minutes: u32) -> Result<CronInstallResult> {
    let entry = build_cron_entry(interval_minutes);

    let existing = read_crontab()?;
    let replaced = existing.contains(CRON_TAG);

    // Strip ALL old lore-sync lines first, then append one new entry.
    // This is idempotent even if the crontab somehow has duplicate tagged lines.
    let mut filtered: String = existing
        .lines()
        .filter(|line| !line.contains(CRON_TAG))
        .collect::<Vec<_>>()
        .join("\n");
    if !filtered.is_empty() && !filtered.ends_with('\n') {
        filtered.push('\n');
    }
    filtered.push_str(&entry);
    filtered.push('\n');

    write_crontab(&filtered)?;

    Ok(CronInstallResult {
        entry,
        interval_minutes,
        log_path: sync_log_path(),
        replaced,
    })
}

/// Remove the lore-sync crontab entry.
pub fn uninstall_cron() -> Result<CronUninstallResult> {
    let existing = read_crontab()?;
    if !existing.contains(CRON_TAG) {
        return Ok(CronUninstallResult {
            was_installed: false,
        });
    }

    let new_crontab: String = existing
        .lines()
        .filter(|line| !line.contains(CRON_TAG))
        .collect::<Vec<_>>()
        .join("\n")
        + "\n";

    // If the crontab would be empty (only whitespace), remove it entirely
    if new_crontab.trim().is_empty() {
        let status = Command::new("crontab").arg("-r").status()?;
        if !status.success() {
            return Err(LoreError::Other("crontab -r failed".to_string()));
        }
    } else {
        write_crontab(&new_crontab)?;
    }

    Ok(CronUninstallResult {
        was_installed: true,
    })
}

/// Inspect the current crontab for a lore-sync entry.
pub fn cron_status() -> Result<CronStatusResult> {
    let existing = read_crontab()?;
    let lore_line = existing.lines().find(|l| l.contains(CRON_TAG));

    match lore_line {
        Some(line) => {
            let interval = parse_interval(line);
            let binary_path = parse_binary_path(line);

            let current_exe = std::env::current_exe()
                .ok()
                .map(|p| p.display().to_string());
            let binary_mismatch = current_exe
                .as_ref()
                .zip(binary_path.as_ref())
                .is_some_and(|(current, cron)| current != cron);

            Ok(CronStatusResult {
                installed: true,
                interval_minutes: interval,
                binary_path,
                current_binary: current_exe,
                binary_mismatch,
                log_path: Some(sync_log_path()),
                cron_entry: Some(line.to_string()),
            })
        }
        None => Ok(CronStatusResult {
            installed: false,
            interval_minutes: None,
            binary_path: None,
            current_binary: std::env::current_exe()
                .ok()
                .map(|p| p.display().to_string()),
            binary_mismatch: false,
            log_path: None,
            cron_entry: None,
        }),
    }
}

/// Parse the interval from a cron expression like `*/8 * * * * ...`
fn parse_interval(line: &str) -> Option<u32> {
    let first_field = line.split_whitespace().next()?;
    if let Some(n) = first_field.strip_prefix("*/") {
        n.parse().ok()
    } else {
        None
    }
}

/// Parse the binary path from the cron entry after the 5 time fields.
///
/// Handles both quoted (`'/path with spaces/lore'`) and unquoted paths.
/// We skip the time fields manually to avoid `split_whitespace` breaking
/// on spaces inside single-quoted paths.
fn parse_binary_path(line: &str) -> Option<String> {
    // Skip the 5 cron time fields (min hour dom month dow).
    // These never contain spaces, so whitespace-splitting is safe here.
    let mut rest = line;
    for _ in 0..5 {
        rest = rest.trim_start();
        let end = rest.find(char::is_whitespace)?;
        rest = &rest[end..];
    }
    rest = rest.trim_start();

    // The command starts here — it may be single-quoted.
    if let Some(after_quote) = rest.strip_prefix('\'') {
        let end = after_quote.find('\'')?;
        Some(after_quote[..end].to_string())
    } else {
        let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
        Some(rest[..end].to_string())
    }
}

// ── Result types ──

#[derive(Serialize)]
pub struct CronInstallResult {
    pub entry: String,
    pub interval_minutes: u32,
    pub log_path: PathBuf,
    pub replaced: bool,
}

#[derive(Serialize)]
pub struct CronUninstallResult {
    pub was_installed: bool,
}

#[derive(Serialize)]
pub struct CronStatusResult {
    pub installed: bool,
    pub interval_minutes: Option<u32>,
    pub binary_path: Option<String>,
    pub current_binary: Option<String>,
    pub binary_mismatch: bool,
    pub log_path: Option<PathBuf>,
    pub cron_entry: Option<String>,
}

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

    #[test]
    fn build_cron_entry_formats_correctly() {
        let entry = build_cron_entry(8);
        assert!(entry.starts_with("*/8 * * * * "));
        assert!(entry.contains("sync -q --lock"));
        assert!(entry.ends_with(CRON_TAG));
    }

    #[test]
    fn parse_interval_extracts_number() {
        assert_eq!(parse_interval("*/8 * * * * /usr/bin/lore sync"), Some(8));
        assert_eq!(parse_interval("*/15 * * * * /usr/bin/lore sync"), Some(15));
        assert_eq!(parse_interval("0 * * * * /usr/bin/lore sync"), None);
    }

    #[test]
    fn parse_binary_path_extracts_sixth_field() {
        // Unquoted path
        assert_eq!(
            parse_binary_path(
                "*/8 * * * * /usr/local/bin/lore sync -q --lock >> /tmp/log 2>&1 # lore-sync"
            ),
            Some("/usr/local/bin/lore".to_string())
        );
        // Single-quoted path without spaces
        assert_eq!(
            parse_binary_path(
                "*/8 * * * * '/usr/local/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
            ),
            Some("/usr/local/bin/lore".to_string())
        );
        // Single-quoted path WITH spaces (common on macOS)
        assert_eq!(
            parse_binary_path(
                "*/8 * * * * '/Users/Taylor Eernisse/.cargo/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
            ),
            Some("/Users/Taylor Eernisse/.cargo/bin/lore".to_string())
        );
    }

    #[test]
    fn sync_lock_at_nonexistent_dir_creates_parents() {
        let dir = tempfile::tempdir().unwrap();
        let lock_file = dir.path().join("nested").join("deep").join("sync.lock");
        let guard = acquire_sync_lock_at(&lock_file).unwrap();
        assert!(guard.is_some());
        assert!(lock_file.exists());
    }

    #[test]
    fn sync_lock_is_exclusive_across_processes() {
        // POSIX fcntl locks are per-process, so same-process re-lock always
        // succeeds. We verify cross-process exclusion using a Python child
        // that attempts the same fcntl F_SETLK.
        let dir = tempfile::tempdir().unwrap();
        let lock_file = dir.path().join("sync.lock");
        let _guard = acquire_sync_lock_at(&lock_file).unwrap().unwrap();

        let script = r#"
import fcntl, struct, sys
fd = open(sys.argv[1], "w")
try:
    fcntl.fcntl(fd, fcntl.F_SETLK, struct.pack("hhllhh", fcntl.F_WRLCK, 0, 0, 0, 0, 0))
    sys.exit(0)
except (IOError, OSError):
    sys.exit(1)
"#;
        let status = std::process::Command::new("python3")
            .args(["-c", script, &lock_file.display().to_string()])
            .status()
            .unwrap();
        assert!(
            !status.success(),
            "child process should fail to acquire fcntl lock held by parent"
        );
    }
}
