//! Pure auth helpers: password hashing, session-token generation, and
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
//! in their own modules. Keeping this surface pure also keeps the unit
//! tests fast (no Postgres needed).
//!
//! Hash algorithm is Argon2id with the OWASP default parameters
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
//! sessions table.

use argon2::password_hash::rand_core::OsRng as ArgonRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};

/// Returned when the supplied password hash string isn't a valid PHC
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
#[derive(Debug, thiserror::Error)]
#[error("invalid Argon2id PHC hash")]
pub struct InvalidPasswordHash;

/// Real Argon2id PHC string used by the timing-flat login path: on a
/// bad-email lookup the login routine still runs `verify_password`
/// against this hash so the wall-clock cost matches the good-email
/// branch. The plaintext that produced this hash is irrelevant —
/// `verify_password` will return false for any input the caller could
/// realistically present, and the surrounding code drops the result.
/// Reusing the existing admin-auth login pattern from `auth_api.rs`.
pub const TIMING_FLAT_DUMMY_HASH: &str =
    "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";

/// Hash a raw password into an Argon2id PHC-formatted string suitable
/// for `admin_users.password_hash`. The output already encodes the salt
/// and parameters; nothing else needs to be persisted alongside it.
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
    let salt = SaltString::generate(&mut ArgonRng);
    let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
    Ok(hash.to_string())
}

/// Constant-ish-time verify of a raw password against a PHC hash.
/// Returns `false` for any error (including malformed stored hash) —
/// callers should treat that case identically to "wrong password" so
/// nothing leaks about why auth failed.
#[must_use]
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
    let Ok(parsed) = PasswordHash::new(stored_hash) else {
        return false;
    };
    Argon2::default()
        .verify_password(raw.as_bytes(), &parsed)
        .is_ok()
}

/// Validate that a string parses as a PHC Argon2id hash — used at
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
/// rather than write garbage into the DB and discover it at first login.
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
    PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
    Ok(())
}

/// Newly minted session token: `raw` goes to the client (cookie + JSON
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
/// even if the DB leaks.
pub struct GeneratedToken {
    pub raw: String,
    pub hash: String,
}

/// Generate a fresh session token (32 random bytes base64-url-encoded).
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
/// instead of returning, but that's a non-recoverable system condition.
#[must_use]
pub fn generate_session_token() -> GeneratedToken {
    let mut bytes = [0u8; 32];
    OsRng.fill_bytes(&mut bytes);
    let raw = URL_SAFE_NO_PAD.encode(bytes);
    let hash = hash_token(&raw);
    GeneratedToken { raw, hash }
}

/// SHA-256(raw) as lower-case hex. Stable lookup key for
/// `admin_sessions.token_hash`.
#[must_use]
pub fn hash_token(raw: &str) -> String {
    let digest = Sha256::digest(raw.as_bytes());
    hex(&digest)
}

fn hex(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

// ----------------------------------------------------------------------------
// API key generation (Phase 3.5)
// ----------------------------------------------------------------------------

/// Wire-format prefix that marks a Bearer value as an API key (vs. a
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
/// generator and the verifier agree.
pub const API_KEY_WIRE_PREFIX: &str = "pic_";

/// Length of the indexed prefix portion (the first 8 chars of the
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;

/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
///
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
///   caller in the response body and never persisted.
/// * `prefix` is the indexed 8-char slice persisted to
///   `api_keys.prefix` for lookup.
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
///   covers the body after `pic_` (i.e., `raw[4..]`).
pub struct GeneratedApiKey {
    pub raw: String,
    pub prefix: String,
    pub hash: String,
}

/// Generate a fresh API key. 32 random bytes → unpadded base32, then
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
/// key; everything after `pic_` is what the verifier hashes.
///
/// # Errors
///
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
/// fails (which it shouldn't under normal conditions).
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
    let mut bytes = [0u8; 32];
    OsRng.fill_bytes(&mut bytes);
    let body = BASE32_NOPAD.encode(&bytes);
    debug_assert!(
        body.len() >= API_KEY_INDEX_PREFIX_LEN,
        "32 bytes base32 must exceed the 8-char prefix length"
    );
    let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
    let salt = SaltString::generate(&mut ArgonRng);
    let hash = Argon2::default()
        .hash_password(body.as_bytes(), &salt)?
        .to_string();
    let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
    Ok(GeneratedApiKey { raw, prefix, hash })
}

/// Verify a wire-format token body (the portion *after* `pic_`)
/// against a stored Argon2id hash. Convenience wrapper around
/// `verify_password` named to reflect its caller.
#[must_use]
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
    verify_password(stored_hash, presented_body)
}

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

    #[test]
    fn hash_verify_roundtrip() {
        let h = hash_password("correct horse battery staple").unwrap();
        assert!(verify_password(&h, "correct horse battery staple"));
        assert!(!verify_password(&h, "wrong"));
    }

    #[test]
    fn verify_returns_false_on_malformed_hash() {
        assert!(!verify_password("not-a-phc-string", "anything"));
    }

    #[test]
    fn validate_password_hash_accepts_phc() {
        let h = hash_password("pw").unwrap();
        assert!(validate_password_hash(&h).is_ok());
    }

    #[test]
    fn validate_password_hash_rejects_garbage() {
        assert!(validate_password_hash("not a hash").is_err());
    }

    #[test]
    fn generate_token_unique_and_hash_stable() {
        let a = generate_session_token();
        let b = generate_session_token();
        assert_ne!(a.raw, b.raw, "tokens must be unique");
        assert_ne!(a.hash, b.hash, "hashes must differ");
        assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
        assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
    }

    #[test]
    fn generate_api_key_round_trip() {
        let key = generate_api_key().expect("mint");
        assert!(
            key.raw.starts_with(API_KEY_WIRE_PREFIX),
            "raw must carry the pic_ prefix"
        );
        let body = key
            .raw
            .strip_prefix(API_KEY_WIRE_PREFIX)
            .expect("starts with prefix");
        assert_eq!(
            &body[..API_KEY_INDEX_PREFIX_LEN],
            key.prefix,
            "stored prefix matches the first 8 chars of the body"
        );
        assert!(
            verify_api_key(&key.hash, body),
            "Argon2 verify must accept the original body"
        );
        assert!(
            !verify_api_key(&key.hash, "wrong-body-entirely"),
            "Argon2 verify must reject anything else"
        );
    }

    #[test]
    fn generate_api_key_unique() {
        let a = generate_api_key().expect("mint a");
        let b = generate_api_key().expect("mint b");
        assert_ne!(a.raw, b.raw);
        assert_ne!(a.hash, b.hash);
        assert_ne!(
            a.prefix, b.prefix,
            "32 random bytes → prefix collision is negligible"
        );
    }
}
