//! Plaintext-free audit explanation projections.
//!
//! This module turns raw [`crate::audit::AuditEntry`] values into reusable
//! explanation objects for session views, execution timelines, and authority
//! diffs. Secret *values* never appear here, and secret *names* are projected
//! as opaque `key_ref` hashes so higher layers can render "what happened and
//! why?" without widening the trust boundary.

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

use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
use crate::contracts::{
    AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision, AuthorityTrustLevel,
};
use crate::events::key_ref;
use crate::lifecycle::{classify_operation, LifecycleState, OperationKind};
use crate::rbac::RbacProfile;

pub const DEFAULT_SESSION_GAP_MINUTES: i64 = 30;

/// Timeline-style explanation for a profile's audit history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditTimeline {
    pub sessions: Vec<AuditSession>,
}

impl AuditTimeline {
    pub fn from_entries(entries: &[AuditEntry]) -> Self {
        explain_entries_with_gap(entries, Duration::minutes(DEFAULT_SESSION_GAP_MINUTES))
    }
}

/// Probable operator session inferred from audit history.
///
/// Current audit logs do not carry explicit session ids, so sessions are
/// grouped heuristically: on the first entry, on `unlock`, on profile changes,
/// and on long idle gaps.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditSession {
    pub profile: String,
    pub session_index: usize,
    pub start: DateTime<Utc>,
    pub end: DateTime<Utc>,
    pub boundary: SessionBoundary,
    pub operation_count: usize,
    pub exec_count: usize,
    pub failure_count: usize,
    pub operations: Vec<ExplainedAuditOperation>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionBoundary {
    StartOfLog,
    UnlockBoundary,
    TimeGap,
    ProfileChange,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExplainedAuditOperation {
    pub id: String,
    pub timestamp: DateTime<Utc>,
    pub operation: String,
    pub kind: ExplainedOperationKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lifecycle_state: Option<LifecycleState>,
    pub status: AuditStatus,
    pub key_ref: Option<String>,
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authority: Option<ExecutionAuthoritySummary>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExplainedOperationKind {
    SecretLifecycle,
    VaultLifecycle,
    Execution,
    Session,
    Sync,
    Share,
    Team,
    RotationPolicy,
    CredentialHelper,
    Other,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionAuthoritySummary {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub contract_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_decision: Option<AuthorityTargetDecision>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub matched_target: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authority_profile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authority_namespace: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_level: Option<AuthorityTrustLevel>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub access_profile: Option<RbacProfile>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inherit: Option<AuthorityInheritMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deny_dangerous_env: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub redact_output: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network: Option<AuthorityNetworkPolicy>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_secret_refs: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub required_secret_refs: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub injected_secret_refs: Vec<String>,
    pub contract_diff: AuthorityContractDiff,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub gaps: Vec<ExecutionGap>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityContractDiff {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unexpected_injected_secret_refs: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub missing_required_secret_refs: Vec<String>,
    pub target_mismatch: bool,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dropped_env_names: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionGap {
    MissingExecContext,
    MissingContractName,
    MissingTarget,
    MissingInjectedSecretSet,
    MissingTargetDecision,
}

pub fn explain_entries(entries: &[AuditEntry]) -> AuditTimeline {
    AuditTimeline::from_entries(entries)
}

pub fn explain_entries_with_gap(entries: &[AuditEntry], session_gap: Duration) -> AuditTimeline {
    let mut ordered = entries.to_vec();
    ordered.sort_by(|left, right| {
        left.timestamp
            .cmp(&right.timestamp)
            .then_with(|| left.id.cmp(&right.id))
    });

    let mut sessions = Vec::new();
    let mut current: Option<SessionAccumulator> = None;

    for entry in ordered {
        let boundary = match current.as_ref() {
            None => SessionBoundary::StartOfLog,
            Some(acc) if acc.profile != entry.profile => SessionBoundary::ProfileChange,
            Some(_) if entry.operation == "unlock" => SessionBoundary::UnlockBoundary,
            Some(acc) if entry.timestamp - acc.last_timestamp > session_gap => {
                SessionBoundary::TimeGap
            }
            Some(_) => {
                if let Some(acc) = current.as_mut() {
                    acc.push(entry);
                }
                continue;
            }
        };

        if let Some(acc) = current.take() {
            sessions.push(acc.finish());
        }
        let mut acc = SessionAccumulator::new(boundary, sessions.len(), entry.profile.clone());
        acc.push(entry);
        current = Some(acc);
    }

    if let Some(acc) = current.take() {
        sessions.push(acc.finish());
    }

    AuditTimeline { sessions }
}

impl ExplainedAuditOperation {
    pub fn from_entry(entry: &AuditEntry) -> Self {
        let classified = classify_operation(&entry.operation);
        let authority = if entry.operation == "exec" {
            Some(ExecutionAuthoritySummary::from_exec_context(
                &entry.profile,
                entry
                    .context
                    .as_ref()
                    .and_then(|context| context.exec.as_ref()),
            ))
        } else {
            None
        };

        Self {
            id: entry.id.clone(),
            timestamp: entry.timestamp,
            operation: entry.operation.clone(),
            kind: operation_kind(classified.kind),
            lifecycle_state: classified.lifecycle_state,
            status: entry.status.clone(),
            key_ref: entry.key.as_deref().map(|key| key_ref(&entry.profile, key)),
            message: entry.message.clone(),
            authority,
        }
    }
}

impl ExecutionAuthoritySummary {
    fn from_exec_context(profile: &str, context: Option<&AuditExecContext>) -> Self {
        let Some(context) = context else {
            return Self {
                contract_name: None,
                target: None,
                target_decision: None,
                matched_target: None,
                authority_profile: None,
                authority_namespace: None,
                trust_level: None,
                access_profile: None,
                inherit: None,
                deny_dangerous_env: None,
                redact_output: None,
                network: None,
                allowed_secret_refs: Vec::new(),
                required_secret_refs: Vec::new(),
                injected_secret_refs: Vec::new(),
                contract_diff: AuthorityContractDiff {
                    unexpected_injected_secret_refs: Vec::new(),
                    missing_required_secret_refs: Vec::new(),
                    target_mismatch: false,
                    dropped_env_names: Vec::new(),
                },
                gaps: vec![ExecutionGap::MissingExecContext],
            };
        };

        let allowed_names = sorted_unique(&context.allowed_secrets);
        let required_names = sorted_unique(&context.required_secrets);
        let injected_names = sorted_unique(&context.injected_secrets);
        let explicit_missing = sorted_unique(&context.missing_required_secrets);
        let missing_names = if explicit_missing.is_empty() {
            required_names
                .iter()
                .filter(|required| !injected_names.contains(*required))
                .cloned()
                .collect::<Vec<_>>()
        } else {
            explicit_missing
        };
        let unexpected_names = injected_names
            .iter()
            .filter(|injected| !allowed_names.contains(*injected))
            .cloned()
            .collect::<Vec<_>>();

        let mut gaps = Vec::new();
        if context.contract_name.as_deref().unwrap_or("").is_empty() {
            gaps.push(ExecutionGap::MissingContractName);
        }
        if context.target.as_deref().unwrap_or("").is_empty() {
            gaps.push(ExecutionGap::MissingTarget);
        }
        if injected_names.is_empty() {
            gaps.push(ExecutionGap::MissingInjectedSecretSet);
        }
        if context.target_allowed.is_none() && context.target_decision.is_none() {
            gaps.push(ExecutionGap::MissingTargetDecision);
        }

        let target_mismatch = match context.target_decision {
            Some(decision) => !decision.is_allowed(),
            None => matches!(context.target_allowed, Some(false)),
        };

        Self {
            contract_name: context.contract_name.clone(),
            target: context.target.clone(),
            target_decision: context.target_decision,
            matched_target: context.matched_target.clone(),
            authority_profile: context.authority_profile.clone(),
            authority_namespace: context.authority_namespace.clone(),
            trust_level: context.trust_level,
            access_profile: context.access_profile,
            inherit: context.inherit,
            deny_dangerous_env: context.deny_dangerous_env,
            redact_output: context.redact_output,
            network: context.network,
            allowed_secret_refs: names_to_refs(profile, &allowed_names),
            required_secret_refs: names_to_refs(profile, &required_names),
            injected_secret_refs: names_to_refs(profile, &injected_names),
            contract_diff: AuthorityContractDiff {
                unexpected_injected_secret_refs: names_to_refs(profile, &unexpected_names),
                missing_required_secret_refs: names_to_refs(profile, &missing_names),
                target_mismatch,
                dropped_env_names: sorted_unique(&context.dropped_env_names),
            },
            gaps,
        }
    }
}

fn operation_kind(kind: OperationKind) -> ExplainedOperationKind {
    match kind {
        OperationKind::SecretLifecycle => ExplainedOperationKind::SecretLifecycle,
        OperationKind::VaultLifecycle => ExplainedOperationKind::VaultLifecycle,
        OperationKind::Execution => ExplainedOperationKind::Execution,
        OperationKind::Session => ExplainedOperationKind::Session,
        OperationKind::Sync => ExplainedOperationKind::Sync,
        OperationKind::Share => ExplainedOperationKind::Share,
        OperationKind::Team => ExplainedOperationKind::Team,
        OperationKind::RotationPolicy => ExplainedOperationKind::RotationPolicy,
        OperationKind::CredentialHelper => ExplainedOperationKind::CredentialHelper,
        OperationKind::Other => ExplainedOperationKind::Other,
    }
}

fn sorted_unique(items: &[String]) -> Vec<String> {
    let mut out = items
        .iter()
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect::<Vec<_>>();
    out.sort();
    out.dedup();
    out
}

fn names_to_refs(profile: &str, names: &[String]) -> Vec<String> {
    names.iter().map(|name| key_ref(profile, name)).collect()
}

#[derive(Debug)]
struct SessionAccumulator {
    profile: String,
    session_index: usize,
    boundary: SessionBoundary,
    start: DateTime<Utc>,
    last_timestamp: DateTime<Utc>,
    operations: Vec<ExplainedAuditOperation>,
    exec_count: usize,
    failure_count: usize,
}

impl SessionAccumulator {
    fn new(boundary: SessionBoundary, session_index: usize, profile: String) -> Self {
        Self {
            profile,
            session_index,
            boundary,
            start: Utc::now(),
            last_timestamp: Utc::now(),
            operations: Vec::new(),
            exec_count: 0,
            failure_count: 0,
        }
    }

    fn push(&mut self, entry: AuditEntry) {
        if self.operations.is_empty() {
            self.start = entry.timestamp;
        }
        self.last_timestamp = entry.timestamp;
        if entry.operation == "exec" {
            self.exec_count += 1;
        }
        if matches!(entry.status, AuditStatus::Failure) {
            self.failure_count += 1;
        }
        self.operations
            .push(ExplainedAuditOperation::from_entry(&entry));
    }

    fn finish(self) -> AuditSession {
        AuditSession {
            profile: self.profile,
            session_index: self.session_index,
            start: self.start,
            end: self.last_timestamp,
            boundary: self.boundary,
            operation_count: self.operations.len(),
            exec_count: self.exec_count,
            failure_count: self.failure_count,
            operations: self.operations,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audit::{AuditContext, AuditExecContext};
    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
    use crate::lifecycle::{
        CredentialHelperLifecycleState, LifecycleState, PolicyLifecycleState, SecretLifecycleState,
        SessionLifecycleState, ShareLifecycleState, SyncLifecycleState, TeamLifecycleState,
        VaultLifecycleState,
    };
    use crate::rbac::RbacProfile;

    #[test]
    fn exec_without_context_reports_gap() {
        let entry = AuditEntry::success("dev", "exec", None);
        let explained = ExplainedAuditOperation::from_entry(&entry);
        let authority = explained
            .authority
            .expect("exec should produce authority summary");
        assert_eq!(authority.gaps, vec![ExecutionGap::MissingExecContext]);
        assert!(authority.injected_secret_refs.is_empty());
    }

    #[test]
    fn exec_context_projects_plaintext_free_contract_diff() {
        let contract = AuthorityContract {
            name: "deploy".into(),
            profile: Some("work".into()),
            namespace: Some("infra".into()),
            access_profile: RbacProfile::ReadOnly,
            allow_all_secrets: false,
            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
            required_secrets: vec!["DB_PASSWORD".into()],
            allowed_targets: vec!["terraform".into()],
            trust: AuthorityTrust::Hardened,
            network: AuthorityNetworkPolicy::Restricted,
        };
        let context = AuditContext::from_exec(
            AuditExecContext::from_contract(&contract)
                .with_target("terraform")
                .with_injected_secrets(["DB_PASSWORD", "UNPLANNED_TOKEN"])
                .with_dropped_env_names(["OPENAI_API_KEY"])
                .with_target_evaluation(&contract.evaluate_target(Some("terraform"))),
        );
        let entry = AuditEntry::success("dev", "exec", None).with_context(context);

        let explained = ExplainedAuditOperation::from_entry(&entry);
        let authority = explained.authority.unwrap();
        assert_eq!(authority.contract_name.as_deref(), Some("deploy"));
        assert_eq!(
            authority.target_decision,
            Some(AuthorityTargetDecision::AllowedExact)
        );
        assert_eq!(authority.access_profile, Some(RbacProfile::ReadOnly));
        assert_eq!(authority.matched_target.as_deref(), Some("terraform"));
        assert_eq!(authority.trust_level, Some(AuthorityTrustLevel::Hardened));
        assert_eq!(authority.inherit, Some(AuthorityInheritMode::Minimal));
        assert_eq!(authority.network, Some(AuthorityNetworkPolicy::Restricted));
        assert_eq!(authority.allowed_secret_refs.len(), 2);
        assert_eq!(authority.injected_secret_refs.len(), 2);
        assert_eq!(
            authority
                .contract_diff
                .unexpected_injected_secret_refs
                .len(),
            1
        );
        assert_eq!(
            authority.contract_diff.missing_required_secret_refs.len(),
            0
        );
        assert_eq!(
            authority.contract_diff.dropped_env_names,
            vec!["OPENAI_API_KEY"]
        );
        assert!(!authority
            .allowed_secret_refs
            .iter()
            .any(|value| value.contains("DB_PASSWORD")));
    }

    #[test]
    fn exec_context_denied_target_sets_mismatch_without_plaintext_leak() {
        let contract = AuthorityContract {
            name: "deploy".into(),
            profile: Some("work".into()),
            namespace: Some("infra".into()),
            access_profile: RbacProfile::ReadOnly,
            allow_all_secrets: false,
            allowed_secrets: vec!["DB_PASSWORD".into()],
            required_secrets: vec!["DB_PASSWORD".into()],
            allowed_targets: vec!["terraform".into()],
            trust: AuthorityTrust::Hardened,
            network: AuthorityNetworkPolicy::Restricted,
        };
        let context = AuditContext::from_exec(
            AuditExecContext::from_contract(&contract)
                .with_target("bash")
                .with_injected_secrets(["DB_PASSWORD"])
                .with_target_evaluation(&contract.evaluate_target(Some("bash"))),
        );
        let entry = AuditEntry::success("dev", "exec", None).with_context(context);

        let explained = ExplainedAuditOperation::from_entry(&entry);
        let authority = explained.authority.unwrap();
        assert_eq!(
            authority.target_decision,
            Some(AuthorityTargetDecision::Denied)
        );
        assert!(authority.contract_diff.target_mismatch);
        assert!(authority.matched_target.is_none());
        assert!(!serde_json::to_string(&authority)
            .unwrap()
            .contains("DB_PASSWORD"));
    }

    #[test]
    fn timeline_splits_on_unlock_and_idle_gap() {
        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
            .unwrap()
            .with_timezone(&Utc);

        let mut unlock = AuditEntry::success("dev", "unlock", None);
        unlock.timestamp = base;

        let mut exec = AuditEntry::success("dev", "exec", None);
        exec.timestamp = base + Duration::minutes(5);

        let mut get = AuditEntry::success("dev", "get", Some("API_KEY"));
        get.timestamp = base + Duration::minutes(7);

        let mut later = AuditEntry::failure("dev", "get", Some("MISSING"), "not found");
        later.timestamp = base + Duration::minutes(80);

        let timeline = explain_entries_with_gap(&[later, exec, unlock, get], Duration::minutes(30));
        assert_eq!(timeline.sessions.len(), 2);
        assert_eq!(timeline.sessions[0].boundary, SessionBoundary::StartOfLog);
        assert_eq!(timeline.sessions[0].operation_count, 3);
        assert_eq!(timeline.sessions[0].exec_count, 1);
        assert_eq!(timeline.sessions[1].boundary, SessionBoundary::TimeGap);
        assert_eq!(timeline.sessions[1].failure_count, 1);
    }

    #[test]
    fn timeline_splits_on_profile_change() {
        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
            .unwrap()
            .with_timezone(&Utc);

        let mut left = AuditEntry::success("dev", "get", Some("A"));
        left.timestamp = base;
        let mut right = AuditEntry::success("prod", "get", Some("B"));
        right.timestamp = base + Duration::minutes(1);

        let timeline = explain_entries_with_gap(&[left, right], Duration::minutes(30));
        assert_eq!(timeline.sessions.len(), 2);
        assert_eq!(
            timeline.sessions[1].boundary,
            SessionBoundary::ProfileChange
        );
    }

    #[test]
    fn vault_and_share_entries_expose_explicit_lifecycle_state() {
        let created =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "init", None));
        assert_eq!(
            created.lifecycle_state,
            Some(LifecycleState::Vault(VaultLifecycleState::Created))
        );

        let published =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "share-once", None));
        assert_eq!(
            published.lifecycle_state,
            Some(LifecycleState::Share(ShareLifecycleState::Published))
        );
    }

    #[test]
    fn secret_entries_expose_explicit_lifecycle_state() {
        let written = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "set", None));
        assert_eq!(
            written.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Written))
        );

        let accessed =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "get", None));
        assert_eq!(
            accessed.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
        );

        let deleted =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "delete", None));
        assert_eq!(
            deleted.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
        );

        let imported =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "import", None));
        assert_eq!(
            imported.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Imported))
        );

        let exported =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "export", None));
        assert_eq!(
            exported.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Exported))
        );

        let namespace_copy =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-copy", None));
        assert_eq!(
            namespace_copy.lifecycle_state,
            Some(LifecycleState::Secret(SecretLifecycleState::Written))
        );
    }

    #[test]
    fn surface_aliases_reuse_existing_vault_lifecycle_state() {
        let created =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "create", None));
        assert_eq!(
            created.lifecycle_state,
            Some(LifecycleState::Vault(VaultLifecycleState::Created))
        );

        let team_created =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "team-init", None));
        assert_eq!(
            team_created.lifecycle_state,
            Some(LifecycleState::Vault(VaultLifecycleState::Created))
        );

        let namespace_move =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-move", None));
        assert_eq!(
            namespace_move.lifecycle_state,
            Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
        );
    }

    #[test]
    fn policy_and_helper_entries_expose_explicit_lifecycle_state() {
        let policy_set =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "policy-set", None));
        assert_eq!(
            policy_set.lifecycle_state,
            Some(LifecycleState::Policy(PolicyLifecycleState::Set))
        );

        let policy_due =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "rotate-due", None));
        assert_eq!(
            policy_due.lifecycle_state,
            Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
        );

        let helper_get = ExplainedAuditOperation::from_entry(&AuditEntry::success(
            "dev",
            "credential-helper-get",
            None,
        ));
        assert_eq!(
            helper_get.lifecycle_state,
            Some(LifecycleState::CredentialHelper(
                CredentialHelperLifecycleState::Accessed
            ))
        );

        let helper_store = ExplainedAuditOperation::from_entry(&AuditEntry::success(
            "dev",
            "credential-helper-store",
            None,
        ));
        assert_eq!(
            helper_store.lifecycle_state,
            Some(LifecycleState::CredentialHelper(
                CredentialHelperLifecycleState::Stored
            ))
        );
    }

    #[test]
    fn team_entries_expose_explicit_lifecycle_state_and_kind() {
        let added = ExplainedAuditOperation::from_entry(&AuditEntry::success(
            "dev",
            "team-add-member",
            None,
        ));
        assert_eq!(added.kind, ExplainedOperationKind::Team);
        assert_eq!(
            added.lifecycle_state,
            Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
        );

        let removed = ExplainedAuditOperation::from_entry(&AuditEntry::success(
            "dev",
            "team-remove-member",
            None,
        ));
        assert_eq!(removed.kind, ExplainedOperationKind::Team);
        assert_eq!(
            removed.lifecycle_state,
            Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
        );
    }

    #[test]
    fn session_and_sync_entries_expose_explicit_lifecycle_state() {
        let unlocked =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "unlock", None));
        assert_eq!(
            unlocked.lifecycle_state,
            Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
        );

        let pulled =
            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "kv-pull", None));
        assert_eq!(
            pulled.lifecycle_state,
            Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
        );

        let merged = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "sync", None));
        assert_eq!(
            merged.lifecycle_state,
            Some(LifecycleState::Sync(SyncLifecycleState::Merged))
        );
    }
}
