//! Auth status projection — poll surface for CLI/REST/RPC/MCP/WASM.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::error::AuthErrorKind;
use crate::handles::{AuthLeasePhase, AuthLeaseSnapshot};
use crate::provider::Provider;

/// Public auth status phase shared by REST, RPC, CLI, and generated SDK wire
/// payloads.
///
/// This is the compatibility projection of typed auth lease truth. Surfaces
/// should map to this enum first and only then emit [`Self::as_public_str`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AuthStatusPhase {
    Valid,
    Expiring,
    Expired,
    ReauthRequired,
    RefreshFailed,
    /// The lease was explicitly released (credential lifecycle torn down).
    Released,
    /// No lease phase is tracked at all for this binding.
    Absent,
    /// A lease may exist but holds no live credential material.
    MissingCredential,
}

impl AuthStatusPhase {
    pub const ALL: &'static [Self] = &[
        Self::Valid,
        Self::Expiring,
        Self::Expired,
        Self::ReauthRequired,
        Self::RefreshFailed,
        Self::Released,
        Self::Absent,
        Self::MissingCredential,
    ];

    pub const fn as_public_str(self) -> &'static str {
        match self {
            Self::Valid => "valid",
            Self::Expiring => "expiring",
            Self::Expired => "expired",
            Self::ReauthRequired => "reauth_required",
            Self::RefreshFailed => "refresh_failed",
            Self::Released => "released",
            Self::Absent => "absent",
            Self::MissingCredential => "missing_credential",
        }
    }

    /// True when there is no usable live lease for this binding.
    ///
    /// Replaces the old `== AuthStatusPhase::Unknown` check after the catch-all
    /// `Unknown` variant was split into the three distinct no-live-lease states
    /// (`Released`, `Absent`, `MissingCredential`).
    pub const fn is_no_live_lease(self) -> bool {
        matches!(
            self,
            Self::Released | Self::Absent | Self::MissingCredential
        )
    }

    pub const fn from_lease_phase(phase: Option<AuthLeasePhase>) -> Self {
        match phase {
            Some(AuthLeasePhase::Valid) => Self::Valid,
            Some(AuthLeasePhase::Expiring | AuthLeasePhase::Refreshing) => Self::Expiring,
            Some(AuthLeasePhase::Expired) => Self::Expired,
            Some(AuthLeasePhase::ReauthRequired) => Self::ReauthRequired,
            Some(AuthLeasePhase::Released) => Self::Released,
            None => Self::Absent,
        }
    }

    pub fn from_lease_snapshot(_now: DateTime<Utc>, snapshot: &AuthLeaseSnapshot) -> Self {
        if !snapshot.credential_present {
            return Self::MissingCredential;
        }
        match snapshot.phase {
            Some(AuthLeasePhase::Valid) => Self::Valid,
            Some(AuthLeasePhase::Expiring | AuthLeasePhase::Refreshing) => Self::Expiring,
            Some(AuthLeasePhase::Expired) => Self::Expired,
            Some(AuthLeasePhase::ReauthRequired) => Self::ReauthRequired,
            Some(AuthLeasePhase::Released) => Self::Released,
            None => Self::Absent,
        }
    }
}

/// Status snapshot of a resolved (or unresolved) auth profile.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AuthStatus {
    pub profile_id: String,
    pub provider: Provider,
    pub backend_kind: String,
    pub auth_method: String,
    pub source_label: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
    pub expires_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub account_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub organization_id: Option<String>,
    #[serde(default)]
    pub needs_reauth: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_error: Option<AuthErrorSummary>,
}

/// Compact projection of the last auth error. Used in AuthStatus poll surface.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AuthErrorSummary {
    pub kind: AuthErrorKind,
    pub message: String,
    #[cfg_attr(feature = "schema", schemars(with = "String"))]
    pub occurred_at: DateTime<Utc>,
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;
    use chrono::{Duration, TimeZone};

    #[test]
    fn auth_status_roundtrip() {
        let status = AuthStatus {
            profile_id: "openai_api_key".into(),
            provider: Provider::OpenAI,
            backend_kind: crate::provider_matrix::OpenAiBackendKind::OpenAiApi
                .as_str()
                .into(),
            auth_method: crate::provider_matrix::OpenAiAuthMethod::ApiKey
                .as_str()
                .into(),
            source_label: "env:OPENAI_API_KEY".into(),
            expires_at: None,
            account_id: None,
            workspace_id: None,
            organization_id: Some("org-abc".into()),
            needs_reauth: false,
            last_error: None,
        };
        let s = serde_json::to_string(&status).unwrap();
        let back: AuthStatus = serde_json::from_str(&s).unwrap();
        assert_eq!(back, status);
    }

    #[test]
    fn auth_error_summary_roundtrip() {
        let summary = AuthErrorSummary {
            kind: AuthErrorKind::MissingSecret,
            message: "env var not set".into(),
            occurred_at: Utc::now(),
        };
        let s = serde_json::to_string(&summary).unwrap();
        let back: AuthErrorSummary = serde_json::from_str(&s).unwrap();
        assert_eq!(back.kind, summary.kind);
        assert_eq!(back.message, summary.message);
    }

    #[test]
    fn auth_status_phase_maps_machine_lease_phase_to_public_values() {
        let now = Utc.with_ymd_and_hms(2026, 4, 28, 12, 0, 0).unwrap();

        let valid = AuthLeaseSnapshot {
            phase: Some(AuthLeasePhase::Valid),
            expires_at: Some((now + Duration::minutes(10)).timestamp() as u64),
            credential_present: true,
            generation: 1,
            credential_published_at_millis: None,
        };
        assert_eq!(
            AuthStatusPhase::from_lease_snapshot(now, &valid).as_public_str(),
            "valid"
        );

        let expired = AuthLeaseSnapshot {
            phase: Some(AuthLeasePhase::Valid),
            expires_at: Some((now - Duration::seconds(1)).timestamp() as u64),
            credential_present: true,
            generation: 1,
            credential_published_at_millis: None,
        };
        assert_eq!(
            AuthStatusPhase::from_lease_snapshot(now, &expired).as_public_str(),
            "valid"
        );

        let machine_expired = AuthLeaseSnapshot {
            phase: Some(AuthLeasePhase::Expired),
            expires_at: Some((now - Duration::seconds(1)).timestamp() as u64),
            credential_present: true,
            generation: 1,
            credential_published_at_millis: None,
        };
        assert_eq!(
            AuthStatusPhase::from_lease_snapshot(now, &machine_expired).as_public_str(),
            "expired"
        );

        let stale_token_without_machine = AuthLeaseSnapshot {
            phase: None,
            expires_at: Some((now + Duration::minutes(10)).timestamp() as u64),
            credential_present: false,
            generation: 1,
            credential_published_at_millis: None,
        };
        assert_eq!(
            AuthStatusPhase::from_lease_snapshot(now, &stale_token_without_machine).as_public_str(),
            "missing_credential"
        );
    }

    #[test]
    fn auth_status_phase_maps_typed_lease_phase_to_public_values() {
        assert_eq!(
            AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Valid)).as_public_str(),
            "valid"
        );
        assert_eq!(
            AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Expiring)).as_public_str(),
            "expiring"
        );
        assert_eq!(
            AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Expired)).as_public_str(),
            "expired"
        );
        assert_eq!(
            AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::ReauthRequired)).as_public_str(),
            "reauth_required"
        );
        assert_eq!(
            AuthStatusPhase::from_lease_phase(None).as_public_str(),
            "absent"
        );
        assert_eq!(
            AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Released)).as_public_str(),
            "released"
        );
    }

    #[test]
    fn auth_status_phase_is_no_live_lease_classifies_three_states() {
        assert!(AuthStatusPhase::Released.is_no_live_lease());
        assert!(AuthStatusPhase::Absent.is_no_live_lease());
        assert!(AuthStatusPhase::MissingCredential.is_no_live_lease());
        assert!(!AuthStatusPhase::Valid.is_no_live_lease());
    }

    #[cfg(feature = "schema")]
    #[test]
    fn auth_status_emits_json_schema() {
        // Proves that the JsonSchema derive works without panicking and
        // produces a non-trivial schema object. L1.5 requirement.
        let schema = schemars::schema_for!(AuthStatus);
        let json = serde_json::to_value(&schema).unwrap();
        // The schema must have a top-level object with properties.
        assert!(json.is_object());
        let props = json
            .get("properties")
            .expect("AuthStatus schema has properties");
        assert!(props.get("profile_id").is_some());
        assert!(props.get("provider").is_some());
        assert!(props.get("needs_reauth").is_some());
    }
}