//! Process-local "this case is queued or in flight" tracking.
//!
//! Replaces the previous design where the existence of `analysis_input.json`
//! on disk doubled as the lifecycle marker. That coupled disk state
//! (lives forever) to process state (lives one boot session), so a crash
//! mid-job left the case stuck in `Queued` after the next restart.
//!
//! By moving the marker into an [`Arc<RwLock<HashSet<PathBuf>>>`] held in
//! `AppState`, the marker is reset to empty on every server boot. The
//! payload that used to live in `analysis_input.json` now travels with
//! the [`AnalyzeJob`] over the mpsc channel.
//!
//! [`AnalyzeJob`]: super::AnalyzeJob

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};

/// Shared, process-local set of cases currently queued or being analyzed.
/// Cheap to clone (`Arc`). Operations are non-blocking and short — the
/// inner `HashSet` is only ever held under the lock for an `insert`,
/// `remove`, or `contains` call.
#[derive(Clone, Default)]
pub struct InFlight(Arc<RwLock<HashSet<PathBuf>>>);

impl InFlight {
    pub fn new() -> Self {
        Self::default()
    }

    /// Atomically claim a case. Returns `true` if the caller now owns the
    /// slot (proceed to send a job), `false` if another caller already
    /// holds it (skip — duplicate trigger). Replaces the `O_EXCL` create
    /// race-guard the manual-trigger handlers used to rely on.
    pub fn try_claim(&self, case_dir: &Path) -> bool {
        self.0
            .write()
            .expect("inflight lock poisoned")
            .insert(case_dir.to_path_buf())
    }

    /// Release a slot. Idempotent — safe to call from cleanup paths that
    /// do not know whether the slot was actually held.
    pub fn release(&self, case_dir: &Path) {
        self.0
            .write()
            .expect("inflight lock poisoned")
            .remove(case_dir);
    }

    /// Read-only check used by `evaluate_state` to render the
    /// `Queued` UI state.
    pub fn contains(&self, case_dir: &Path) -> bool {
        self.0
            .read()
            .expect("inflight lock poisoned")
            .contains(case_dir)
    }
}

/// RAII slot-release guard. Construct after a successful [`InFlight::try_claim`];
/// the slot is released when the guard drops. Covers success, every
/// failure path, and worker panics — analogous to [`crate::BusyGuard`].
pub struct InFlightGuard {
    set: InFlight,
    case_dir: PathBuf,
}

impl InFlightGuard {
    pub fn new(set: InFlight, case_dir: PathBuf) -> Self {
        Self { set, case_dir }
    }
}

impl Drop for InFlightGuard {
    fn drop(&mut self) {
        self.set.release(&self.case_dir);
    }
}

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

    fn p(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    #[test]
    fn try_claim_returns_true_first_then_false() {
        let set = InFlight::new();
        assert!(set.try_claim(&p("/case/a")));
        assert!(!set.try_claim(&p("/case/a")));
        assert!(set.try_claim(&p("/case/b")));
    }

    #[test]
    fn release_makes_slot_reclaimable() {
        let set = InFlight::new();
        assert!(set.try_claim(&p("/case/a")));
        set.release(&p("/case/a"));
        assert!(set.try_claim(&p("/case/a")));
    }

    #[test]
    fn release_unknown_slot_is_noop() {
        let set = InFlight::new();
        set.release(&p("/case/never-claimed"));
    }

    #[test]
    fn contains_reflects_claim_state() {
        let set = InFlight::new();
        assert!(!set.contains(&p("/case/a")));
        set.try_claim(&p("/case/a"));
        assert!(set.contains(&p("/case/a")));
        set.release(&p("/case/a"));
        assert!(!set.contains(&p("/case/a")));
    }

    #[test]
    fn guard_releases_on_drop() {
        let set = InFlight::new();
        assert!(set.try_claim(&p("/case/a")));
        {
            let _g = InFlightGuard::new(set.clone(), p("/case/a"));
            assert!(set.contains(&p("/case/a")));
        }
        assert!(!set.contains(&p("/case/a")));
    }

    #[test]
    fn guard_releases_on_panic_unwind() {
        let set = InFlight::new();
        set.try_claim(&p("/case/a"));
        let set_clone = set.clone();
        let result = std::panic::catch_unwind(move || {
            let _g = InFlightGuard::new(set_clone, p("/case/a"));
            panic!("simulated worker panic");
        });
        assert!(result.is_err());
        assert!(!set.contains(&p("/case/a")));
    }
}
