//! Persistent paired-device access model.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use thiserror::Error;
use uuid::Uuid;

const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:";
const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0";
const SESSION_TOKEN_HASH_HEX_LEN: usize = 64;

/// Unique identifier for a paired device.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceId(Uuid);

impl DeviceId {
    /// Builds a device id from an existing UUID.
    #[must_use]
    pub const fn from_uuid(value: Uuid) -> Self {
        Self(value)
    }

    /// Returns the wrapped UUID.
    #[must_use]
    pub const fn as_uuid(self) -> Uuid {
        self.0
    }
}

impl std::fmt::Display for DeviceId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}

/// Device display name supplied by the newly paired device.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceName(String);

impl DeviceName {
    /// Validates and stores a device name.
    ///
    /// Names are required and limited to 40 Unicode scalar values.
    pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
        let value = value.into().trim().to_owned();
        let len = value.chars().count();
        if len == 0 {
            return Err(DeviceError::InvalidName(
                "device name is required".to_owned(),
            ));
        }
        if len > 40 {
            return Err(DeviceError::InvalidName(
                "device name must be 40 characters or fewer".to_owned(),
            ));
        }
        Ok(Self(value))
    }

    /// Returns the stored name.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Hash of a random 256-bit session token.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionTokenHash(String);

impl SessionTokenHash {
    /// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`.
    #[must_use]
    pub fn from_token_bytes(token_bytes: &[u8]) -> Self {
        let mut hasher = Sha256::new();
        hasher.update(SESSION_TOKEN_HASH_CONTEXT);
        hasher.update(token_bytes);
        let digest = hasher.finalize();
        Self(format!(
            "{SESSION_TOKEN_HASH_PREFIX}{}",
            hex::encode(digest)
        ))
    }

    /// Validates an already persisted hash string.
    pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
        let value = value.into();
        validate_hash_string(&value)?;
        Ok(Self(value))
    }

    /// Returns the persisted hash string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Constant-time verification of raw token bytes against this stored hash.
    #[must_use]
    pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool {
        let candidate = Self::from_token_bytes(token_bytes);
        self.constant_time_eq(&candidate)
    }

    /// Constant-time equality for two valid persisted token hashes.
    #[must_use]
    pub fn constant_time_eq(&self, other: &Self) -> bool {
        self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
    }
}

/// A paired device persisted in the app-data security store.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairedDevice {
    /// Persistent device id.
    pub device_id: DeviceId,
    /// User-facing device name.
    pub name: DeviceName,
    /// Pairing timestamp as epoch milliseconds.
    pub paired_at_ms: u64,
    /// Last successful authenticated access timestamp as epoch milliseconds.
    pub last_seen_at_ms: u64,
    /// Hash of the bearer session token.
    pub session_token_hash: SessionTokenHash,
}

/// Successful session authentication result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticatedDevice {
    /// Authenticated device id.
    pub device_id: DeviceId,
    /// Matched token hash.
    pub token_hash: SessionTokenHash,
}

/// Device-domain validation errors.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DeviceError {
    /// Invalid device name.
    #[error("invalid device name: {0}")]
    InvalidName(String),
    /// Invalid persisted token hash.
    #[error("invalid session token hash")]
    InvalidSessionTokenHash,
}

fn validate_hash_string(value: &str) -> Result<(), DeviceError> {
    let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else {
        return Err(DeviceError::InvalidSessionTokenHash);
    };
    if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err(DeviceError::InvalidSessionTokenHash);
    }
    Ok(())
}

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

    #[test]
    fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() {
        let token = [7_u8; 32];
        let hash = SessionTokenHash::from_token_bytes(&token);

        assert!(hash.as_str().starts_with("sha256:"));
        assert_eq!(hash.as_str().len(), "sha256:".len() + 64);
        assert!(hash.verify_token_bytes(&token));
        assert!(!hash.verify_token_bytes(&[8_u8; 32]));
        assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap()));
    }

    #[test]
    fn device_name_is_required_and_limited() {
        assert!(DeviceName::new("phone").is_ok());
        assert!(DeviceName::new("   ").is_err());
        assert!(DeviceName::new("a".repeat(41)).is_err());
    }
}
