//! Crash recovery (plan A6): a running RustFig keeps a copy of an unsaved
//! drawing in a recovery folder, so a crash or a killed process loses at
//! most a few seconds of work. xfig does the same in its emergency save
//! (`SAVE.fig`, `u_error.c`), but only when it notices the crash itself.
//!
//! Each running instance has an id; it writes `ID.rustfig` (the drawing,
//! RustFig's lossless format) and `ID.path` (the file it came from, if any),
//! and holds an exclusive lock on `ID.lock` while it runs. The operating
//! system releases the lock when the process ends, however it ends (even
//! `kill -9`), so a drawing whose lock can be taken belongs to an instance
//! that stopped without cleaning up: it is offered for recovery at the next
//! start, right away. Native only: the web version keeps its drawings in
//! the browser (plan C3).

use crate::EngineError;
use rustfig_format::FileFormat;
use rustfig_model::Document;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::SystemTime;

/// The platform's folder for RustFig's recovery files.
pub fn default_dir() -> Option<PathBuf> {
    use crate::dirs::{data_dir, process_env, HostOs};
    // Scripts and agents (`rustfig --background`) keep their copies apart.
    if let Some(d) = process_env("RUSTFIG_RECOVERY_DIR") {
        return Some(d);
    }
    data_dir(HostOs::current(), &process_env).map(|b| b.join("recovery"))
}

/// A drawing left behind by an instance that stopped.
#[derive(Debug, Clone, PartialEq)]
pub struct Orphan {
    pub id: String,
    /// The recovery copy.
    pub file: PathBuf,
    /// The file the drawing was opened from or saved to, if any.
    pub original: Option<String>,
    pub saved: Option<SystemTime>,
}

/// One instance's recovery files.
#[derive(Debug)]
pub struct Recovery {
    dir: PathBuf,
    id: String,
    /// The lock held while this instance runs (none if it couldn't be made:
    /// then other instances may offer this one's copy too early).
    lock: Option<File>,
}

fn io(e: std::io::Error, what: &Path) -> EngineError {
    EngineError::io(format!("{}: {e}", what.display()))
}

impl Recovery {
    /// Recovery files of this process in `dir` (created when needed).
    pub fn new(dir: PathBuf) -> Recovery {
        let nanos = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        // The clock alone can give two instances of one process the same
        // id (macOS: microseconds), so a counter makes it unique.
        static NEXT: AtomicU64 = AtomicU64::new(0);
        let n = NEXT.fetch_add(1, Ordering::Relaxed);
        let id = format!("{}-{nanos:x}-{n}", std::process::id());
        let lock = std::fs::create_dir_all(&dir)
            .ok()
            .and_then(|()| File::create(dir.join(format!("{id}.lock"))).ok())
            .filter(|f| f.try_lock().is_ok());
        Recovery { dir, id, lock }
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    fn file(&self, ext: &str) -> PathBuf {
        self.dir.join(format!("{}.{ext}", self.id))
    }

    /// Whether this instance holds its lock.
    pub fn locked(&self) -> bool {
        self.lock.is_some()
    }

    /// Keep a copy of `doc` (opened from `original`).
    pub fn save(&self, doc: &Document, original: Option<&str>) -> Result<(), EngineError> {
        std::fs::create_dir_all(&self.dir).map_err(|e| io(e, &self.dir))?;
        let text = rustfig_format::write(doc, FileFormat::Native).map_err(|e| EngineError::format(e.to_string()))?;
        let f = self.file("rustfig");
        crate::cmd::file::write_atomic(&f.to_string_lossy(), text.as_bytes())?;
        let p = self.file("path");
        match original {
            Some(o) => std::fs::write(&p, o.as_bytes()).map_err(|e| io(e, &p)),
            None => {
                // No original: a stale path file from an earlier drawing goes.
                let _ = std::fs::remove_file(&p); // absent is fine
                Ok(())
            }
        }
    }

    /// The drawing is saved (or discarded): no copy is needed.
    pub fn clear(&self) {
        for ext in ["rustfig", "path"] {
            let _ = std::fs::remove_file(self.file(ext)); // absent is fine
        }
    }

    /// A clean exit: remove everything of this instance.
    pub fn finish(&mut self) {
        self.clear();
        if let Some(l) = self.lock.take() {
            let _ = l.unlock(); // the file goes next; dropping unlocks too
        }
        let _ = std::fs::remove_file(self.file("lock")); // absent is fine
    }

    /// Drawings in the folder left by instances that stopped.
    pub fn orphans(&self) -> Vec<Orphan> {
        orphans(&self.dir, Some(&self.id))
    }
}

/// Whether the instance `id` in `dir` still runs: its lock is held.
fn running(dir: &Path, id: &str) -> bool {
    match File::open(dir.join(format!("{id}.lock"))) {
        Ok(f) => match f.try_lock() {
            Ok(()) => false, // nobody holds it; dropping `f` releases it
            Err(std::fs::TryLockError::WouldBlock) => true,
            Err(std::fs::TryLockError::Error(_)) => false,
        },
        Err(_) => false,
    }
}

/// Drawings in `dir` whose instance stopped (its lock is free).
pub fn orphans(dir: &Path, except: Option<&str>) -> Vec<Orphan> {
    let Ok(rd) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut out: Vec<Orphan> = rd
        .flatten()
        .filter_map(|e| {
            let file = e.path();
            if file.extension().and_then(|x| x.to_str()) != Some("rustfig") {
                return None;
            }
            let id = file.file_stem()?.to_str()?.to_string();
            if except == Some(id.as_str()) {
                return None;
            }
            if running(dir, &id) {
                return None;
            }
            let original = std::fs::read_to_string(dir.join(format!("{id}.path")))
                .ok()
                .filter(|s| !s.is_empty());
            let saved = e.metadata().and_then(|m| m.modified()).ok();
            Some(Orphan {
                id,
                file,
                original,
                saved,
            })
        })
        .take(64)
        .collect();
    out.sort_by_key(|o| std::cmp::Reverse(o.saved));
    out
}

/// Remove an orphan's files.
pub fn discard(dir: &Path, id: &str) {
    for ext in ["rustfig", "path", "lock"] {
        let _ = std::fs::remove_file(dir.join(format!("{id}.{ext}"))); // absent is fine
    }
}

/// Most saved versions of one file the preference may ask for.
pub const MAX_VERSIONS: usize = 50;
/// Versions larger than this are not kept (a copy per save must stay cheap).
const MAX_VERSION_BYTES: u64 = 64 * 1024 * 1024;

/// A saved version of a file kept in the recovery folder (W10-14).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
    /// Digits: the time the version was replaced (nanoseconds since 1970),
    /// unique per file; also the file's name.
    pub id: String,
    pub file: PathBuf,
    /// Milliseconds since 1970.
    pub time_ms: u64,
    pub size: u64,
}

/// FNV-1a of the path: a name for the file's folder that stays the same
/// across runs and versions of Rust.
fn path_key(path: &str) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in path.bytes() {
        h ^= u64::from(b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{h:016x}")
}

fn versions_dir(dir: &Path, path: &str) -> PathBuf {
    dir.join("versions").join(path_key(path))
}

/// Versions of `path` in `dir`, newest first (at most [`MAX_VERSIONS`]).
pub fn versions(dir: &Path, path: &str) -> Vec<Version> {
    let vd = versions_dir(dir, path);
    let Ok(rd) = std::fs::read_dir(&vd) else {
        return Vec::new();
    };
    let mut out: Vec<Version> = rd
        .flatten()
        .take(MAX_VERSIONS * 4)
        .filter_map(|e| {
            let file = e.path();
            let id = file.file_stem()?.to_str()?.to_string();
            if id.is_empty() || id.len() > 24 || !id.chars().all(|c| c.is_ascii_digit()) {
                return None;
            }
            let nanos: u128 = id.parse().ok()?;
            let size = e.metadata().ok()?.len();
            Some(Version {
                time_ms: u64::try_from(nanos / 1_000_000).unwrap_or(u64::MAX),
                id,
                file,
                size,
            })
        })
        .collect();
    out.sort_by(|a, b| b.id.len().cmp(&a.id.len()).then_with(|| b.id.cmp(&a.id)));
    out.truncate(MAX_VERSIONS);
    out
}

/// The file of version `id` of `path`.
pub fn version_file(dir: &Path, path: &str, id: &str) -> Option<PathBuf> {
    versions(dir, path).into_iter().find(|v| v.id == id).map(|v| v.file)
}

/// Keep the file at `path` (about to be overwritten by a save) as a version
/// in `dir`, then drop all but the newest `keep` (0 keeps none and removes
/// the old ones). No file yet: nothing to keep. Returns a failure message.
pub fn keep_version(dir: &Path, path: &str, keep: usize) -> Option<String> {
    let keep = keep.min(MAX_VERSIONS);
    let vd = versions_dir(dir, path);
    if keep == 0 {
        let _ = std::fs::remove_dir_all(&vd); // absent is fine
        return None;
    }
    let meta = std::fs::metadata(path).ok()?;
    if !meta.is_file() || meta.len() > MAX_VERSION_BYTES {
        return None;
    }
    let ext = Path::new(path)
        .extension()
        .and_then(|x| x.to_str())
        .filter(|x| x.len() <= 8 && x.chars().all(|c| c.is_ascii_alphanumeric()))
        .unwrap_or("fig");
    let result = (|| -> Result<(), String> {
        std::fs::create_dir_all(&vd).map_err(|e| format!("{}: {e}", vd.display()))?;
        let mut nanos = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        // Strictly newer than the newest kept version, whatever the clock.
        if let Some(newest) = versions(dir, path).first().and_then(|v| v.id.parse::<u128>().ok()) {
            nanos = nanos.max(newest + 1);
        }
        let dest = vd.join(format!("{nanos:020}.{ext}"));
        std::fs::copy(path, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
        Ok(())
    })();
    for old in versions(dir, path).into_iter().skip(keep) {
        let _ = std::fs::remove_file(old.file); // best effort: a leftover is pruned next time
    }
    result.err()
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustfig_geom::Point;
    use rustfig_model::{Object, ObjectKind, PolyKind, Polyline, Style};
    use std::sync::Arc;

    fn tmp(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("rustfig-recovery-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        d
    }

    fn doc() -> Document {
        let mut d = Document::new();
        d.objects.push(Arc::new(Object::new(ObjectKind::Polyline(Polyline::new(
            PolyKind::Box,
            Style::default(),
            vec![Point::new(0.0, 0.0), Point::new(600.0, 0.0), Point::new(600.0, 300.0)],
        )))));
        d.assign_ids();
        d
    }

    #[test]
    fn a_stopped_instance_leaves_an_orphan() {
        let dir = tmp("orphan");
        let a = Recovery::new(dir.clone());
        assert!(a.locked());
        a.save(&doc(), Some("/x/fig.fig")).unwrap();
        let b = Recovery::new(dir.clone());
        // A still runs: nothing to recover.
        assert!(b.orphans().is_empty());
        // A stops without cleaning up (as a crash or kill -9: the system
        // releases its lock).
        drop(a);
        // A process another test spawns at this moment can hold a copy of
        // A's lock until it has started (fork, then exec), so wait a little.
        let mut found = b.orphans();
        for _ in 0..50 {
            if !found.is_empty() {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(20));
            found = b.orphans();
        }
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].original.as_deref(), Some("/x/fig.fig"));
        let text = std::fs::read_to_string(&found[0].file).unwrap();
        let back = rustfig_format::read_any(text.as_bytes()).unwrap();
        assert_eq!(back.objects.len(), 1);
        discard(&dir, &found[0].id);
        assert!(b.orphans().is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn clear_and_finish_remove_the_copy() {
        let dir = tmp("clear");
        let mut a = Recovery::new(dir.clone());
        a.save(&doc(), None).unwrap();
        a.clear();
        assert!(orphans(&dir, None).is_empty());
        a.save(&doc(), None).unwrap();
        a.finish();
        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn default_dir_is_named_after_rustfig() {
        if let Some(d) = default_dir() {
            assert!(d.to_string_lossy().to_lowercase().contains("rustfig"));
            assert!(d.ends_with("recovery"));
        }
    }
}
