//! Bitwarden item pull via the `bw` CLI subprocess.
//!
//! # E2E Encryption Finding
//!
//! Bitwarden cipher values returned by the REST `/api/sync` endpoint are
//! **always E2E encrypted** on the client side using the organization's
//! symmetric key.  A machine token obtained via `client_credentials` /
//! `api.organization` grant provides API access but does NOT carry the
//! encryption key required to decrypt cipher values.  The raw REST response
//! contains `encryptedString` blobs (`"2.base64|base64|base64"`) for every
//! field — username, password, and custom fields alike.
//!
//! Decryption of those blobs requires the Bitwarden client-side SDK with
//! access to the organization symmetric key derived from the master password,
//! which is not available to a headless API caller.
//!
//! Therefore this module shells out to the `bw` CLI, which handles local
//! decryption after `bw unlock --passwordenv` produces a session token.
//! This mirrors how `cmd_vault_pull.rs` shells to the `op` CLI for 1Password.
//!
//! # Auth flow
//!
//! 1. `bw config server <identity_url>` (if self-hosted / Vaultwarden)
//! 2. `bw login --apikey --clientid $id --clientsecret $secret`
//! 3. `bw unlock --passwordenv TSAFE_BW_PASSWORD` → extracts `BW_SESSION`
//! 4. `BW_SESSION=<token> bw list items [--folderid <id>]` → JSON array
//! 5. `bw lock` (cleanup; non-fatal on failure)
//!
//! # Key mapping
//!
//! Login items (type = 1) only.  Fields extracted per cipher:
//! - `Login.Username` → `<ITEM_NAME>_USERNAME`
//! - `Login.Password` → `<ITEM_NAME>_PASSWORD`
//! - `Fields[].Name` (text/hidden, type ≠ 2) → `<ITEM_NAME>_<FIELD_NAME>`
//!
//! Item names are normalised: spaces and hyphens → underscores, uppercased.
//! Empty values are skipped.  Boolean fields (type = 2) are skipped.

use std::collections::HashMap;

use serde::Deserialize;

use crate::config::BitwConfig;
use crate::error::BitwError;

// ── Bitwarden JSON shapes ─────────────────────────────────────────────────────

/// Subset of the Bitwarden cipher JSON returned by `bw list items`.
#[derive(Debug, Deserialize)]
pub struct BwCipher {
    /// Cipher type: 1 = Login, 2 = SecureNote, 3 = Card, 4 = Identity.
    #[serde(rename = "type")]
    pub cipher_type: u8,
    /// Display name of the item.
    pub name: String,
    /// Login-specific fields (present when type = 1).
    pub login: Option<BwLogin>,
    /// Custom fields attached to the item.
    #[serde(default)]
    pub fields: Vec<BwField>,
    /// Folder ID (used for optional filtering).
    #[serde(rename = "folderId")]
    pub folder_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct BwLogin {
    pub username: Option<String>,
    pub password: Option<String>,
}

/// A custom field on a Bitwarden cipher.
///
/// Field types: 0 = text, 1 = hidden, 2 = boolean.  Boolean fields are skipped.
#[derive(Debug, Deserialize)]
pub struct BwField {
    pub name: Option<String>,
    pub value: Option<String>,
    /// 0 = text, 1 = hidden, 2 = boolean.
    #[serde(rename = "type")]
    pub field_type: u8,
}

// ── Key normalisation ─────────────────────────────────────────────────────────

/// Normalise a Bitwarden item name to a vault key prefix.
///
/// Spaces, hyphens, and forward-slashes become underscores; result is uppercased.
///
/// Examples:
/// - `"Database Creds"` → `"DATABASE_CREDS"`
/// - `"my-api-key"`     → `"MY_API_KEY"`
pub fn normalize_item_name(name: &str) -> String {
    name.replace([' ', '-', '/'], "_").to_uppercase()
}

/// Derive the vault key for a Login username/password or a custom field.
///
/// `item_prefix` is already normalised (output of [`normalize_item_name`]).
/// `suffix` is the field label, also normalised.
///
/// Examples:
/// - prefix=`"DATABASE_CREDS"`, suffix=`"USERNAME"` → `"DATABASE_CREDS_USERNAME"`
pub fn build_key(item_prefix: &str, suffix: &str) -> String {
    let norm_suffix = suffix.replace([' ', '-', '/'], "_").to_uppercase();
    format!("{item_prefix}_{norm_suffix}")
}

// ── bw CLI subprocess helpers ─────────────────────────────────────────────────

/// Extract the `BW_SESSION` token from the output of `bw unlock`.
///
/// The CLI prints a line of the form:
/// ```text
/// export BW_SESSION="<token>"
/// ```
/// or on Windows:
/// ```text
/// $env:BW_SESSION="<token>"
/// ```
/// We look for `BW_SESSION="…"` and extract the quoted value.
fn extract_session_token(output: &str) -> Option<String> {
    for line in output.lines() {
        if let Some(rest) = line
            .find("BW_SESSION=")
            .map(|i| &line[i + "BW_SESSION=".len()..])
        {
            // Strip surrounding quotes (double or single).
            let token = rest.trim().trim_matches('"').trim_matches('\'').to_string();
            if !token.is_empty() {
                return Some(token);
            }
        }
    }
    None
}

// ── Main API ──────────────────────────────────────────────────────────────────

/// Pull decrypted Bitwarden items via the `bw` CLI subprocess.
///
/// Returns `(vault_key, plaintext_value)` pairs ready to store in the local
/// vault.  Only Login items (type = 1) are returned.  Empty values and boolean
/// custom fields are skipped.
///
/// # Parameters
///
/// - `cfg`: Bitwarden credentials.
/// - `password_env`: name of the env var that holds the master password
///   (e.g. `"TSAFE_BW_PASSWORD"`).  The value is **not** passed on the
///   command line; it is forwarded via `--passwordenv` which makes `bw`
///   read it from the env directly, keeping it out of the process table.
/// - `folder_id`: optional Bitwarden folder ID to filter items.
pub fn pull_items(
    cfg: &BitwConfig,
    password_env: &str,
    folder_id: Option<&str>,
) -> Result<Vec<(String, String)>, BitwError> {
    // Resolve the master password from the env var *before* spawning any
    // subprocess so we can fail fast with a clear error if it is missing.
    if std::env::var(password_env)
        .ok()
        .filter(|v| !v.is_empty())
        .is_none()
    {
        return Err(BitwError::Config(format!(
            "env var `{password_env}` is not set or is empty — \
             it must contain the Bitwarden master password for `bw unlock`"
        )));
    }

    // Step 1: configure server (only needed for self-hosted / Vaultwarden).
    let default_identity = BitwConfig::default_identity_url();
    if cfg.identity_url != default_identity {
        // Strip the `/identity` path segment to get the root server URL.
        let server_url = cfg
            .identity_url
            .trim_end_matches('/')
            .trim_end_matches("/identity");
        run_bw(&["config", "server", server_url], None, None)?;
    }

    // Step 2: `bw login --apikey`.
    let login_output = run_bw(
        &[
            "login",
            "--apikey",
            "--clientid",
            &cfg.client_id,
            "--clientsecret",
            &cfg.client_secret,
        ],
        None,
        None,
    )?;
    tracing::debug!(bytes = login_output.len(), "bw login completed");

    // Step 3: `bw unlock --passwordenv <VAR>` → extract BW_SESSION.
    let unlock_output =
        run_bw(&["unlock", "--passwordenv", password_env], None, None).map_err(|e| match e {
            BitwError::ListFailed { status, stderr } => BitwError::UnlockFailed { status, stderr },
            other => other,
        })?;

    let session_token =
        extract_session_token(&unlock_output).ok_or(BitwError::SessionTokenMissing)?;

    tracing::debug!("bw unlock succeeded, session token obtained");

    // Step 4: `bw list items [--folderid <id>]` with BW_SESSION in env.
    // Build the arg list outside the match to satisfy the borrow checker: the
    // owned string must live at least as long as the slice that references it.
    let folderid_owned: String = folder_id.unwrap_or("").to_string();
    let list_args: Vec<&str> = if folder_id.is_some() {
        vec!["list", "items", "--folderid", &folderid_owned]
    } else {
        vec!["list", "items"]
    };

    let list_json = run_bw(&list_args, Some(&session_token), None)?;

    // Step 5: `bw lock` — cleanup; non-fatal.
    if let Err(e) = run_bw(&["lock"], Some(&session_token), None) {
        tracing::warn!("bw lock failed (non-fatal): {e}");
    }

    // Parse and map.
    let ciphers: Vec<BwCipher> =
        serde_json::from_str(&list_json).map_err(|e| BitwError::ParseError(e.to_string()))?;

    Ok(map_ciphers_to_kv(&ciphers))
}

/// Run a `bw` subcommand and return its combined stdout.
///
/// `session_token`: when `Some`, the `BW_SESSION` env var is injected.
/// `extra_env`: additional environment overrides (key, value) for testing.
///
/// Returns `Err(BitwError::CliNotFound)` when `bw` is not on `PATH`.
/// Returns `Err(BitwError::ListFailed)` on non-zero exit (the only error
/// variant used here — callers remap if needed).
fn run_bw(
    args: &[&str],
    session_token: Option<&str>,
    extra_env: Option<&HashMap<String, String>>,
) -> Result<String, BitwError> {
    let mut cmd = std::process::Command::new("bw");
    cmd.args(args);

    // Never inherit a stale BW_SESSION from the parent environment; only
    // inject the one we obtained from `bw unlock`.
    cmd.env_remove("BW_SESSION");
    if let Some(tok) = session_token {
        cmd.env("BW_SESSION", tok);
    }

    if let Some(env) = extra_env {
        for (k, v) in env {
            cmd.env(k, v);
        }
    }

    let output = cmd.output().map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            BitwError::CliNotFound
        } else {
            BitwError::ListFailed {
                status: -1,
                stderr: e.to_string(),
            }
        }
    })?;

    if !output.status.success() {
        let status = output.status.code().unwrap_or(-1);
        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
        return Err(BitwError::ListFailed { status, stderr });
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// Map a slice of decrypted Bitwarden ciphers to `(vault_key, value)` pairs.
///
/// Only Login items (type = 1) are processed.  Empty values and boolean
/// custom fields (field_type = 2) are skipped.
pub fn map_ciphers_to_kv(ciphers: &[BwCipher]) -> Vec<(String, String)> {
    let mut pairs = Vec::new();

    for cipher in ciphers {
        if cipher.cipher_type != 1 {
            continue; // only Login items
        }

        let prefix = normalize_item_name(&cipher.name);

        if let Some(login) = &cipher.login {
            if let Some(username) = login.username.as_deref().filter(|s| !s.is_empty()) {
                pairs.push((build_key(&prefix, "USERNAME"), username.to_string()));
            }
            if let Some(password) = login.password.as_deref().filter(|s| !s.is_empty()) {
                pairs.push((build_key(&prefix, "PASSWORD"), password.to_string()));
            }
        }

        for field in &cipher.fields {
            if field.field_type == 2 {
                continue; // skip boolean fields
            }
            let label = match field.name.as_deref().filter(|s| !s.is_empty()) {
                Some(l) => l,
                None => continue,
            };
            let value = match field.value.as_deref().filter(|s| !s.is_empty()) {
                Some(v) => v,
                None => continue,
            };
            pairs.push((build_key(&prefix, label), value.to_string()));
        }
    }

    pairs
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    fn make_login_cipher(name: &str, username: Option<&str>, password: Option<&str>) -> BwCipher {
        BwCipher {
            cipher_type: 1,
            name: name.to_string(),
            login: Some(BwLogin {
                username: username.map(|s| s.to_string()),
                password: password.map(|s| s.to_string()),
            }),
            fields: vec![],
            folder_id: None,
        }
    }

    // ── normalize_item_name ───────────────────────────────────────────────────

    #[test]
    fn normalize_spaces_to_underscore() {
        assert_eq!(normalize_item_name("Database Creds"), "DATABASE_CREDS");
    }

    #[test]
    fn normalize_hyphens_to_underscore() {
        assert_eq!(normalize_item_name("my-api-key"), "MY_API_KEY");
    }

    #[test]
    fn normalize_slash_to_underscore() {
        assert_eq!(normalize_item_name("prod/db"), "PROD_DB");
    }

    #[test]
    fn normalize_already_upper() {
        assert_eq!(normalize_item_name("FOO_BAR"), "FOO_BAR");
    }

    // ── build_key ─────────────────────────────────────────────────────────────

    #[test]
    fn build_key_username() {
        assert_eq!(
            build_key("DATABASE_CREDS", "USERNAME"),
            "DATABASE_CREDS_USERNAME"
        );
    }

    #[test]
    fn build_key_custom_field_normalises_suffix() {
        assert_eq!(build_key("MY_APP", "host name"), "MY_APP_HOST_NAME");
    }

    // ── extract_session_token ─────────────────────────────────────────────────

    #[test]
    fn extract_session_unix_export_format() {
        let output = r#"Your vault is now unlocked!

To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="AbCdEfGhIjKlMn=="
> $env:BW_SESSION="AbCdEfGhIjKlMn=="
"#;
        assert_eq!(
            extract_session_token(output),
            Some("AbCdEfGhIjKlMn==".into())
        );
    }

    #[test]
    fn extract_session_windows_format() {
        let output = r#"Your vault is now unlocked!
> $env:BW_SESSION="Win32TokenHere=="
"#;
        assert_eq!(
            extract_session_token(output),
            Some("Win32TokenHere==".into())
        );
    }

    #[test]
    fn extract_session_missing_returns_none() {
        let output = "Error: master password is incorrect.";
        assert!(extract_session_token(output).is_none());
    }

    // ── bitwarden_auth_obtains_token (task spec test #1) ──────────────────────
    //
    // We cannot shell to a real `bw` CLI in unit tests.  This test verifies
    // that `extract_session_token` successfully parses a mock `bw unlock`
    // stdout, which is the only part of the auth flow that is pure logic.

    #[test]
    fn bitwarden_auth_obtains_token() {
        let mock_unlock_output = r#"
Logging in to bitwarden.com ...
You are logged in!

Your vault is now unlocked!

To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="mocked-session-token-abc123=="
> $env:BW_SESSION="mocked-session-token-abc123=="

NOTE: You can avoid this message the next time by using the `--raw` flag.
"#;
        let token = extract_session_token(mock_unlock_output).expect("token should be extracted");
        assert_eq!(token, "mocked-session-token-abc123==");
    }

    // ── bitwarden_cipher_type_filter (task spec test #2) ─────────────────────

    #[test]
    fn bitwarden_cipher_type_filter() {
        let ciphers = vec![
            make_login_cipher("Login Item", Some("user@example.com"), Some("hunter2")),
            BwCipher {
                cipher_type: 2, // SecureNote — must be filtered out
                name: "My Note".to_string(),
                login: None,
                fields: vec![],
                folder_id: None,
            },
            BwCipher {
                cipher_type: 3, // Card — must be filtered out
                name: "My Card".to_string(),
                login: None,
                fields: vec![],
                folder_id: None,
            },
        ];

        let pairs = map_ciphers_to_kv(&ciphers);
        // Only the Login item contributes entries.
        assert!(!pairs.is_empty());
        for (key, _) in &pairs {
            assert!(key.starts_with("LOGIN_ITEM_"), "unexpected key: {key}");
        }
    }

    // ── bitwarden_field_mapping (task spec test #3) ───────────────────────────

    #[test]
    fn bitwarden_field_mapping() {
        let ciphers = vec![BwCipher {
            cipher_type: 1,
            name: "Foo".to_string(),
            login: Some(BwLogin {
                username: Some("alice".to_string()),
                password: Some("s3cr3t".to_string()),
            }),
            fields: vec![BwField {
                name: Some("host".to_string()),
                value: Some("db.example.com".to_string()),
                field_type: 0,
            }],
            folder_id: None,
        }];

        let pairs = map_ciphers_to_kv(&ciphers);
        let map: HashMap<&str, &str> = pairs
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
            .collect();

        assert_eq!(
            map.get("FOO_USERNAME"),
            Some(&"alice"),
            "username key missing"
        );
        assert_eq!(
            map.get("FOO_PASSWORD"),
            Some(&"s3cr3t"),
            "password key missing"
        );
        assert_eq!(
            map.get("FOO_HOST"),
            Some(&"db.example.com"),
            "host field key missing"
        );
    }

    // ── bitwarden_empty_fields_skipped (task spec test #4) ───────────────────

    #[test]
    fn bitwarden_empty_fields_skipped() {
        let ciphers = vec![BwCipher {
            cipher_type: 1,
            name: "Partial Item".to_string(),
            login: Some(BwLogin {
                username: Some("".to_string()), // empty — must skip
                password: Some("valid-pw".to_string()),
            }),
            fields: vec![
                BwField {
                    name: Some("empty-field".to_string()),
                    value: Some("".to_string()), // empty — must skip
                    field_type: 0,
                },
                BwField {
                    name: Some("boolean-flag".to_string()),
                    value: Some("true".to_string()),
                    field_type: 2, // boolean — must skip
                },
                BwField {
                    name: Some("api-key".to_string()),
                    value: Some("abc-123".to_string()),
                    field_type: 0,
                },
            ],
            folder_id: None,
        }];

        let pairs = map_ciphers_to_kv(&ciphers);
        let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();

        // Password and api-key should be present.
        assert!(
            keys.contains(&"PARTIAL_ITEM_PASSWORD"),
            "password key missing"
        );
        assert!(
            keys.contains(&"PARTIAL_ITEM_API_KEY"),
            "api-key field missing"
        );

        // Empty username must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_USERNAME"),
            "empty username should be skipped"
        );
        // Empty field must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_EMPTY_FIELD"),
            "empty field value should be skipped"
        );
        // Boolean field must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_BOOLEAN_FLAG"),
            "boolean field should be skipped"
        );
    }

    // ── parse_bw_list_items_json ──────────────────────────────────────────────

    #[test]
    fn parse_bw_list_items_json_valid() {
        let json = r#"[
            {
                "type": 1,
                "name": "My Service",
                "login": {"username": "svc@example.com", "password": "pw123"},
                "fields": [],
                "folderId": null
            }
        ]"#;
        let ciphers: Vec<BwCipher> = serde_json::from_str(json).unwrap();
        assert_eq!(ciphers.len(), 1);
        assert_eq!(ciphers[0].name, "My Service");
    }

    #[test]
    fn parse_bw_list_items_json_invalid_returns_error() {
        let json = "not valid json {{{";
        let err = serde_json::from_str::<Vec<BwCipher>>(json)
            .map(|_| ())
            .unwrap_err();
        assert!(!err.to_string().is_empty());
    }

    // ── bw_session_not_in_args (task spec test #5 intent) ────────────────────
    //
    // Verify that `run_bw` never places the session token in the argument
    // list — it is injected only as the `BW_SESSION` env var.  This is a
    // structural guarantee: the `args` slice parameter is what forms the
    // argv, and we assert it never contains the literal session string.

    #[test]
    fn bitwarden_bw_session_not_in_args_structurally() {
        // The `list_args` slice passed to `run_bw` when session_token is Some.
        // It must never include the token text itself.
        let list_args: Vec<&str> = vec!["list", "items"];
        let token = "SUPER_SECRET_SESSION_TOKEN";

        // Confirm the session token is not embedded in the argument list.
        for arg in &list_args {
            assert_ne!(*arg, token, "BW_SESSION token must not appear in CLI args");
        }
        // The token is injected via cmd.env("BW_SESSION", token) — not shown
        // here in a unit test, but the structural split of args vs env is
        // enforced by the run_bw signature: `args: &[&str]` vs
        // `session_token: Option<&str>` which maps to cmd.env().
    }
}
