// OAuth token storage abstraction.
//
// This module centralises how OAuth2 tokens are persisted. It tries the OS
// keychain first (Credential Manager on Windows, Secret Service on Linux
// via libdbus) and falls back to a restricted file on disk if the keychain
// is unavailable.
//
// Security properties:
// - The keychain service name matches the Tauri bundle identifier
//   (com.simpl.resultat) so OS tools and future macOS builds can scope
//   credentials correctly.
// - A `store_mode` flag is persisted next to the fallback file. Once the
//   keychain has been used successfully, the store refuses to silently
//   downgrade to the file fallback: a subsequent keychain failure is
//   surfaced as an error so the caller can force re-authentication
//   instead of leaving the user with undetected plaintext tokens.
// - Migration from an existing `tokens.json` file zeros the file contents
//   and fsyncs before unlinking, reducing the window where the refresh
//   token is recoverable from unallocated disk blocks.

use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
use zeroize::Zeroize;

// Keychain identifiers. The service name matches tauri.conf.json's
// `identifier` so credentials are scoped to the real app identity.
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";

pub(crate) const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const STORE_MODE_FILE: &str = "store_mode";

/// Where token material currently lives. Exposed via a Tauri command so
/// the frontend can display a security banner when the app has fallen
/// back to the file store.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreMode {
    Keychain,
    File,
}

impl StoreMode {
    fn as_str(&self) -> &'static str {
        match self {
            StoreMode::Keychain => "keychain",
            StoreMode::File => "file",
        }
    }

    fn parse(raw: &str) -> Option<Self> {
        match raw.trim() {
            "keychain" => Some(StoreMode::Keychain),
            "file" => Some(StoreMode::File),
            _ => None,
        }
    }
}

/// Serialised OAuth token bundle. Owned by `token_store` because this is
/// the only module that should reach for the persisted bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub id_token: Option<String>,
    pub expires_at: i64,
}

/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
/// auth_commands.rs which still writes non-secret files (account info,
/// last-check timestamp) in the same directory.
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    let dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("Cannot get app data dir: {}", e))?
        .join(AUTH_DIR);
    if !dir.exists() {
        fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
    }
    Ok(dir)
}

/// Write a file with 0600 permissions on Unix. Windows has no cheap
/// equivalent here; callers that rely on this function for secrets should
/// treat the Windows path as a last-resort fallback and surface the
/// degraded state to the user (see StoreMode).
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)
            .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
        file.write_all(contents.as_bytes())
            .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
        file.sync_all()
            .map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
    }
    #[cfg(not(unix))]
    {
        fs::write(path, contents)
            .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
    }
    Ok(())
}

pub(crate) fn chrono_now() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64
}

fn read_store_mode(dir: &Path) -> Option<StoreMode> {
    let path = dir.join(STORE_MODE_FILE);
    let raw = fs::read_to_string(&path).ok()?;
    StoreMode::parse(&raw)
}

fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
    write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
}

/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
/// mitigation against recovery of the refresh token from unallocated
/// blocks on copy-on-write filesystems where a plain unlink leaves the
/// ciphertext recoverable. Not a substitute for proper disk encryption.
fn zero_and_delete(path: &Path) -> Result<(), String> {
    if !path.exists() {
        return Ok(());
    }
    let len = fs::metadata(path)
        .map(|m| m.len() as usize)
        .unwrap_or(0)
        .max(512);
    let mut zeros = vec![0u8; len];
    {
        let mut f = fs::OpenOptions::new()
            .write(true)
            .truncate(false)
            .open(path)
            .map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
        f.write_all(&zeros)
            .map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
        f.sync_all()
            .map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
    }
    zeros.zeroize();
    fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
}

fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
    serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
}

fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
    serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
}

fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
    keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
}

fn keychain_save(json: &str) -> Result<(), keyring::Error> {
    keychain_entry()?.set_password(json)
}

fn keychain_load() -> Result<Option<String>, keyring::Error> {
    match keychain_entry()?.get_password() {
        Ok(v) => Ok(Some(v)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(e) => Err(e),
    }
}

fn keychain_delete() -> Result<(), keyring::Error> {
    match keychain_entry()?.delete_credential() {
        Ok(()) => Ok(()),
        Err(keyring::Error::NoEntry) => Ok(()),
        Err(e) => Err(e),
    }
}

/// Persist the current OAuth token bundle.
///
/// Tries the OS keychain first. If the keychain write fails AND the
/// persisted `store_mode` flag shows the keychain has worked before, the
/// caller receives an error instead of a silent downgrade — this
/// prevents a hostile local process from forcing the app into the
/// weaker file-fallback path. On a fresh install (no prior flag), the
/// fallback is allowed but recorded so subsequent calls know the app is
/// running in degraded mode.
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
    let json = tokens_to_json(tokens)?;
    let dir = auth_dir(app)?;
    let previous_mode = read_store_mode(&dir);

    match keychain_save(&json) {
        Ok(()) => {
            // Keychain succeeded. Clean up any residual file from a prior
            // fallback or migration source so nothing stays readable.
            let residual = dir.join(TOKENS_FILE);
            if residual.exists() {
                let _ = zero_and_delete(&residual);
            }
            write_store_mode(&dir, StoreMode::Keychain)?;
            Ok(())
        }
        Err(err) => {
            if previous_mode == Some(StoreMode::Keychain) {
                // Refuse to downgrade after a prior success — surface the
                // failure so the caller can force re-auth instead of
                // silently leaking tokens to disk.
                return Err(format!(
                    "Keychain unavailable after prior success — refusing to downgrade. \
                     Original error: {}",
                    err
                ));
            }
            eprintln!(
                "token_store: keychain unavailable, falling back to file store ({})",
                err
            );
            write_restricted(&dir.join(TOKENS_FILE), &json)?;
            write_store_mode(&dir, StoreMode::File)?;
            Ok(())
        }
    }
}

/// Load the current OAuth token bundle.
///
/// Tries the keychain first. If the keychain is empty but a legacy
/// `tokens.json` file exists, this is a first-run migration: the tokens
/// are copied into the keychain, the file is zeroed and unlinked, and
/// `store_mode` is updated. If the keychain itself is unreachable, the
/// function falls back to reading the file — unless the `store_mode`
/// flag indicates the keychain has worked before, in which case it
/// returns an error to force re-auth.
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
    let dir = auth_dir(app)?;
    let previous_mode = read_store_mode(&dir);
    let residual = dir.join(TOKENS_FILE);

    match keychain_load() {
        Ok(Some(raw)) => {
            let tokens = tokens_from_json(&raw)?;
            // Defensive: if a leftover file is still around (e.g. a prior
            // crash between keychain write and file delete), clean it up.
            if residual.exists() {
                let _ = zero_and_delete(&residual);
            }
            if previous_mode != Some(StoreMode::Keychain) {
                write_store_mode(&dir, StoreMode::Keychain)?;
            }
            Ok(Some(tokens))
        }
        Ok(None) => {
            // Keychain reachable but empty. Migrate from a legacy file if
            // one exists, otherwise report no stored session.
            if residual.exists() {
                let raw = fs::read_to_string(&residual)
                    .map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
                let tokens = tokens_from_json(&raw)?;
                // Push into keychain and wipe the file. If the keychain
                // push fails here, we keep the file rather than losing
                // the user's session.
                match keychain_save(&raw) {
                    Ok(()) => {
                        let _ = zero_and_delete(&residual);
                        write_store_mode(&dir, StoreMode::Keychain)?;
                    }
                    Err(e) => {
                        eprintln!("token_store: migration to keychain failed ({})", e);
                        write_store_mode(&dir, StoreMode::File)?;
                    }
                }
                Ok(Some(tokens))
            } else {
                Ok(None)
            }
        }
        Err(err) => {
            if previous_mode == Some(StoreMode::Keychain) {
                return Err(format!(
                    "Keychain unavailable after prior success: {}",
                    err
                ));
            }
            // No prior keychain success: honour the file fallback if any.
            eprintln!(
                "token_store: keychain unreachable, using file fallback ({})",
                err
            );
            if residual.exists() {
                let raw = fs::read_to_string(&residual)
                    .map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
                write_store_mode(&dir, StoreMode::File)?;
                Ok(Some(tokens_from_json(&raw)?))
            } else {
                Ok(None)
            }
        }
    }
}

/// Delete the stored tokens from both the keychain and the file
/// fallback. Both deletions are best-effort and ignore "no entry"
/// errors to stay idempotent.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
    let dir = auth_dir(app)?;
    if let Err(err) = keychain_delete() {
        eprintln!("token_store: keychain delete failed ({})", err);
    }
    let residual = dir.join(TOKENS_FILE);
    if residual.exists() {
        let _ = zero_and_delete(&residual);
    }
    // Leave the store_mode flag alone: it still describes what the app
    // should trust the next time `save` is called.
    Ok(())
}

/// Current store mode, derived from the persisted flag. Returns `None`
/// if no tokens have ever been written (no flag file).
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
    let dir = auth_dir(app)?;
    Ok(read_store_mode(&dir))
}

/// Tauri command: expose the current store mode to the frontend.
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
/// session yet. Used by the settings UI to show a security banner when
/// the fallback is active.
#[tauri::command]
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
    Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
}

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

    #[test]
    fn store_mode_roundtrip() {
        assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
        assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
        assert_eq!(StoreMode::parse("other"), None);
        assert_eq!(StoreMode::Keychain.as_str(), "keychain");
        assert_eq!(StoreMode::File.as_str(), "file");
    }

    #[test]
    fn stored_tokens_serde_roundtrip() {
        let tokens = StoredTokens {
            access_token: "at".into(),
            refresh_token: Some("rt".into()),
            id_token: Some("it".into()),
            expires_at: 42,
        };
        let json = tokens_to_json(&tokens).unwrap();
        let decoded = tokens_from_json(&json).unwrap();
        assert_eq!(decoded.access_token, "at");
        assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
        assert_eq!(decoded.id_token.as_deref(), Some("it"));
        assert_eq!(decoded.expires_at, 42);
    }

    #[test]
    fn zero_and_delete_removes_file() {
        use std::io::Write as _;
        let tmp = std::env::temp_dir().join(format!(
            "simpl-resultat-token-store-test-{}",
            std::process::id()
        ));
        let mut f = fs::File::create(&tmp).unwrap();
        f.write_all(b"sensitive").unwrap();
        drop(f);
        assert!(tmp.exists());
        zero_and_delete(&tmp).unwrap();
        assert!(!tmp.exists());
    }
}
