//! # `chanora_storage`
//!
//! Two strictly separated repositories per SAD-067:
//!
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
//!   settings, identity *references*) via SQLite. Crate choice:
//!   `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
//!   `poc/sqlite-storage-spike` lands in v0.4.
//! * [`SecretStorageRepository`] — secret material (identity private
//!   keys, server passwords) via platform secure storage. Linux
//!   policy: Secret Service preferred, kernel keyutils fallback
//!   (DEC-013.2). Other platforms TBD per SS-TC-001/002/004/005.
//!
//! Secret values **never** appear in the local DB (SS-AUD-001/002);
//! bookmarks store only an `identity_ref` lookup name into the
//! secure store.
//!
//! ## Beta status (A.2)
//!
//! Ships a single concrete identity-only store, [`IdentityFileStore`],
//! that persists a single identity to a file at
//! `<storage_dir>/identity.tskey` with restrictive POSIX permissions
//! (0600) on Unix. This is the **best-effort fallback** the secure
//! storage policy permits when no platform keyring is available
//! (RISK-PoC-002 / SS-RISK-FALLBACK). The full Secret Service +
//! keyutils backend, plus Android Keystore / iOS Keychain, will
//! replace this in v0.4. Until then:
//!
//! * Linux desktop: file with 0600 mode in `$XDG_DATA_HOME/chanora`
//!   (or the path provided by the bridge caller).
//! * Android: file in app-private storage. App-private means it's
//!   not world-readable, but it is **not** encrypted at rest. This
//!   is the documented Beta gap.
//! * iOS / Windows / macOS: same — caller chooses the directory.
//!
//! The store is intentionally limited to one identity per
//! installation in Beta; bookmark / multi-identity support arrives
//! with the SQLite repository.

#![forbid(unsafe_code)]
#![warn(missing_docs)]

use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use rusqlite::{params, Connection};
use thiserror::Error;
use tracing::{info, warn};
use zeroize::Zeroize;

/// Errors raised by either storage repository.
#[derive(Debug, Error)]
pub enum StorageError {
    /// Looked-up entry does not exist.
    #[error("not found")]
    NotFound,
    /// Schema migration error.
    #[error("migration: {0}")]
    Migration(String),
    /// Underlying SQLite error.
    #[error("sqlite: {0}")]
    Sqlite(String),
    /// Platform secure-storage backend rejected an operation.
    #[error("secure-store: {0}")]
    SecureStore(String),
    /// Filesystem I/O error (Beta file-fallback store).
    #[error("io: {0}")]
    Io(String),
    /// Cryptographic operation failed (key generation, encrypt, or
    /// decrypt). Typically indicates a corrupted DEK or tampered
    /// identity file.
    #[error("crypto: {0}")]
    Crypto(String),
}

/// Marker trait for the non-secret database side. Concrete impl will
/// land alongside the `BookmarkRepository` / `SettingsRepository`
/// traits promoted from the SQLite PoC.
pub trait LocalDatabaseRepository: Send + Sync {}

/// Marker trait for the platform secure-storage side. Concrete impl
/// will land alongside the `Secret` newtype + per-platform adapters
/// promoted from the secure-storage PoC.
pub trait SecretStorageRepository: Send + Sync {}

/// Beta identity store: a single ChaCha20-Poly1305-encrypted file
/// containing the base64 TS3 identity string. The Data Encryption
/// Key (DEK) is 32 random bytes stored in the platform keyring
/// (Linux Secret Service via D-Bus, macOS Keychain, Windows
/// Credential Manager, iOS Keychain) when available, with a
/// best-effort file fallback at `identity.dek` (mode 0600) when the
/// keyring is unreachable. Per DEC-013.2 the Linux preference is
/// Secret Service; the kernel keyutils fallback is not yet wired —
/// see the keyring-error log line on first run when the bus is
/// missing.
///
/// Threat-model honest assessment:
///
/// * **Keyring path** (the normal case on a logged-in desktop): the
///   DEK lives in the OS keyring, locked under the user's session.
///   Recovering the identity then requires both the on-disk
///   ciphertext *and* the active user session — a meaningful
///   improvement over the file-fallback two-file model.
/// * **File-fallback path** (headless servers, CI containers,
///   Android-without-Keystore, fresh installs where the keyring
///   isn't running yet): same dual-file guarantee as v0.4 — both
///   files in the install dir must be readable to recover the
///   identity.
/// * **Does NOT help against** a malicious user inside the same
///   session (keyring lookup succeeds for any process the user
///   runs); v1.1+ may pursue per-process scoping where the OS
///   supports it.
///
/// File format: `identity.tskey` = `[12-byte nonce][AEAD ciphertext+tag]`.
/// Legacy plaintext files written by v0.3 are still readable; the
/// next `save()` upgrades them to encrypted form (and shreds the
/// plaintext temp file via the atomic rename).
#[derive(Debug, Clone)]
pub struct IdentityFileStore {
    path: PathBuf,
    dek_path: PathBuf,
    /// Stable per-install identifier used as the keyring account
    /// name. Derived from the install directory so the same store
    /// finds the same keyring entry across restarts.
    keyring_account: String,
}

impl IdentityFileStore {
    /// Service name used for the platform keyring entry. Kept
    /// short and stable so a re-install with the same install
    /// directory finds the existing DEK.
    pub const KEYRING_SERVICE: &'static str = "chanora";

    /// Construct a store rooted at `dir`. Creates the directory and
    /// the DEK on first use; subsequent uses reuse the existing DEK.
    pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
        let dir = dir.as_ref();
        fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
        let canonical = fs::canonicalize(dir)
            .map(|p| p.to_string_lossy().into_owned())
            .unwrap_or_else(|_| dir.to_string_lossy().into_owned());
        let store = Self {
            path: dir.join("identity.tskey"),
            dek_path: dir.join("identity.dek"),
            keyring_account: format!("identity-dek::{canonical}"),
        };
        // Ensure a DEK exists somewhere we can retrieve it.
        store.ensure_dek()?;
        Ok(store)
    }

    /// Path to the underlying identity file. Exposed for diagnostics.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Try to read the DEK from the platform keyring. Returns
    /// `Ok(None)` when no entry exists or when the platform keyring
    /// is unreachable (typical for headless / CI hosts). Decoding
    /// errors propagate as [`StorageError::Crypto`].
    ///
    /// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
    /// environments where a real Secret Service call would block on
    /// a missing D-Bus session.
    #[allow(unused_variables)]
    fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
        if keyring_disabled() {
            return Ok(None);
        }
        #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
        {
            use base64::Engine;
            let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
                Ok(e) => e,
                Err(e) => {
                    warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
                    return Ok(None);
                }
            };
            match entry.get_password() {
                Ok(b64) => {
                    let bytes = base64::engine::general_purpose::STANDARD
                        .decode(b64.as_bytes())
                        .map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
                    if bytes.len() != 32 {
                        return Err(StorageError::Crypto(format!(
                            "keyring dek length {} (expected 32)",
                            bytes.len()
                        )));
                    }
                    let mut key = [0u8; 32];
                    key.copy_from_slice(&bytes);
                    Ok(Some(key))
                }
                Err(keyring::Error::NoEntry) => Ok(None),
                Err(e) => {
                    // Bus unreachable, no session, locked keychain
                    // — best-effort: fall through to file.
                    warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
                    Ok(None)
                }
            }
        }
        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
        {
            Ok(None)
        }
    }

    /// Persist the DEK in the platform keyring. Returns true on
    /// success and false when the keyring is unreachable (caller
    /// should then fall back to the file path).
    ///
    /// Honours `CHANORA_DISABLE_KEYRING=1`.
    #[allow(unused_variables)]
    fn keyring_save(&self, key: &[u8; 32]) -> bool {
        if keyring_disabled() {
            return false;
        }
        #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
        {
            use base64::Engine;
            let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
                Ok(e) => e,
                Err(_) => return false,
            };
            let b64 = base64::engine::general_purpose::STANDARD.encode(key);
            match entry.set_password(&b64) {
                Ok(()) => {
                    info!(target: "chanora_storage", "DEK stored in platform keyring");
                    true
                }
                Err(e) => {
                    warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
                    false
                }
            }
        }
        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
        {
            false
        }
    }

    fn ensure_dek(&self) -> Result<(), StorageError> {
        // Already in the keyring? Done.
        if self.keyring_load()?.is_some() {
            return Ok(());
        }
        // File-fallback DEK present? Try to migrate into the
        // keyring opportunistically (lets users upgrade for free)
        // and keep the file as the live source if migration fails.
        if self.dek_path.exists() {
            if let Ok(key) = read_file_dek(&self.dek_path) {
                if self.keyring_save(&key) {
                    // Best-effort scrub: remove the file copy now
                    // that the keyring holds the authoritative value.
                    let _ = fs::remove_file(&self.dek_path);
                }
                let mut k = key;
                k.zeroize();
            }
            return Ok(());
        }
        // Fresh install: generate a new DEK and store it in the
        // keyring if we can, else fall back to the file.
        let mut key = [0u8; 32];
        OsRng.fill_bytes(&mut key);
        if !self.keyring_save(&key) {
            let mut f = open_private(&self.dek_path)?;
            f.write_all(&key)
                .map_err(|e| StorageError::Io(format!("write dek: {e}")))?;
            f.sync_all()
                .map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
            info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
        }
        key.zeroize();
        Ok(())
    }

    fn load_dek(&self) -> Result<[u8; 32], StorageError> {
        if let Some(k) = self.keyring_load()? {
            return Ok(k);
        }
        read_file_dek(&self.dek_path)
    }

    /// Hand out a [`DekCrypto`] anchored on the same DEK that
    /// encrypts the identity file. Used by [`BookmarkRepository`]
    /// to encrypt server-password columns under the same per-install
    /// key (MVP hardening).
    pub fn crypto(&self) -> Result<impl Crypto + Clone + std::fmt::Debug + 'static, StorageError> {
        Ok(DekCrypto::new(self.load_dek()?))
    }

    /// Read the persisted identity, if any. Transparently handles
    /// the legacy plaintext format (pre-External Beta).
    pub fn load(&self) -> Result<Option<String>, StorageError> {
        let mut f = match fs::File::open(&self.path) {
            Ok(f) => f,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
        };
        let mut buf = Vec::new();
        f.read_to_end(&mut buf)
            .map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
        if buf.is_empty() {
            return Ok(None);
        }

        // Encrypted format: at least nonce (12) + tag (16) = 28
        // bytes, and the first byte should not be a printable ASCII
        // base64 character. Heuristic: a legacy plaintext file
        // starts with an ASCII digit (counter prefix) or an ASCII
        // letter (raw base64 key). If the first byte is non-ASCII
        // or non-printable, treat as ciphertext.
        if buf.len() >= 28 && !is_plausibly_legacy_plaintext(&buf) {
            let mut key_bytes = self.load_dek()?;
            let key = Key::from_slice(&key_bytes);
            let cipher = ChaCha20Poly1305::new(key);
            let nonce_bytes = &buf[..12];
            let nonce = Nonce::from_slice(nonce_bytes);
            let pt = cipher
                .decrypt(nonce, &buf[12..])
                .map_err(|e| {
                    key_bytes.zeroize();
                    StorageError::Crypto(format!("decrypt: {e}"))
                })?;
            key_bytes.zeroize();
            let s = String::from_utf8(pt)
                .map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?;
            let trimmed = s.trim().to_string();
            if trimmed.is_empty() {
                return Ok(None);
            }
            return Ok(Some(trimmed));
        }

        // Legacy plaintext path. We do NOT auto-upgrade here —
        // upgrade happens on the next save() to keep load()
        // side-effect-free.
        warn!(
            target: "chanora_storage",
            "identity file is in legacy plaintext format; will encrypt on next save"
        );
        let s = String::from_utf8(buf)
            .map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?;
        let trimmed = s.trim().to_string();
        if trimmed.is_empty() {
            Ok(None)
        } else {
            Ok(Some(trimmed))
        }
    }

    /// Persist `identity` to disk, replacing any previous content.
    /// On Unix the file is written with mode 0600. Encrypted with
    /// ChaCha20-Poly1305 using the per-install DEK.
    pub fn save(&self, identity: &str) -> Result<(), StorageError> {
        let plaintext = identity.trim().as_bytes();
        let mut key_bytes = self.load_dek()?;
        let key = Key::from_slice(&key_bytes);
        let cipher = ChaCha20Poly1305::new(key);
        let mut nonce_bytes = [0u8; 12];
        OsRng.fill_bytes(&mut nonce_bytes);
        let nonce = Nonce::from_slice(&nonce_bytes);
        let ct = cipher.encrypt(nonce, plaintext).map_err(|e| {
            key_bytes.zeroize();
            StorageError::Crypto(format!("encrypt: {e}"))
        })?;
        key_bytes.zeroize();

        // Atomic write: temp file + rename.
        let tmp = self.path.with_extension("tskey.tmp");
        {
            let mut f = open_private(&tmp)?;
            f.write_all(&nonce_bytes)
                .map_err(|e| StorageError::Io(format!("write nonce: {e}")))?;
            f.write_all(&ct)
                .map_err(|e| StorageError::Io(format!("write ct: {e}")))?;
            f.sync_all()
                .map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?;
        }
        fs::rename(&tmp, &self.path)
            .map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?;
        info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)");
        Ok(())
    }

    /// Remove any persisted identity. No-op if none exists. Leaves
    /// the DEK in place so future saves don't generate a new one.
    pub fn clear(&self) -> Result<(), StorageError> {
        match fs::remove_file(&self.path) {
            Ok(()) => {
                info!(target: "chanora_storage", path = ?self.path, "identity cleared");
                Ok(())
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(StorageError::Io(format!(
                "remove {:?}: {e}",
                self.path
            ))),
        }
    }
}

/// True if `buf` looks like a legacy plaintext identity (printable
/// ASCII with a digit/letter first byte and at most a trailing
/// newline). False for ciphertext.
fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
    if buf.is_empty() {
        return false;
    }
    let first = buf[0];
    if !(first.is_ascii_alphanumeric() || first == b'+' || first == b'/') {
        return false;
    }
    // The TS3 identity format is base64 + a counter prefix; all
    // bytes are printable ASCII. The ciphertext is uniformly random.
    buf.iter().all(|&b| {
        b.is_ascii_alphanumeric()
            || b == b'+'
            || b == b'/'
            || b == b'='
            || b == b'V'
            || b == b'\n'
            || b == b'\r'
            || b == b' '
    })
}

/// Read a 32-byte DEK from `path`. Used by the file-fallback path
/// and the legacy migration path inside `ensure_dek`.
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
    let mut f = fs::File::open(path)
        .map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
    let mut key = [0u8; 32];
    f.read_exact(&mut key)
        .map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
    Ok(key)
}

/// True when `CHANORA_DISABLE_KEYRING=1` is set. Lets tests and
/// headless / sandboxed environments force the file-fallback path
/// without poking a real OS keyring (which would either prompt the
/// user or block on a missing D-Bus session).
fn keyring_disabled() -> bool {
    matches!(
        std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
        Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes")
    )
}

#[cfg(unix)]
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
    use std::os::unix::fs::OpenOptionsExt;
    fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .mode(0o600)
        .open(p)
        .map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
}

#[cfg(not(unix))]
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
    // On non-Unix targets we can't set POSIX mode bits; the file
    // sits in app-private storage where the platform sandbox does
    // the access control. Document the gap rather than failing.
    warn!(target: "chanora_storage", "non-unix: file permissions not restricted");
    fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(p)
        .map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
}

/// Public abstraction over the per-install envelope-encryption
/// helper. Callers see only the encrypt/decrypt pair so the
/// concrete key material stays inside `chanora_storage`.
pub trait Crypto: Send + Sync {
    /// Encrypt `plaintext` under the per-install DEK. Returns a
    /// fresh `[nonce || ct || tag]` blob.
    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError>;
    /// Decrypt a blob previously produced by [`Self::encrypt`].
    fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError>;
}

impl Crypto for DekCrypto {
    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
        DekCrypto::encrypt(self, plaintext)
    }
    fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
        DekCrypto::decrypt(self, blob)
    }
}

/// Shared envelope encryption helper used by both
/// [`IdentityFileStore`] and [`BookmarkRepository`]. Both rely on
/// the same per-install 32-byte DEK so a single keyring entry (or
/// fallback file) protects everything secret in `<storage_dir>`.
///
/// Wire format: `[12-byte nonce][AEAD ct+tag]`. Bytes are opaque to
/// callers; persist them as-is.
#[derive(Clone)]
struct DekCrypto {
    key: [u8; 32],
}

impl std::fmt::Debug for DekCrypto {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never expose the key in Debug output.
        f.write_str("DekCrypto { key: [redacted] }")
    }
}

impl DekCrypto {
    fn new(key: [u8; 32]) -> Self {
        Self { key }
    }

    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
        let key = Key::from_slice(&self.key);
        let cipher = ChaCha20Poly1305::new(key);
        let mut nonce_bytes = [0u8; 12];
        OsRng.fill_bytes(&mut nonce_bytes);
        let nonce = Nonce::from_slice(&nonce_bytes);
        let ct = cipher
            .encrypt(nonce, plaintext)
            .map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
        let mut out = Vec::with_capacity(12 + ct.len());
        out.extend_from_slice(&nonce_bytes);
        out.extend_from_slice(&ct);
        Ok(out)
    }

    fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
        if blob.len() < 12 + 16 {
            return Err(StorageError::Crypto(format!(
                "envelope length {} < minimum",
                blob.len()
            )));
        }
        let key = Key::from_slice(&self.key);
        let cipher = ChaCha20Poly1305::new(key);
        let nonce = Nonce::from_slice(&blob[..12]);
        cipher
            .decrypt(nonce, &blob[12..])
            .map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
    }
}

impl Drop for DekCrypto {
    fn drop(&mut self) {
        self.key.zeroize();
    }
}

/// A persisted bookmark: a friendly label paired with a TS3 server
/// address and the nickname the user wants when connecting.
///
/// Bookmark rows are uniquely identified by an auto-incrementing
/// `id`. The `display_name` is purely cosmetic. `host` is the same
/// string the user would type into the connect form.
///
/// MVP hardening: when the repository is constructed via
/// [`BookmarkRepository::with_crypto`] the `password` column is
/// stored as a ChaCha20-Poly1305 envelope under the per-install
/// DEK, so a stolen SQLite file alone does not leak server
/// passwords. The plaintext column is still read for backward
/// compatibility and upgraded on the next `update()` call. Rows
/// inserted by a pre-MVP build remain readable with their plain
/// password values until upgraded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bookmark {
    /// Stable row id assigned by SQLite.
    pub id: i64,
    /// User-facing label.
    pub display_name: String,
    /// `hostname[:port]` or TSDNS name.
    pub host: String,
    /// Nickname to use for this bookmark.
    pub nickname: String,
    /// Optional remembered server password. Stored as plain text in
    /// the SQLite file (Beta gap, RISK-PoC-002). v0.4 will lift
    /// passwords into the keyring.
    pub password: Option<String>,
}

/// SQLite-backed bookmark store. The DB file lives at
/// `<storage_dir>/chanora.db`. The schema is migrated on
/// construction; failures here abort the constructor rather than
/// poisoning later calls.
///
/// MVP: when a [`Crypto`] is wired via [`Self::with_crypto`] the
/// `password` column is replaced by an encrypted `password_blob`
/// column. Legacy plaintext passwords in the `password` column are
/// still read transparently and lifted into `password_blob` on the
/// next `update()` call so existing v0.4 installs upgrade for free.
pub struct BookmarkRepository {
    conn: Mutex<Connection>,
    crypto: Option<Box<dyn Crypto>>,
}

impl BookmarkRepository {
    /// Open or create the bookmark database under `dir`. No
    /// password-column encryption — equivalent to the v0.4 behaviour
    /// and kept for tests.
    pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
        Self::open(dir.as_ref(), None)
    }

    /// Open or create the bookmark database with password-column
    /// encryption wired to the per-install DEK.
    pub fn with_crypto<C>(dir: impl AsRef<Path>, crypto: C) -> Result<Self, StorageError>
    where
        C: Crypto + 'static,
    {
        Self::open(dir.as_ref(), Some(Box::new(crypto) as Box<dyn Crypto>))
    }

    fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
        fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
        let path = dir.join("chanora.db");
        let conn = Connection::open(&path)
            .map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?;
        conn.pragma_update(None, "foreign_keys", "ON")
            .map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?;
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS bookmarks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                display_name TEXT NOT NULL,
                host TEXT NOT NULL,
                nickname TEXT NOT NULL,
                password TEXT
            );
            CREATE TABLE IF NOT EXISTS schema_version (
                v INTEGER PRIMARY KEY
            );
            INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
        )
        .map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
        // Schema v2 migration: encrypted password column. Idempotent.
        let has_blob: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM pragma_table_info('bookmarks') WHERE name='password_blob'",
                [],
                |r| r.get(0),
            )
            .map_err(|e| StorageError::Migration(format!("table_info: {e}")))?;
        if has_blob == 0 {
            conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
                .map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?;
            conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
                .map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
            info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
        }
        info!(target: "chanora_storage", path = ?path, "bookmark db opened");
        Ok(Self {
            conn: Mutex::new(conn),
            crypto,
        })
    }

    /// True if the repository has password-column encryption wired.
    pub fn encrypts_passwords(&self) -> bool {
        self.crypto.is_some()
    }

    /// Insert a new bookmark and return its assigned id. The `id`
    /// field on the input is ignored. Encrypts the password if a
    /// crypto helper is wired; otherwise writes it plain.
    pub fn add(&self, b: &Bookmark) -> Result<i64, StorageError> {
        let conn = self
            .conn
            .lock()
            .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
        let blob = match (&self.crypto, &b.password) {
            (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
            _ => None,
        };
        let plain: Option<&str> = if self.crypto.is_some() {
            None
        } else {
            b.password.as_deref()
        };
        conn.execute(
            "INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
            params![b.display_name, b.host, b.nickname, plain, blob],
        )
        .map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
        Ok(conn.last_insert_rowid())
    }

    /// Replace an existing bookmark identified by `id`. Errors with
    /// [`StorageError::NotFound`] if no such row exists. Honours the
    /// password-column encryption setting and clears the legacy
    /// plaintext column so a row inserted by a pre-MVP build is
    /// upgraded on the next update.
    pub fn update(&self, b: &Bookmark) -> Result<(), StorageError> {
        let conn = self
            .conn
            .lock()
            .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
        let blob = match (&self.crypto, &b.password) {
            (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
            _ => None,
        };
        let plain: Option<&str> = if self.crypto.is_some() {
            None
        } else {
            b.password.as_deref()
        };
        let n = conn
            .execute(
                "UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
                params![b.display_name, b.host, b.nickname, plain, blob, b.id],
            )
            .map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
        if n == 0 {
            Err(StorageError::NotFound)
        } else {
            Ok(())
        }
    }

    /// Delete a bookmark by id. No-op if it doesn't exist.
    pub fn delete(&self, id: i64) -> Result<(), StorageError> {
        let conn = self
            .conn
            .lock()
            .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
        conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])
            .map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?;
        Ok(())
    }

    /// List all bookmarks ordered by id (insertion order). Decrypts
    /// `password_blob` if present; falls back to the legacy plain
    /// `password` column otherwise (legacy v0.4 rows).
    pub fn list(&self) -> Result<Vec<Bookmark>, StorageError> {
        let conn = self
            .conn
            .lock()
            .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
        let mut stmt = conn
            .prepare(
                "SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
            )
            .map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
        let rows = stmt
            .query_map([], |row| {
                let id: i64 = row.get(0)?;
                let display_name: String = row.get(1)?;
                let host: String = row.get(2)?;
                let nickname: String = row.get(3)?;
                let plain: Option<String> = row.get(4)?;
                let blob: Option<Vec<u8>> = row.get(5)?;
                Ok((id, display_name, host, nickname, plain, blob))
            })
            .map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
        let mut out = Vec::new();
        for r in rows {
            let (id, display_name, host, nickname, plain, blob) =
                r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?;
            let password = match (blob.as_ref(), self.crypto.as_ref()) {
                (Some(b), Some(c)) => Some(
                    String::from_utf8(c.decrypt(b)?)
                        .map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?,
                ),
                (Some(_), None) => {
                    // We have an encrypted blob but no key. Skip the
                    // password rather than panicking; the caller can
                    // re-enter it.
                    warn!(target: "chanora_storage", id, "encrypted bookmark password but crypto not wired; skipping");
                    None
                }
                (None, _) => plain,
            };
            out.push(Bookmark {
                id,
                display_name,
                host,
                nickname,
                password,
            });
        }
        Ok(out)
    }
}

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

    /// Tests always run against the file fallback. A real keyring
    /// hit would either prompt the developer or block on a missing
    /// D-Bus session inside CI. The override is set process-wide
    /// via a module-init guard so individual `#[test]` order does
    /// not matter.
    fn force_keyring_off() {
        std::env::set_var("CHANORA_DISABLE_KEYRING", "1");
    }

    #[test]
    fn round_trip() {
        force_keyring_off();
        let tmp = tempdir();
        let store = IdentityFileStore::new(&tmp).unwrap();
        assert!(store.load().unwrap().is_none());
        store.save("abc123").unwrap();
        assert_eq!(store.load().unwrap().as_deref(), Some("abc123"));
        store.clear().unwrap();
        assert!(store.load().unwrap().is_none());
    }

    #[test]
    fn empty_file_is_none() {
        force_keyring_off();
        let tmp = tempdir();
        let store = IdentityFileStore::new(&tmp).unwrap();
        fs::write(store.path(), "   \n").unwrap();
        assert!(store.load().unwrap().is_none());
    }

    #[cfg(unix)]
    #[test]
    fn unix_mode_is_0600() {
        force_keyring_off();
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempdir();
        let store = IdentityFileStore::new(&tmp).unwrap();
        store.save("xyz").unwrap();
        let mode = fs::metadata(store.path()).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
    }

    #[test]
    fn bookmark_round_trip() {
        force_keyring_off();
        let tmp = tempdir();
        let repo = BookmarkRepository::new(&tmp).unwrap();
        assert!(repo.list().unwrap().is_empty());
        let id = repo
            .add(&Bookmark {
                id: 0,
                display_name: "home".to_string(),
                host: "cn.teamspeak.app".to_string(),
                nickname: "u".to_string(),
                password: None,
            })
            .unwrap();
        assert!(id > 0);
        let rows = repo.list().unwrap();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].display_name, "home");
    }

    #[test]
    fn bookmark_update_and_delete() {
        force_keyring_off();
        let tmp = tempdir();
        let repo = BookmarkRepository::new(&tmp).unwrap();
        let id = repo
            .add(&Bookmark {
                id: 0,
                display_name: "a".to_string(),
                host: "h".to_string(),
                nickname: "n".to_string(),
                password: Some("pw".to_string()),
            })
            .unwrap();
        repo.update(&Bookmark {
            id,
            display_name: "b".to_string(),
            host: "h2".to_string(),
            nickname: "n2".to_string(),
            password: None,
        })
        .unwrap();
        let rows = repo.list().unwrap();
        assert_eq!(rows[0].display_name, "b");
        assert_eq!(rows[0].password, None);
        repo.delete(id).unwrap();
        assert!(repo.list().unwrap().is_empty());
    }

    #[test]
    fn bookmark_update_missing_is_notfound() {
        force_keyring_off();
        let tmp = tempdir();
        let repo = BookmarkRepository::new(&tmp).unwrap();
        let r = repo.update(&Bookmark {
            id: 999,
            display_name: "x".to_string(),
            host: "h".to_string(),
            nickname: "n".to_string(),
            password: None,
        });
        assert!(matches!(r, Err(StorageError::NotFound)));
    }

    #[test]
    fn encrypted_round_trip() {
        force_keyring_off();
        let tmp = tempdir();
        let store = IdentityFileStore::new(&tmp).unwrap();
        store.save("3456V/abcdef==").unwrap();
        // The bytes on disk are not the plaintext.
        let raw = fs::read(store.path()).unwrap();
        assert!(!raw.windows(6).any(|w| w == b"abcdef"));
        // load() returns the original.
        assert_eq!(store.load().unwrap().as_deref(), Some("3456V/abcdef=="));
    }

    #[test]
    fn legacy_plaintext_is_read_and_upgraded() {
        force_keyring_off();
        let tmp = tempdir();
        // Pre-Beta on-disk format: raw base64 with counter prefix.
        let path = tmp.join("identity.tskey");
        fs::write(&path, b"9999VabcdefghIJKLmnop=\n").unwrap();
        let store = IdentityFileStore::new(&tmp).unwrap();
        let v = store.load().unwrap();
        assert_eq!(v.as_deref(), Some("9999VabcdefghIJKLmnop="));
        // Save round-trips through encrypted format.
        store.save("9999VabcdefghIJKLmnop=").unwrap();
        let raw = fs::read(store.path()).unwrap();
        assert!(!raw.starts_with(b"9999"));
        assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop="));
    }

    #[test]
    fn encrypted_bookmark_password_round_trip() {
        force_keyring_off();
        let tmp = tempdir();
        let id_store = IdentityFileStore::new(&tmp).unwrap();
        let crypto = id_store.crypto().unwrap();
        let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
        assert!(repo.encrypts_passwords());
        let id = repo
            .add(&Bookmark {
                id: 0,
                display_name: "secret".to_string(),
                host: "h".to_string(),
                nickname: "n".to_string(),
                password: Some("hunter2".to_string()),
            })
            .unwrap();
        // The on-disk row must not contain the plain password.
        let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
        let row: (Option<String>, Option<Vec<u8>>) = conn
            .query_row(
                "SELECT password, password_blob FROM bookmarks WHERE id=?1",
                rusqlite::params![id],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert!(row.0.is_none(), "plaintext password column should be NULL");
        assert!(row.1.is_some(), "password_blob should be populated");
        let blob = row.1.unwrap();
        assert!(!blob.windows(7).any(|w| w == b"hunter2"));
        // Round-trip through the repository decrypts correctly.
        let rows = repo.list().unwrap();
        assert_eq!(rows[0].password.as_deref(), Some("hunter2"));
    }

    #[test]
    fn legacy_plaintext_bookmark_is_readable_and_upgraded() {
        force_keyring_off();
        let tmp = tempdir();
        // Pre-MVP write: opened without crypto, stores plaintext.
        let legacy = BookmarkRepository::new(&tmp).unwrap();
        let id = legacy
            .add(&Bookmark {
                id: 0,
                display_name: "legacy".to_string(),
                host: "h".to_string(),
                nickname: "n".to_string(),
                password: Some("old".to_string()),
            })
            .unwrap();
        drop(legacy);

        // MVP open: same dir, with crypto. Plain row still read.
        let id_store = IdentityFileStore::new(&tmp).unwrap();
        let crypto = id_store.crypto().unwrap();
        let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
        let rows = repo.list().unwrap();
        assert_eq!(rows[0].password.as_deref(), Some("old"));
        // Update lifts it into password_blob and nulls the plaintext.
        repo.update(&Bookmark {
            id,
            display_name: "legacy".to_string(),
            host: "h".to_string(),
            nickname: "n".to_string(),
            password: Some("old".to_string()),
        })
        .unwrap();
        let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
        let row: (Option<String>, Option<Vec<u8>>) = conn
            .query_row(
                "SELECT password, password_blob FROM bookmarks WHERE id=?1",
                rusqlite::params![id],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert!(row.0.is_none(), "plaintext should be cleared after upgrade");
        assert!(row.1.is_some(), "blob should be set after upgrade");
    }

    fn tempdir() -> PathBuf {
        let p = std::env::temp_dir()
            .join("chanora_storage_test")
            .join(format!("{}", std::process::id()))
            .join(format!(
                "{}",
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap()
                    .as_nanos()
            ));
        fs::create_dir_all(&p).unwrap();
        p
    }
}
