//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.

use hmac::{Hmac, Mac};
use sha1::Sha1 as HmacSha1;
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};

/// Steam Mobile Authenticator's 5-character output alphabet.
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
///
/// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`]
/// for the standard implementation.
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
    pub config: TotpConfig,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub issuer: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

impl TotpConfig {
    /// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the
    /// canonical `Zeroizing<Vec<u8>>` form used in [`Self::secret`].
    pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>> {
        Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig {
    /// Raw bytes of the TOTP secret (decoded from base32 when imported).
    pub secret: Zeroizing<Vec<u8>>,
    pub algorithm: TotpAlgorithm,
    pub digits: u8,
    pub period_seconds: u32,
    pub kind: TotpKind,
}

impl Default for TotpConfig {
    fn default() -> Self {
        Self {
            secret: Zeroizing::new(Vec::new()),
            algorithm: TotpAlgorithm::Sha1,
            digits: 6,
            period_seconds: 30,
            kind: TotpKind::Totp,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
    #[default]
    Sha1,
    Sha256,
    Sha512,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
    #[default]
    Totp,
    Hotp { counter: u64 },
    Steam,
}

/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
    let counter = match config.kind {
        TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
        TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
        TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
    };
    let counter_bytes = counter.to_be_bytes();
    let hmac_out: Vec<u8> = match config.algorithm {
        TotpAlgorithm::Sha1 => {
            let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
        TotpAlgorithm::Sha256 => {
            let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
        TotpAlgorithm::Sha512 => {
            let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
    };
    let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
    let truncated = ((hmac_out[offset]     as u32 & 0x7F) << 24)
                  | ((hmac_out[offset + 1] as u32)        << 16)
                  | ((hmac_out[offset + 2] as u32)        << 8)
                  |  (hmac_out[offset + 3] as u32);
    if matches!(config.kind, TotpKind::Steam) {
        let mut t = truncated;
        let mut out = String::with_capacity(5);
        for _ in 0..5 {
            out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
            t /= 26;
        }
        return Ok(out);
    }

    let modulus = 10u32.pow(config.digits as u32);
    Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}

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

    #[test]
    fn rfc6238_sha1_vector_59() {
        let cfg = TotpConfig {
            secret: Zeroizing::new(b"12345678901234567890".to_vec()),
            algorithm: TotpAlgorithm::Sha1,
            digits: 8,
            period_seconds: 30,
            kind: TotpKind::Totp,
        };
        assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
    }
}

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

    #[test]
    fn totp_default_is_sha1_6_30_totp() {
        let cfg = TotpConfig::default();
        assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
        assert_eq!(cfg.digits, 6);
        assert_eq!(cfg.period_seconds, 30);
        assert_eq!(cfg.kind, TotpKind::Totp);
    }

    #[test]
    fn totp_round_trip() {
        let core = TotpCore {
            config: TotpConfig {
                secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
                algorithm: TotpAlgorithm::Sha256,
                digits: 8,
                period_seconds: 60,
                kind: TotpKind::Totp,
            },
            issuer: Some("github".into()),
            label: Some("alice@github".into()),
        };
        let json = serde_json::to_string(&core).unwrap();
        let parsed: TotpCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.config.digits, 8);
        assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
        assert_eq!(parsed.issuer.as_deref(), Some("github"));
    }

    #[test]
    fn hotp_kind_roundtrips_through_json() {
        let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
        let json = serde_json::to_string(&cfg).unwrap();
        let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
        match parsed.kind {
            TotpKind::Hotp { counter } => assert_eq!(counter, 42),
            other => panic!("expected Hotp, got {:?}", other),
        }
        // Note: compute_totp_code will reject this — HOTP not supported
    }

    #[test]
    fn hotp_returns_not_supported_error() {
        let cfg = TotpConfig {
            secret: Zeroizing::new(b"12345678901234567890".to_vec()),
            kind: TotpKind::Hotp { counter: 0 },
            ..TotpConfig::default()
        };
        let result = compute_totp_code(&cfg, 0);
        assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
    }

    #[test]
    fn steam_kind_serializes() {
        let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
        let json = serde_json::to_string(&cfg).unwrap();
        assert!(json.contains("steam"));
    }
}

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

    /// Reference implementation of the Steam 5-character output, per the
    /// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
    /// Used by tests below to cross-check the production impl without
    /// requiring a third-party vector. The algorithm is short enough to
    /// be reproduced here in isolation.
    fn steam_output_reference(truncated: u32) -> String {
        const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
        let mut t = truncated;
        let mut out = String::with_capacity(5);
        for _ in 0..5 {
            out.push(ALPHA[(t % 26) as usize] as char);
            t /= 26;
        }
        out
    }

    /// Compute the dynamic-truncated u32 the same way `compute_totp_code`
    /// does internally — used to drive the reference impl.
    fn truncated_for(secret: &[u8], counter: u64) -> u32 {
        use hmac::{Hmac, Mac};
        use sha1::Sha1;
        let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
        mac.update(&counter.to_be_bytes());
        let bytes = mac.finalize().into_bytes();
        let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
        ((bytes[offset]     as u32 & 0x7F) << 24)
        | ((bytes[offset + 1] as u32)        << 16)
        | ((bytes[offset + 2] as u32)        << 8)
        |  (bytes[offset + 3] as u32)
    }

    #[test]
    fn steam_output_matches_reference_impl() {
        let secret = b"12345678901234567890".to_vec();
        let cfg = TotpConfig {
            secret: Zeroizing::new(secret.clone()),
            algorithm: TotpAlgorithm::Sha1,
            digits: 5,
            period_seconds: 30,
            kind: TotpKind::Steam,
        };
        let code_at_30  = compute_totp_code(&cfg, 30).unwrap();
        let code_at_60  = compute_totp_code(&cfg, 60).unwrap();
        let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
        assert_eq!(code_at_30,  steam_output_reference(truncated_for(&secret, 1)));
        assert_eq!(code_at_60,  steam_output_reference(truncated_for(&secret, 2)));
        assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
    }

    #[test]
    fn steam_output_is_exactly_5_chars_regardless_of_digits() {
        let secret = b"hello world!".to_vec();
        for digits in [4u8, 5, 6, 7, 8] {
            let cfg = TotpConfig {
                secret: Zeroizing::new(secret.clone()),
                algorithm: TotpAlgorithm::Sha1,
                digits,
                period_seconds: 30,
                kind: TotpKind::Steam,
            };
            let code = compute_totp_code(&cfg, 0).unwrap();
            assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
        }
    }

    #[test]
    fn steam_output_uses_only_alphabet_chars() {
        const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
        let secret = b"hello world!".to_vec();
        let cfg = TotpConfig {
            secret: Zeroizing::new(secret),
            algorithm: TotpAlgorithm::Sha1,
            digits: 5,
            period_seconds: 30,
            kind: TotpKind::Steam,
        };
        for t in 0u64..1000 {
            let code = compute_totp_code(&cfg, t * 30).unwrap();
            for ch in code.chars() {
                assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
            }
        }
    }

    #[test]
    fn steam_alphabet_excludes_ambiguous_glyphs() {
        // Authoritative Steam Guard alphabet from Valve's Steam Mobile
        // Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
        // (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
        const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
        for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
            assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
        }
    }
}
