//! Background worker thread for all vault-touching operations.
//!
//! # Why this exists
//!
//! egui is an immediate-mode UI: the main thread calls
//! `eframe::App::update` ~60 times per second, and any blocking IO
//! inside that callback freezes the UI. Vault ops do real work
//! (Argon2id KDF takes ~100ms; opening the platform passphrase
//! dialog spawns a subprocess and blocks on it for *seconds*). All
//! of that has to happen off the main thread.
//!
//! # Design
//!
//! A single worker thread per app, spawned once at startup.
//! - The main thread sends [`WorkerCmd`] over `cmd_tx`.
//! - The worker processes commands sequentially and sends
//!   [`WorkerResult`] over `result_rx`.
//! - Each result carries the [`ReqId`] of the command that produced
//!   it, so the main thread can correlate responses to in-flight
//!   requests and avoid stale UI updates from cancelled work.
//! - The worker holds the unlocked [`envseal::vault::Vault`] in its
//!   own local state — never shared across threads. When a command
//!   needs the vault but it's locked, the worker emits
//!   [`WorkerResult::NeedsPassphrase`] *with the original command
//!   embedded*; the main thread renders an in-window modal and on
//!   submit sends [`WorkerCmd::UnlockVault`] followed by the
//!   re-issued original command.
//! - The worker holds an `egui::Context` clone (set via
//!   [`WorkerHandle::attach_context`] from the first frame) and
//!   calls `request_repaint()` after each result so the main thread
//!   wakes up to consume the response without burning CPU on a
//!   polling loop.
//!
//! No GUI dialog code path is ever called from the egui main
//! thread. The platform PowerShell / zenity / osascript prompts —
//! the original source of the freeze-and-crash UX — are entirely
//! bypassed by [`envseal::vault::Vault::open_with_passphrase`],
//! which takes the passphrase as a parameter.

use std::sync::{
    mpsc::{self, Receiver, Sender},
    Arc, Mutex,
};
use std::time::{Duration, Instant};

use envseal::error::Error;
use envseal::ops::{PolicyRuleView, SecretInfo};
use envseal::secret_health::HealthReport;
use envseal::security_config::SecurityConfig;
use envseal::vault::Vault;
use zeroize::Zeroizing;

/// How often the worker wakes up to check whether the cached vault
/// has been idle long enough to auto-lock. Short enough to keep the
/// "vault locked" badge fresh; long enough that the worker isn't
/// spinning when the GUI is genuinely idle.
const IDLE_TICK: Duration = Duration::from_secs(5);

/// Correlation ID for matching a [`WorkerResult`] back to the
/// [`WorkerCmd`] that produced it. The main thread mints these
/// monotonically and stores them in view state to detect stale
/// responses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ReqId(pub u64);

/// Commands the main thread sends to the worker.
///
/// Every variant carries a [`ReqId`] so the response can be
/// correlated; commands that do not produce a direct response
/// ([`Self::LockVault`], [`Self::Shutdown`]) omit it.
pub enum WorkerCmd {
    ListSecrets {
        id: ReqId,
    },
    PeekSecret {
        id: ReqId,
        name: String,
    },
    StoreSecret {
        id: ReqId,
        name: String,
        value: Zeroizing<String>,
        force: bool,
    },
    RevokeSecret {
        id: ReqId,
        name: String,
    },
    LoadPolicy {
        id: ReqId,
    },
    RevokePolicyBinary {
        id: ReqId,
        binary: String,
    },
    LoadSecurityConfig {
        id: ReqId,
    },
    SaveSecurityConfig {
        id: ReqId,
        config: Box<SecurityConfig>,
    },
    LoadAuditLog {
        id: ReqId,
        n: usize,
        filter: envseal::audit::AuditFilter,
    },
    LoadHealth {
        id: ReqId,
        max_days: Option<u64>,
    },
    /// Unlock the vault with a caller-supplied passphrase.
    /// Used both for first-time unlock and after [`Self::LockVault`].
    UnlockVault {
        id: ReqId,
        passphrase: Zeroizing<String>,
    },
    /// Lock the vault — drops the cached [`Vault`] handle and
    /// zeroizes the in-memory master key. Subsequent vault-touching
    /// commands will trigger [`WorkerResult::NeedsPassphrase`] again.
    LockVault,
    /// Stop the worker thread cleanly. Sent on app shutdown.
    Shutdown,
}

impl WorkerCmd {
    /// Whether this command needs an unlocked vault to execute.
    /// Commands that don't (read-only filesystem queries, lock,
    /// shutdown) skip the unlock-or-prompt dance entirely.
    fn needs_unlocked_vault(&self) -> bool {
        match self {
            // Filesystem-only — no vault unlock needed.
            Self::ListSecrets { .. } | Self::LoadAuditLog { .. } => false,
            // Vault unlock IS the goal — never needs an existing one.
            Self::UnlockVault { .. } | Self::LockVault | Self::Shutdown => false,
            // Everything else touches the master key.
            Self::PeekSecret { .. }
            | Self::StoreSecret { .. }
            | Self::RevokeSecret { .. }
            | Self::LoadPolicy { .. }
            | Self::RevokePolicyBinary { .. }
            | Self::LoadSecurityConfig { .. }
            | Self::SaveSecurityConfig { .. }
            | Self::LoadHealth { .. } => true,
        }
    }

    /// The correlation id this command will reply with, if any.
    fn req_id(&self) -> Option<ReqId> {
        match self {
            Self::ListSecrets { id }
            | Self::PeekSecret { id, .. }
            | Self::StoreSecret { id, .. }
            | Self::RevokeSecret { id, .. }
            | Self::LoadPolicy { id }
            | Self::RevokePolicyBinary { id, .. }
            | Self::LoadSecurityConfig { id }
            | Self::SaveSecurityConfig { id, .. }
            | Self::LoadAuditLog { id, .. }
            | Self::LoadHealth { id, .. }
            | Self::UnlockVault { id, .. } => Some(*id),
            Self::LockVault | Self::Shutdown => None,
        }
    }
}

/// Lightweight, redacted preview of a secret returned to the main thread.
///
/// `length` and `secret_type` aren't surfaced in the current toast UI
/// but are kept on the view so a richer "secret detail" panel can use
/// them later without reworking the worker contract.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PeekView {
    pub name: String,
    pub redacted: String,
    pub length: usize,
    pub secret_type: Option<&'static str>,
}

/// Snapshot of a [`SecretInfo`] usable on the main thread.
#[derive(Debug, Clone)]
pub struct SecretView {
    pub name: String,
}

impl From<SecretInfo> for SecretView {
    fn from(info: SecretInfo) -> Self {
        Self { name: info.name }
    }
}

/// Snapshot of a single secret's health usable on the main thread.
#[derive(Debug, Clone)]
pub struct HealthSecretView {
    pub name: String,
    pub age_days: u64,
    pub needs_rotation: bool,
}

/// Snapshot of a [`HealthReport`] usable on the main thread.
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct HealthView {
    pub total: usize,
    pub stale: usize,
    pub weak: usize,
    pub secrets: Vec<HealthSecretView>,
}

impl From<HealthReport> for HealthView {
    fn from(report: HealthReport) -> Self {
        Self {
            total: report.total,
            stale: report.stale_count,
            weak: report.weak_count,
            secrets: report
                .secrets
                .into_iter()
                .map(|s| HealthSecretView {
                    name: s.name,
                    age_days: s.age.map(|d| d.as_secs() / 86400).unwrap_or(0),
                    needs_rotation: s.needs_rotation,
                })
                .collect(),
        }
    }
}

/// Results the worker sends back to the main thread.
pub enum WorkerResult {
    SecretsListed {
        id: ReqId,
        result: Result<Vec<SecretView>, String>,
    },
    SecretPeeked {
        id: ReqId,
        result: Result<PeekView, String>,
    },
    SecretStored {
        id: ReqId,
        /// Stored secret name on success; quality / entropy signals
        /// the caller can surface as toasts. Structured form so the
        /// UI can render label/detail/mitigation distinctly rather
        /// than getting a pre-formatted string blob.
        result: Result<(String, Vec<envseal::guard::Signal>), String>,
    },
    SecretRevoked {
        id: ReqId,
        result: Result<String, String>,
    },
    PolicyLoaded {
        id: ReqId,
        result: Result<Vec<PolicyRuleView>, String>,
    },
    PolicyRuleRevoked {
        id: ReqId,
        result: Result<String, String>,
    },
    SecurityConfigLoaded {
        id: ReqId,
        result: Result<Box<SecurityConfig>, String>,
    },
    SecurityConfigSaved {
        id: ReqId,
        result: Result<(), String>,
    },
    AuditLogLoaded {
        id: ReqId,
        /// Parsed entries (newest first). The view layer renders
        /// per-event icons / summaries from the typed
        /// [`envseal::audit::AuditEvent`] inside each entry rather
        /// than scraping the raw JSON.
        entries: Vec<envseal::audit::ParsedEntry>,
        /// Number of log lines that failed to parse — non-zero
        /// implies log corruption or a chain rotation that the
        /// view should surface to the operator.
        dropped_lines: usize,
    },
    HealthLoaded {
        id: ReqId,
        result: Result<HealthView, String>,
    },
    VaultUnlocked {
        /// Currently unused — modal closes regardless of which
        /// in-flight unlock id replied. Kept for future "race the
        /// passphrase" UX where multiple unlocks could be in flight.
        #[allow(dead_code)]
        id: ReqId,
        result: Result<UnlockOutcome, String>,
    },
    VaultLocked,
    /// The worker received a vault-touching command but no vault is
    /// unlocked. The main thread must render the passphrase modal
    /// and on submit issue [`WorkerCmd::UnlockVault`] followed by
    /// re-issuing the original command (which the GUI stashed under
    /// `original_id`).
    NeedsPassphrase {
        is_new_vault: bool,
        original_id: ReqId,
    },
}

/// What happened on a successful unlock.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnlockOutcome {
    /// An existing vault was unlocked.
    Unlocked,
    /// A new vault was just created.
    Initialized,
}

/// Handle the main thread holds to communicate with the worker.
pub struct WorkerHandle {
    cmd_tx: Sender<WorkerCmd>,
    result_rx: Receiver<WorkerResult>,
    ctx_slot: Arc<Mutex<Option<eframe::egui::Context>>>,
    next_id: u64,
}

impl WorkerHandle {
    /// Mint a new request ID.
    pub fn new_req_id(&mut self) -> ReqId {
        self.next_id = self.next_id.wrapping_add(1);
        ReqId(self.next_id)
    }

    /// Send a command. Returns false if the worker has died (e.g. on
    /// shutdown); callers can treat that as a no-op since the app is
    /// tearing down.
    pub fn send(&self, cmd: WorkerCmd) -> bool {
        self.cmd_tx.send(cmd).is_ok()
    }

    /// Drain pending results without blocking. Returns every result
    /// the worker has produced since the last call. Caller dispatches
    /// each one to the appropriate view state.
    pub fn drain_results(&self) -> Vec<WorkerResult> {
        let mut out = Vec::new();
        while let Ok(r) = self.result_rx.try_recv() {
            out.push(r);
        }
        out
    }

    /// Hand the worker the egui Context so it can request repaints
    /// from its own thread when results arrive. Called once on the
    /// first frame.
    pub fn attach_context(&self, ctx: eframe::egui::Context) {
        if let Ok(mut slot) = self.ctx_slot.lock() {
            *slot = Some(ctx);
        }
    }
}

impl Drop for WorkerHandle {
    fn drop(&mut self) {
        let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
    }
}

/// Spawn the worker thread and return a [`WorkerHandle`] to drive it.
pub fn spawn() -> Result<WorkerHandle, String> {
    let (cmd_tx, cmd_rx) = mpsc::channel::<WorkerCmd>();
    let (result_tx, result_rx) = mpsc::channel::<WorkerResult>();
    let ctx_slot: Arc<Mutex<Option<eframe::egui::Context>>> = Arc::new(Mutex::new(None));
    let ctx_clone = ctx_slot.clone();

    std::thread::Builder::new()
        .name("envseal-gui-worker".to_string())
        .spawn(move || worker_loop(&cmd_rx, &result_tx, &ctx_clone))
        .map_err(|e| format!("worker thread spawn failed: {e}"))?;

    Ok(WorkerHandle {
        cmd_tx,
        result_rx,
        ctx_slot,
        next_id: 0,
    })
}

fn worker_loop(
    cmd_rx: &Receiver<WorkerCmd>,
    result_tx: &Sender<WorkerResult>,
    ctx_slot: &Arc<Mutex<Option<eframe::egui::Context>>>,
) {
    let mut vault: Option<Vault> = None;
    // Last command that touched the vault. None when no vault is
    // unlocked. Reset on every command, checked on every IDLE_TICK.
    let mut last_activity: Option<Instant> = None;

    loop {
        match cmd_rx.recv_timeout(IDLE_TICK) {
            Ok(WorkerCmd::Shutdown) => break,
            Ok(cmd) => {
                let touched_vault =
                    cmd.needs_unlocked_vault() || matches!(cmd, WorkerCmd::UnlockVault { .. });
                let results = handle_cmd(cmd, &mut vault);
                if touched_vault && vault.is_some() {
                    last_activity = Some(Instant::now());
                } else if vault.is_none() {
                    last_activity = None;
                }
                for r in results {
                    if result_tx.send(r).is_err() {
                        return;
                    }
                }
                wake_main(ctx_slot);
            }
            Err(mpsc::RecvTimeoutError::Timeout) => {
                if let Some(locked) = check_idle_lock(&mut vault, &mut last_activity) {
                    if result_tx.send(locked).is_err() {
                        return;
                    }
                    wake_main(ctx_slot);
                }
            }
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
        }
    }
}

/// If a vault is held and the configured `auto_lock_secs` window
/// has elapsed since `last_activity`, drop the vault and emit a
/// `VaultLocked` notification. Returns `None` when no action is
/// required.
fn check_idle_lock(
    vault: &mut Option<Vault>,
    last_activity: &mut Option<Instant>,
) -> Option<WorkerResult> {
    let v = vault.as_ref()?;
    let activity = (*last_activity)?;
    // Read auto_lock_secs from the loaded security config. We have
    // an unlocked vault, so this read is cheap and definitely valid.
    let lock_after = match envseal::security_config::load_config(v.root(), v.master_key_bytes()) {
        Ok(cfg) if cfg.auto_lock_secs > 0 => Duration::from_secs(u64::from(cfg.auto_lock_secs)),
        // 0 = never, OR config load failed (rare; conservative
        // fallback to the 60-second default rather than holding the
        // vault open indefinitely).
        Ok(_) => return None,
        Err(_) => Duration::from_secs(60),
    };
    if activity.elapsed() < lock_after {
        return None;
    }
    *vault = None;
    *last_activity = None;
    Some(WorkerResult::VaultLocked)
}

fn wake_main(ctx_slot: &Arc<Mutex<Option<eframe::egui::Context>>>) {
    if let Ok(slot) = ctx_slot.lock() {
        if let Some(ctx) = slot.as_ref() {
            ctx.request_repaint();
        }
    }
}

/// Process one command. Returns a vector because [`WorkerCmd::LockVault`]
/// produces a single notification while normal commands produce one
/// result, and a "needs passphrase" interception produces exactly one
/// [`WorkerResult::NeedsPassphrase`].
fn handle_cmd(cmd: WorkerCmd, vault: &mut Option<Vault>) -> Vec<WorkerResult> {
    // Interception: if the command needs an unlocked vault and we
    // don't have one, bounce back NeedsPassphrase. The main thread
    // is responsible for re-issuing the command after unlock.
    if cmd.needs_unlocked_vault() && vault.is_none() {
        let Some(id) = cmd.req_id() else {
            return Vec::new();
        };
        let is_new_vault = match envseal::ops::vault_root() {
            Ok(root) => match envseal::ops::vault_status(&root) {
                Ok(status) => !status.initialized,
                Err(_) => true,
            },
            Err(_) => true,
        };
        return vec![WorkerResult::NeedsPassphrase {
            is_new_vault,
            original_id: id,
        }];
    }

    match cmd {
        WorkerCmd::ListSecrets { id } => vec![do_list_secrets(id)],
        WorkerCmd::PeekSecret { id, name } => match vault.as_ref() {
            Some(v) => vec![do_peek(id, &name, v)],
            None => vec![WorkerResult::SecretPeeked {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::StoreSecret {
            id,
            name,
            value,
            force,
        } => match vault.as_ref() {
            Some(v) => vec![do_store(id, &name, &value, force, v)],
            None => vec![WorkerResult::SecretStored {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::RevokeSecret { id, name } => match vault.as_ref() {
            Some(v) => vec![do_revoke(id, &name, v)],
            None => vec![WorkerResult::SecretRevoked {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::LoadPolicy { id } => match vault.as_ref() {
            Some(v) => vec![do_load_policy(id, v)],
            None => vec![WorkerResult::PolicyLoaded {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::RevokePolicyBinary { id, binary } => match vault.as_ref() {
            Some(v) => vec![do_revoke_policy_binary(id, &binary, v)],
            None => vec![WorkerResult::PolicyRuleRevoked {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::LoadSecurityConfig { id } => match vault.as_ref() {
            Some(v) => vec![do_load_security_config(id, v)],
            None => vec![WorkerResult::SecurityConfigLoaded {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::SaveSecurityConfig { id, config } => match vault.as_ref() {
            Some(v) => vec![do_save_security_config(id, *config, v)],
            None => vec![WorkerResult::SecurityConfigSaved {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::LoadAuditLog { id, n, filter } => {
            let result = envseal::ops::audit_log_parsed_filtered(n, &filter);
            vec![WorkerResult::AuditLogLoaded {
                id,
                entries: result.entries,
                dropped_lines: result.dropped_lines,
            }]
        }
        WorkerCmd::LoadHealth { id, max_days } => match vault.as_ref() {
            Some(v) => vec![do_health(id, max_days, v)],
            None => vec![WorkerResult::HealthLoaded {
                id,
                result: Err("vault unexpectedly locked".to_string()),
            }],
        },
        WorkerCmd::UnlockVault { id, passphrase } => vec![do_unlock(id, &passphrase, vault)],
        WorkerCmd::LockVault => {
            *vault = None;
            vec![WorkerResult::VaultLocked]
        }
        WorkerCmd::Shutdown => Vec::new(),
    }
}

// ──────────────────────────────────────────────────────────────────
//  Per-command implementations. Each is a thin wrapper around the
//  ops layer that maps the result into the worker's reply variant
//  and converts any Error into a String for transport across the
//  channel (the main thread doesn't need the typed Error here).
// ──────────────────────────────────────────────────────────────────

fn do_list_secrets(id: ReqId) -> WorkerResult {
    let result = envseal::ops::vault_root()
        .and_then(|root| envseal::ops::list_secrets(&root))
        .map(|infos| infos.into_iter().map(SecretView::from).collect())
        .map_err(|e: Error| e.to_string());
    WorkerResult::SecretsListed { id, result }
}

fn do_peek(id: ReqId, name: &str, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::peek_secret(vault, name)
        .map(|p| PeekView {
            name: p.name,
            redacted: p.redacted,
            length: p.length,
            secret_type: p.secret_type,
        })
        .map_err(|e| e.to_string());
    WorkerResult::SecretPeeked { id, result }
}

fn do_store(
    id: ReqId,
    name: &str,
    value: &Zeroizing<String>,
    force: bool,
    vault: &Vault,
) -> WorkerResult {
    // Use the audited ops path (size cap + audit log + entropy
    // warnings) rather than calling vault.store() directly. Keeps
    // the CLI and GUI on the same well-tested code path.
    let result = envseal::ops::store_secret_in(vault, name, value.as_bytes(), force)
        .map(|warnings| (name.to_string(), warnings))
        .map_err(|e| e.to_string());
    WorkerResult::SecretStored { id, result }
}

fn do_revoke(id: ReqId, name: &str, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::revoke_with_policy(vault, name)
        .map(|()| name.to_string())
        .map_err(|e| e.to_string());
    WorkerResult::SecretRevoked { id, result }
}

fn do_load_policy(id: ReqId, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::policy_show_in(vault).map_err(|e| e.to_string());
    WorkerResult::PolicyLoaded { id, result }
}

fn do_revoke_policy_binary(id: ReqId, binary: &str, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::policy_revoke_binary_in(vault, binary)
        .map(|()| binary.to_string())
        .map_err(|e| e.to_string());
    WorkerResult::PolicyRuleRevoked { id, result }
}

fn do_load_security_config(id: ReqId, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::security_config_get_in(vault)
        .map(Box::new)
        .map_err(|e| e.to_string());
    WorkerResult::SecurityConfigLoaded { id, result }
}

fn do_save_security_config(id: ReqId, config: SecurityConfig, vault: &Vault) -> WorkerResult {
    let result = envseal::ops::security_config_save_in(vault, &config).map_err(|e| e.to_string());
    WorkerResult::SecurityConfigSaved { id, result }
}

fn do_health(id: ReqId, max_days: Option<u64>, _vault: &Vault) -> WorkerResult {
    let result = envseal::ops::health_report(max_days)
        .map(HealthView::from)
        .map_err(|e| e.to_string());
    WorkerResult::HealthLoaded { id, result }
}

fn do_unlock(id: ReqId, passphrase: &Zeroizing<String>, vault: &mut Option<Vault>) -> WorkerResult {
    let was_new = match envseal::ops::vault_root() {
        Ok(root) => match envseal::ops::vault_status(&root) {
            Ok(status) => !status.initialized,
            Err(_) => true,
        },
        Err(_) => true,
    };

    let outcome = if was_new {
        UnlockOutcome::Initialized
    } else {
        UnlockOutcome::Unlocked
    };

    match Vault::open_default_with_passphrase(passphrase) {
        Ok(v) => {
            *vault = Some(v);
            WorkerResult::VaultUnlocked {
                id,
                result: Ok(outcome),
            }
        }
        Err(e) => WorkerResult::VaultUnlocked {
            id,
            result: Err(e.to_string()),
        },
    }
}
