use std::{
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use chrono::{DateTime, Duration, Utc};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::{approval_receipts, fs_util, key_store, logs};

const RUN_UNLOCK_PREFIX: &str = "unlock/run";
const LOG_UNLOCK_PREFIX: &str = "unlock/logs";
const MODE_UNLOCK_PREFIX: &str = "unlock/mode";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnlockSession {
    pub id: uuid::Uuid,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub project: String,
    pub vault: PathBuf,
    pub key_name: String,
    pub purpose: UnlockPurpose,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signing_key_ciphertext: Option<approval_receipts::SigningKeyCiphertext>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum UnlockPurpose {
    Run,
    Logs,
    Mode(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunUnlockLookup {
    Available(String),
    Missing,
    MaterialUnavailable { reason: String },
}

impl RunUnlockLookup {
    pub fn reason(&self) -> Option<&str> {
        match self {
            Self::Missing => Some("missing_unlock_session"),
            Self::MaterialUnavailable { reason } => Some(reason.as_str()),
            Self::Available(_) => None,
        }
    }
}

#[derive(Debug)]
pub enum RunSigningLookup {
    Available(approval_receipts::SessionSigningKey),
    Missing,
    MaterialUnavailable { reason: String },
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UnlockState {
    sessions: Vec<UnlockSession>,
}

pub fn unlocks_path() -> PathBuf {
    logs::ward_home().join("sessions").join("unlocks.json")
}

pub fn parse_ttl(value: &str) -> Result<Duration> {
    let value = value.trim();
    if value.len() < 2 {
        anyhow::bail!("ttl must use a suffix: m, h, or d");
    }
    let (number, suffix) = value.split_at(value.len() - 1);
    let amount = number
        .parse::<i64>()
        .with_context(|| format!("invalid ttl amount: {number}"))?;
    if amount <= 0 {
        anyhow::bail!("ttl must be greater than zero");
    }

    match suffix {
        "m" => Ok(Duration::minutes(amount)),
        "h" => Ok(Duration::hours(amount)),
        "d" => Ok(Duration::days(amount)),
        other => anyhow::bail!("unsupported ttl suffix: {other}"),
    }
}

pub fn create_run_unlock(
    project: &str,
    vault: &Path,
    passphrase: &str,
    ttl: Duration,
) -> Result<UnlockSession> {
    create_unlock(project, vault, passphrase, ttl, UnlockPurpose::Run)
}

pub fn create_logs_unlock(project: &str, vault: &Path, ttl: Duration) -> Result<UnlockSession> {
    create_unlock(project, vault, "", ttl, UnlockPurpose::Logs)
}

pub fn create_mode_unlock(
    project: &str,
    vault: &Path,
    passphrase: &str,
    ttl: Duration,
    mode_name: &str,
) -> Result<UnlockSession> {
    create_unlock(
        project,
        vault,
        passphrase,
        ttl,
        UnlockPurpose::Mode(mode_name.to_string()),
    )
}

pub fn active_run_passphrase(project: &str, vault: &Path) -> Result<Option<String>> {
    let Some(session) = active_session(project, vault, UnlockPurpose::Run)? else {
        return Ok(None);
    };
    #[cfg(not(test))]
    {
        let _ = session;
        return Ok(None);
    }
    #[cfg(test)]
    key_store::get_secret(&session.key_name)
}

pub fn active_run_lookup(project: &str, vault: &Path) -> Result<RunUnlockLookup> {
    let Some(session) = active_session(project, vault, UnlockPurpose::Run)? else {
        return Ok(RunUnlockLookup::Missing);
    };
    #[cfg(not(test))]
    {
        let _ = session;
        return Ok(RunUnlockLookup::MaterialUnavailable {
            reason: "broker_unlock_only".to_string(),
        });
    }
    #[cfg(test)]
    match key_store::get_secret(&session.key_name) {
        Ok(Some(passphrase)) => Ok(RunUnlockLookup::Available(passphrase)),
        Ok(None) => Ok(RunUnlockLookup::MaterialUnavailable {
            reason: "unlock_material_unavailable".to_string(),
        }),
        Err(error) => Ok(RunUnlockLookup::MaterialUnavailable {
            reason: format!("unlock_material_unreadable: {error}"),
        }),
    }
}

pub fn active_run_signing_key(project: &str, vault: &Path) -> Result<RunSigningLookup> {
    let Some(session) = active_session(project, vault, UnlockPurpose::Run)? else {
        return Ok(RunSigningLookup::Missing);
    };
    #[cfg(not(test))]
    {
        let _ = session;
        return Ok(RunSigningLookup::MaterialUnavailable {
            reason: "signing_key_unavailable".to_string(),
        });
    }
    #[cfg(test)]
    {
        let Some(ciphertext) = session.signing_key_ciphertext.as_ref() else {
            return Ok(RunSigningLookup::MaterialUnavailable {
                reason: "signing_key_unavailable".to_string(),
            });
        };
        let session_secret = match key_store::get_secret(&session.key_name) {
            Ok(Some(secret)) => secret,
            Ok(None) => {
                return Ok(RunSigningLookup::MaterialUnavailable {
                    reason: "unlock_material_unavailable".to_string(),
                })
            }
            Err(error) => {
                return Ok(RunSigningLookup::MaterialUnavailable {
                    reason: format!("unlock_material_unreadable: {error}"),
                })
            }
        };
        match approval_receipts::decrypt_session_signing_key(ciphertext, &session_secret) {
            Ok(signing_key) => Ok(RunSigningLookup::Available(signing_key)),
            Err(error) => Ok(RunSigningLookup::MaterialUnavailable {
                reason: format!("signing_key_unavailable: {error}"),
            }),
        }
    }
}

pub fn logs_unlocked(project: &str, vault: &Path) -> Result<bool> {
    Ok(active_session(project, vault, UnlockPurpose::Logs)?.is_some())
}

pub fn clear_all_unlocks() -> Result<usize> {
    let path = unlocks_path();
    let state = load_state(&path)?;
    for session in &state.sessions {
        if session.purpose == UnlockPurpose::Logs {
            key_store::delete_secret(&session.key_name)?;
        }
    }
    if path.exists() {
        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
    }
    Ok(state.sessions.len())
}

pub fn clear_project_unlocks(project: &str) -> Result<usize> {
    let path = unlocks_path();
    let mut state = load_state(&path)?;
    let before = state.sessions.len();
    let mut removed_keys = Vec::new();
    state.sessions.retain(|session| {
        let should_remove = session.project == project;
        if should_remove {
            if session.purpose == UnlockPurpose::Logs {
                removed_keys.push(session.key_name.clone());
            }
        }
        !should_remove
    });
    for key_name in removed_keys {
        key_store::delete_secret(&key_name)?;
    }
    let removed = before - state.sessions.len();
    if removed > 0 {
        write_state(&path, &state)?;
    }
    Ok(removed)
}

fn create_unlock(
    project: &str,
    vault: &Path,
    _secret: &str,
    ttl: Duration,
    purpose: UnlockPurpose,
) -> Result<UnlockSession> {
    let mut state = load_state(&unlocks_path())?;
    let now = Utc::now();
    remove_expired_and_matching(&mut state, project, vault, &purpose, now)?;

    let key_name = key_name(project, vault, &purpose);
    #[cfg(test)]
    if purpose == UnlockPurpose::Run {
        key_store::set_secret(&key_name, _secret)?;
    }
    if purpose == UnlockPurpose::Logs {
        key_store::set_secret(&key_name, &random_marker())?;
    }

    #[cfg(test)]
    let signing_key_ciphertext = if purpose == UnlockPurpose::Run {
        let ciphertext =
            approval_receipts::session_signing_key_ciphertext(project, _secret, _secret)?;
        Some(ciphertext)
    } else {
        None
    };
    #[cfg(not(test))]
    let signing_key_ciphertext = None;

    let session = UnlockSession {
        id: uuid::Uuid::new_v4(),
        created_at: now,
        expires_at: now + ttl,
        project: project.to_string(),
        vault: vault.to_path_buf(),
        key_name,
        purpose,
        signing_key_ciphertext,
    };
    state.sessions.push(session.clone());
    write_state(&unlocks_path(), &state)?;
    Ok(session)
}

fn active_session(
    project: &str,
    vault: &Path,
    purpose: UnlockPurpose,
) -> Result<Option<UnlockSession>> {
    let mut state = load_state(&unlocks_path())?;
    let before = state.sessions.len();
    remove_expired(&mut state, Utc::now())?;
    if before != state.sessions.len() {
        write_state(&unlocks_path(), &state)?;
    }

    Ok(state.sessions.into_iter().rev().find(|session| {
        session.project == project
            && session.vault == vault
            && session.purpose == purpose
            && session.expires_at > Utc::now()
    }))
}

fn remove_expired_and_matching(
    state: &mut UnlockState,
    project: &str,
    vault: &Path,
    purpose: &UnlockPurpose,
    now: DateTime<Utc>,
) -> Result<()> {
    let mut removed = Vec::new();
    state.sessions.retain(|session| {
        let should_remove = session.expires_at <= now
            || (session.project == project
                && session.vault == vault
                && session.purpose == *purpose);
        if should_remove {
            if session.purpose == UnlockPurpose::Logs {
                removed.push(session.key_name.clone());
            }
        }
        !should_remove
    });
    for key_name in removed {
        key_store::delete_secret(&key_name)?;
    }
    Ok(())
}

fn remove_expired(state: &mut UnlockState, now: DateTime<Utc>) -> Result<()> {
    let mut removed = Vec::new();
    state.sessions.retain(|session| {
        let expired = session.expires_at <= now;
        if expired {
            if session.purpose == UnlockPurpose::Logs {
                removed.push(session.key_name.clone());
            }
        }
        !expired
    });
    for key_name in removed {
        key_store::delete_secret(&key_name)?;
    }
    Ok(())
}

fn load_state(path: &Path) -> Result<UnlockState> {
    if !path.exists() {
        return Ok(UnlockState::default());
    }
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    serde_json::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
}

fn write_state(path: &Path, state: &UnlockState) -> Result<()> {
    let contents =
        serde_json::to_string_pretty(state).expect("unlock state serialization is infallible");
    fs_util::ensure_private_dir(&logs::ward_home())?;
    fs_util::write_private_file(path, format!("{contents}\n").as_bytes())
}

fn key_name(project: &str, vault: &Path, purpose: &UnlockPurpose) -> String {
    let prefix = match purpose {
        UnlockPurpose::Run => RUN_UNLOCK_PREFIX,
        UnlockPurpose::Logs => LOG_UNLOCK_PREFIX,
        UnlockPurpose::Mode(_) => MODE_UNLOCK_PREFIX,
    };
    let mut hasher = Sha256::new();
    hasher.update(project.as_bytes());
    hasher.update(b"\0");
    hasher.update(vault.to_string_lossy().as_bytes());
    format!("{prefix}/{}", hex::encode(hasher.finalize()))
}

fn random_marker() -> String {
    let mut bytes = [0_u8; 32];
    OsRng.fill_bytes(&mut bytes);
    STANDARD.encode(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;
    use std::sync::{Mutex, OnceLock};

    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
    }

    #[test]
    fn parse_ttl_supports_minutes_hours_days_and_rejects_bad_values() {
        assert_eq!(parse_ttl("15m").unwrap(), Duration::minutes(15));
        assert_eq!(parse_ttl("8h").unwrap(), Duration::hours(8));
        assert_eq!(parse_ttl("1d").unwrap(), Duration::days(1));
        assert!(parse_ttl("0h").is_err());
        assert!(parse_ttl("8x").is_err());
        assert!(parse_ttl("h").is_err());
        assert!(parse_ttl("xh").is_err());
    }

    #[test]
    #[serial_test::serial]
    fn run_and_logs_unlocks_are_scoped_and_clearable() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        create_logs_unlock("demo", &vault, Duration::minutes(15)).unwrap();

        assert_eq!(
            active_run_passphrase("demo", &vault).unwrap().as_deref(),
            Some("passphrase")
        );
        assert!(logs_unlocked("demo", &vault).unwrap());
        assert_eq!(clear_all_unlocks().unwrap(), 2);
        assert!(active_run_passphrase("demo", &vault).unwrap().is_none());

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn run_unlock_lookup_distinguishes_missing_available_and_unavailable_material() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        assert_eq!(
            active_run_lookup("demo", &vault).unwrap().reason(),
            Some("missing_unlock_session")
        );
        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let available = active_run_lookup("demo", &vault).unwrap();
        assert_eq!(available.reason(), None);
        assert!(matches!(
            available,
            RunUnlockLookup::Available(secret) if secret == "passphrase"
        ));
        assert!(create_run_unlock("demo", &vault, "wrong-passphrase", Duration::hours(1)).is_err());

        let key_store_path = crate::logs::ward_home().join("cache").join("keystore.json");
        std::fs::remove_file(&key_store_path).unwrap();
        let unavailable = active_run_lookup("demo", &vault).unwrap();
        assert_eq!(unavailable.reason(), Some("unlock_material_unavailable"));
        assert!(matches!(
            unavailable,
            RunUnlockLookup::MaterialUnavailable { reason } if reason == "unlock_material_unavailable"
        ));

        std::fs::write(&key_store_path, "{bad-json}").unwrap();
        assert!(matches!(
            active_run_lookup("demo", &vault).unwrap(),
            RunUnlockLookup::MaterialUnavailable { reason } if reason.starts_with("unlock_material_unreadable:")
        ));
        assert!(active_run_passphrase("demo", &vault).is_err());

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn signing_key_lookup_reports_missing_unreadable_and_bad_ciphertext() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let available = active_run_signing_key("demo", &vault).unwrap();
        assert!(matches!(available, RunSigningLookup::Available(_)));

        let mut state = load_state(&unlocks_path()).unwrap();
        state.sessions[0].signing_key_ciphertext = None;
        write_state(&unlocks_path(), &state).unwrap();
        assert!(matches!(
            active_run_signing_key("demo", &vault).unwrap(),
            RunSigningLookup::MaterialUnavailable { reason } if reason == "signing_key_unavailable"
        ));

        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let key_store_path = crate::logs::ward_home().join("cache").join("keystore.json");
        std::fs::remove_file(&key_store_path).unwrap();
        assert!(matches!(
            active_run_signing_key("demo", &vault).unwrap(),
            RunSigningLookup::MaterialUnavailable { reason } if reason == "unlock_material_unavailable"
        ));

        std::fs::write(&key_store_path, "{bad-json}").unwrap();
        assert!(matches!(
            active_run_signing_key("demo", &vault).unwrap(),
            RunSigningLookup::MaterialUnavailable { reason } if reason.starts_with("unlock_material_unreadable:")
        ));

        std::fs::remove_file(&key_store_path).unwrap();
        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let mut state = load_state(&unlocks_path()).unwrap();
        state.sessions[0].signing_key_ciphertext = Some(
            crate::approval_receipts::session_signing_key_ciphertext(
                "demo",
                "passphrase",
                "other-session",
            )
            .unwrap(),
        );
        write_state(&unlocks_path(), &state).unwrap();
        assert!(matches!(
            active_run_signing_key("demo", &vault).unwrap(),
            RunSigningLookup::MaterialUnavailable { reason } if reason.starts_with("signing_key_unavailable:")
        ));

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn clears_only_one_projects_unlock_sessions() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let demo_vault = tempdir.path().join("demo.env.vault");
        let other_vault = tempdir.path().join("other.env.vault");

        create_run_unlock("demo", &demo_vault, "demo-passphrase", Duration::hours(1)).unwrap();
        create_logs_unlock("demo", &demo_vault, Duration::minutes(15)).unwrap();
        create_run_unlock(
            "other",
            &other_vault,
            "other-passphrase",
            Duration::hours(1),
        )
        .unwrap();

        assert_eq!(clear_project_unlocks("missing").unwrap(), 0);
        assert_eq!(clear_project_unlocks("demo").unwrap(), 2);
        assert!(active_run_passphrase("demo", &demo_vault)
            .unwrap()
            .is_none());
        assert_eq!(
            active_run_passphrase("other", &other_vault)
                .unwrap()
                .as_deref(),
            Some("other-passphrase")
        );

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn expired_unlock_is_removed() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let mut state = load_state(&unlocks_path()).unwrap();
        state.sessions[0].expires_at = Utc::now() - Duration::minutes(1);
        write_state(&unlocks_path(), &state).unwrap();

        assert!(active_run_passphrase("demo", &vault).unwrap().is_none());

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn replacing_unlock_removes_previous_secret_and_load_errors_surface() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        let first = create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        let second = create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).unwrap();
        assert_ne!(first.id, second.id);
        assert_eq!(
            active_run_passphrase("demo", &vault).unwrap().as_deref(),
            Some("passphrase")
        );
        assert_eq!(clear_all_unlocks().unwrap(), 1);
        assert_eq!(clear_all_unlocks().unwrap(), 0);

        std::fs::create_dir_all(unlocks_path().parent().unwrap()).unwrap();
        std::fs::write(unlocks_path(), "{bad-json}").unwrap();
        assert!(active_run_passphrase("demo", &vault).is_err());

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }

    #[test]
    #[serial_test::serial]
    fn unlock_storage_and_key_store_failures_are_reported() {
        let _guard = env_lock();
        let tempdir = tempfile::tempdir().unwrap();
        std::env::set_var("WARD_HOME", tempdir.path());
        std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
        let vault = tempdir.path().join(".env.vault");

        std::fs::create_dir_all(unlocks_path().parent().unwrap()).unwrap();
        std::fs::write(unlocks_path(), "{bad-json}").unwrap();
        assert!(clear_all_unlocks().is_err());
        assert!(create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).is_err());
        assert!(logs_unlocked("demo", &vault).is_err());

        std::fs::remove_file(unlocks_path()).unwrap();
        std::fs::create_dir(unlocks_path()).unwrap();
        assert!(load_state(&unlocks_path()).is_err());
        assert!(write_state(&unlocks_path(), &UnlockState::default()).is_err());
        std::fs::remove_dir(unlocks_path()).unwrap();

        let key_store_path = crate::logs::ward_home().join("cache").join("keystore.json");
        std::fs::create_dir_all(key_store_path.parent().unwrap()).unwrap();
        std::fs::write(&key_store_path, "{bad-json}").unwrap();
        assert!(create_logs_unlock("demo", &vault, Duration::minutes(15)).is_err());

        std::fs::remove_file(&key_store_path).unwrap();

        #[cfg(unix)]
        {
            write_state(&unlocks_path(), &UnlockState::default()).unwrap();
            let sessions_dir = unlocks_path().parent().unwrap().to_path_buf();
            let original_permissions = std::fs::metadata(&sessions_dir).unwrap().permissions();
            std::fs::set_permissions(&sessions_dir, std::fs::Permissions::from_mode(0o500))
                .unwrap();
            let result = clear_all_unlocks();
            std::fs::set_permissions(&sessions_dir, original_permissions).unwrap();
            assert!(result.is_err());
            std::fs::remove_file(unlocks_path()).unwrap();
        }

        let sessions_dir = unlocks_path().parent().unwrap().to_path_buf();
        std::fs::remove_dir_all(&sessions_dir).unwrap();
        std::fs::write(&sessions_dir, "").unwrap();
        assert!(create_run_unlock("demo", &vault, "passphrase", Duration::hours(1)).is_err());

        std::env::remove_var("WARD_HOME");
        std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
    }
}
