//! In-memory session store for the SSR UI, with disk persistence.
//!
//! Each session is a UUID cookie (`prov_session`) mapped to a `SessionData`.
//! Sessions are flushed to `~/.local/share/provisioning/daemon-sessions.json`
//! on every create/delete so they survive daemon restarts.

use std::{
    collections::HashMap,
    sync::Arc,
    time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;

pub const COOKIE_NAME: &str = "prov_session";
const SESSION_TTL_SECS: u64 = 7 * 24 * 3600;

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::ZERO)
        .as_secs()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
    pub sub: String,
    pub roles: Vec<String>,
    /// Unix epoch seconds at creation time.
    created_at: u64,
}

impl SessionData {
    pub fn new(sub: impl Into<String>, roles: Vec<String>) -> Self {
        Self { sub: sub.into(), roles, created_at: now_secs() }
    }

    pub fn is_expired(&self) -> bool {
        now_secs().saturating_sub(self.created_at) > SESSION_TTL_SECS
    }
}

/// Permission summary derived from a session's roles.
///
/// In solo/dev mode every session gets `["admin"]`, giving full access.
/// In JWT mode roles come from the token claims (issuer / control-center).
/// When Cedar-backed authz is available, the control-center delegation path
/// will populate roles via policy evaluation — these fields remain stable.
#[derive(Debug, Clone)]
pub struct UiPerms {
    pub subject: String,
    pub roles: Vec<String>,
    /// Always true for any authenticated session.
    pub can_view: bool,
    /// `operator` or `admin` — may run non-destructive mutations.
    pub can_operate: bool,
    /// `admin` only — destructive ops, daemon control, config changes.
    pub can_admin: bool,
}

impl UiPerms {
    pub fn from_session(session: &SessionData) -> Self {
        let admin = session.roles.iter().any(|r| r == "admin");
        let operator = admin || session.roles.iter().any(|r| r == "operator");
        Self {
            subject: session.sub.clone(),
            roles: session.roles.clone(),
            can_view: true,
            can_operate: operator,
            can_admin: admin,
        }
    }

    /// Human-readable label for the highest role this session holds.
    pub fn role_label(&self) -> &'static str {
        if self.can_admin    { "admin" }
        else if self.can_operate { "operator" }
        else                 { "viewer" }
    }
}

#[derive(Clone)]
pub struct SessionStore {
    inner: Arc<RwLock<HashMap<String, SessionData>>>,
    file:  Option<Arc<std::path::PathBuf>>,
}

impl Default for SessionStore {
    fn default() -> Self {
        Self { inner: Arc::new(RwLock::new(HashMap::new())), file: None }
    }
}

impl SessionStore {
    /// Load from `file` if it exists; non-fatal on parse errors.
    pub fn with_persistence(file: std::path::PathBuf) -> Self {
        let sessions = load_from_disk(&file);
        Self {
            inner: Arc::new(RwLock::new(sessions)),
            file:  Some(Arc::new(file)),
        }
    }

    pub async fn create(&self, data: SessionData) -> String {
        let id = Uuid::new_v4().to_string();
        self.inner.write().await.insert(id.clone(), data);
        self.save().await;
        id
    }

    pub async fn get(&self, id: &str) -> Option<SessionData> {
        let store = self.inner.read().await;
        let data = store.get(id)?;
        if data.is_expired() { return None; }
        Some(data.clone())
    }

    pub async fn delete(&self, id: &str) {
        self.inner.write().await.remove(id);
        self.save().await;
    }

    /// Prune expired sessions and flush to disk.
    pub async fn prune(&self) {
        self.inner.write().await.retain(|_, v| !v.is_expired());
        self.save().await;
    }

    async fn save(&self) {
        let Some(ref path) = self.file else { return };
        let snapshot: HashMap<String, SessionData> = self.inner.read().await.clone();
        let path = Arc::clone(path);
        // Spawn blocking write so we don't block the async runtime.
        tokio::task::spawn_blocking(move || {
            if let Some(parent) = path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            match serde_json::to_string(&snapshot) {
                Ok(json) => { let _ = std::fs::write(&*path, json); }
                Err(e)   => tracing::warn!(error = %e, "failed to serialize sessions"),
            }
        });
    }
}

fn load_from_disk(path: &std::path::Path) -> HashMap<String, SessionData> {
    let Ok(bytes) = std::fs::read(path) else { return HashMap::new() };
    let Ok(map) = serde_json::from_slice::<HashMap<String, SessionData>>(&bytes) else {
        tracing::warn!(path = %path.display(), "session file corrupt — starting fresh");
        return HashMap::new();
    };
    // Drop expired sessions on load.
    map.into_iter().filter(|(_, v)| !v.is_expired()).collect()
}
