//! Source-local mobile enrollment and QR envelope parsing.
//!
//! This module validates public coordination metadata and encrypted QR payload
//! framing only. It deliberately does not decrypt share payloads or expose broad
//! vault plaintext APIs.

use chrono::{DateTime, Utc};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;
use tsafe_core::age_crypto;
use tsafe_core::crypto;
use tsafe_core::run_evidence;

use crate::{SafeError, SafeResult};

pub const ENROLLMENT_START_SCHEMA: &str = "tsafe/mobile/enrollment-start/v1";
pub const ENROLLMENT_ENVELOPE_SCHEMA: &str = "tsafe/mobile/enrollment-envelope/v1";
pub const ENROLLMENT_START_REQUEST_SCHEMA: &str = "tsafe/mobile/enrollment-start-request/v1";
pub const ENROLLMENT_RESPONSE_SCHEMA: &str = "tsafe/mobile/enrollment-response/v1";
pub const DESKTOP_VAULT_PROFILE_SCHEMA: &str = "tsafe/mobile/desktop-vault-profile/v1";
pub const MOBILE_PROFILE_RETURN_SCHEMA: &str = "tsafe/mobile/mobile-profile-return/v1";
pub const PUBLIC_QR_RECEIVE_SCHEMA: &str = "tsafe/mobile/qr-receive/v1";
pub const QR_RECEIVE_ENVELOPE_SCHEMA: &str = "tsafe/mobile/qr-receive-envelope/v1";
pub const PUBLIC_QR_RECEIVE_ENVELOPE_SCHEMA: &str = "tsafe/mobile/qr-receive-public-envelope/v1";
pub const ENCRYPTED_QR_PAYLOAD_SCHEMA: &str = "tsafe/mobile/qr-encrypted-payload/v1";
pub const QR_SHARE_PAYLOAD_SCHEMA: &str = "tsafe/mobile/qr-share-payload/v1";
pub const ENCRYPTED_QR_CHUNK_SCHEMA: &str = "tsafe/mobile/qr-encrypted-chunk/v1";
pub const ENCRYPTED_QR_PAYLOAD_CHUNK_SCHEMA: &str = "tsafe/mobile/qr-encrypted-payload-chunk/v1";

const SUPPORTED_MAJOR_VERSION: u16 = 1;
const MAX_QR_FRAMES: u16 = 64;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileRepoRef {
    pub owner: String,
    pub name: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileVaultIdentity {
    pub vault_id: String,
    pub vault_fingerprint: String,
    pub profile: String,
    pub origin_device_id: String,
    pub origin_platform: String,
    pub label: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollmentStartEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub desktop_public_key: String,
    pub nonce: String,
    pub repo: MobileRepoRef,
    pub branch: Option<String>,
    pub profile: String,
    pub team_keys_path: String,
    pub vault_path: String,
    pub vault_identity: Option<MobileVaultIdentity>,
    pub requested_capabilities: Vec<String>,
    pub display_name: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollmentResponseEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub nonce: String,
    pub repo: MobileRepoRef,
    pub profile: String,
    pub vault_identity: Option<MobileVaultIdentity>,
    pub mobile_recipient: String,
    pub device_label: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DesktopVaultProfileEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub nonce: String,
    pub repo: MobileRepoRef,
    pub branch: Option<String>,
    pub profile: String,
    pub team_keys_path: String,
    pub vault_path: String,
    pub desktop_platform: String,
    pub vault_id: String,
    pub vault_fingerprint: String,
    pub desktop_public_key: String,
    pub requested_capabilities: Vec<String>,
    pub display_name: Option<String>,
}

impl DesktopVaultProfileEnvelope {
    pub fn profile_binding_key(&self) -> String {
        desktop_vault_profile_key(&self.repo, &self.vault_id)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileProfileReturnEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub nonce: String,
    pub repo: MobileRepoRef,
    pub profile: String,
    pub vault_id: String,
    pub mobile_profile_id: String,
    pub mobile_recipient: String,
    pub device_label: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MobileDesktopVaultProfileAction {
    CreateProfile,
    ExistingProfile,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileDesktopVaultProfileDecision {
    pub action: MobileDesktopVaultProfileAction,
    pub profile_binding_key: String,
    pub vault_id: String,
    pub profile: String,
    pub desktop_platform: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicQrReceiveEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub profile: String,
    pub nonce: String,
    pub public_key_id: String,
    pub recipient_public_key: String,
    pub requested_capabilities: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QrReceiveEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub challenge: String,
    pub recipient_key_id: String,
    pub recipient_public_key: String,
    pub digest: String,
    pub frame_count: u16,
    pub frame_index: u16,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QrSharePayloadEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub recipient_key_id: String,
    pub challenge: String,
    pub kem: String,
    pub aead: String,
    pub aead_nonce: String,
    pub ciphertext: String,
    pub digest: String,
    pub payload_digest: String,
    pub frame_count: u16,
    pub frame_index: u16,
    pub key_name_suggestion: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptedQrPayloadEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub profile: String,
    pub nonce: String,
    pub recipient_public_key_id: String,
    pub sender_protocol: String,
    pub aead_nonce: String,
    pub ciphertext: String,
    pub digest: String,
    pub frame_count: u16,
    pub frame_index: u16,
    pub key_name_suggestion: Option<String>,
    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub metadata: serde_json::Map<String, Value>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptedQrChunkEnvelope {
    pub schema: String,
    pub version: String,
    pub session_id: String,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub profile: String,
    pub nonce: String,
    pub frame_count: u16,
    pub frame_index: u16,
    pub payload_digest: String,
    pub chunk: String,
}

pub fn parse_enrollment_start_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<EnrollmentStartEnvelope> {
    let value = parse_value(bytes.as_ref(), "enrollment start envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawEnrollmentStartEnvelope = parse_from_value(value, "enrollment start envelope")?;
    let repo_source = repo_from_binding(raw.repo.clone(), raw.binding.as_ref());
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.profile.clone())
        }),
        raw.nonce,
        &[
            ENROLLMENT_START_SCHEMA,
            ENROLLMENT_ENVELOPE_SCHEMA,
            ENROLLMENT_START_REQUEST_SCHEMA,
        ],
        now,
    )?;
    let repo = validate_repo(repo_source.clone())?;
    let branch = raw
        .branch
        .or_else(|| branch_from_binding(raw.binding.as_ref()))
        .or_else(|| branch_from_repo(repo_source.as_ref()));
    let desktop_public_key = require_non_empty(
        raw.desktop_public_key.or_else(|| {
            raw.desktop
                .as_ref()
                .and_then(|desktop| desktop.public_key.as_ref())
                .and_then(|public_key| public_key.value.clone())
                .or_else(|| {
                    raw.desktop_public_identity
                        .as_ref()
                        .and_then(|identity| identity.enrollment_public_key.as_ref())
                        .and_then(|public_key| public_key.public_key.clone())
                })
        }),
        "desktop_public_key",
    )?;
    let team_keys_path = require_non_empty(
        raw.team_keys_path.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.team_keys_path.clone())
                .or_else(|| raw.paths.as_ref().and_then(|paths| paths.team_keys.clone()))
        }),
        "team_keys_path",
    )?;
    let vault_path = require_non_empty(
        raw.vault_path.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.vault_path.clone())
                .or_else(|| {
                    raw.paths
                        .as_ref()
                        .and_then(|paths| paths.vault_ciphertext.clone())
                })
        }),
        "vault_path",
    )?;
    let requested_capabilities = raw
        .requested_capabilities
        .or(raw.capabilities)
        .unwrap_or_default();
    let vault_identity = validate_vault_identity(raw.vault_identity, &common.profile)?;
    let display_name = raw.display_name.or_else(|| {
        raw.desktop
            .as_ref()
            .and_then(|desktop| desktop.display_name.clone())
            .or_else(|| {
                raw.desktop_public_identity
                    .as_ref()
                    .and_then(|identity| identity.display_name.clone())
            })
    });

    Ok(EnrollmentStartEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        desktop_public_key,
        nonce: common.nonce,
        repo,
        branch: normalize_optional(branch),
        profile: common.profile,
        team_keys_path,
        vault_path,
        vault_identity,
        requested_capabilities,
        display_name: normalize_optional(display_name),
    })
}

pub fn validate_enrollment_response_matches_start(
    start: &EnrollmentStartEnvelope,
    response: &EnrollmentResponseEnvelope,
) -> SafeResult<()> {
    if response.session_id != start.session_id {
        return invalid("enrollment response flow binding mismatch");
    }
    if response.nonce != start.nonce {
        return invalid("enrollment response nonce mismatch");
    }
    if response.profile != start.profile {
        return invalid("enrollment response profile mismatch");
    }
    if response.repo != start.repo {
        return invalid("enrollment response repo mismatch");
    }
    match (&start.vault_identity, &response.vault_identity) {
        (Some(start_identity), Some(response_identity)) => {
            if response_identity.vault_id != start_identity.vault_id
                || response_identity.vault_fingerprint != start_identity.vault_fingerprint
            {
                return invalid("enrollment response vault identity mismatch");
            }
        }
        (Some(_), None) => return invalid("enrollment response vault identity missing"),
        _ => {}
    }
    Ok(())
}

pub fn parse_desktop_vault_profile_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<DesktopVaultProfileEnvelope> {
    let value = parse_value(bytes.as_ref(), "desktop vault profile envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawDesktopVaultProfileEnvelope =
        parse_from_value(value, "desktop vault profile envelope")?;
    let repo_source = repo_from_binding(raw.repo.clone(), raw.binding.as_ref());
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.profile.clone())
        }),
        raw.nonce,
        &[DESKTOP_VAULT_PROFILE_SCHEMA],
        now,
    )?;
    let repo = validate_repo(repo_source.clone())?;
    let branch = raw
        .branch
        .or_else(|| branch_from_binding(raw.binding.as_ref()))
        .or_else(|| branch_from_repo(repo_source.as_ref()));
    let desktop_platform =
        validate_desktop_platform(require_non_empty(raw.desktop_platform, "desktop_platform")?)?;
    let vault_id = validate_profile_id(require_non_empty(raw.vault_id, "vault_id")?, "vault_id")?;
    let vault_fingerprint = require_non_empty(raw.vault_fingerprint, "vault_fingerprint")?;
    validate_digest_label(&vault_fingerprint, "vault_fingerprint")?;
    let desktop_public_key = require_non_empty(raw.desktop_public_key, "desktop_public_key")?;
    let team_keys_path = require_non_empty(
        raw.team_keys_path.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.team_keys_path.clone())
                .or_else(|| raw.paths.as_ref().and_then(|paths| paths.team_keys.clone()))
        }),
        "team_keys_path",
    )?;
    let vault_path = require_non_empty(
        raw.vault_path.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.vault_path.clone())
                .or_else(|| {
                    raw.paths
                        .as_ref()
                        .and_then(|paths| paths.vault_ciphertext.clone())
                })
        }),
        "vault_path",
    )?;

    Ok(DesktopVaultProfileEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        nonce: common.nonce,
        repo,
        branch: normalize_optional(branch),
        profile: common.profile,
        team_keys_path,
        vault_path,
        desktop_platform,
        vault_id,
        vault_fingerprint,
        desktop_public_key,
        requested_capabilities: raw
            .requested_capabilities
            .or(raw.capabilities)
            .unwrap_or_default(),
        display_name: normalize_optional(raw.display_name),
    })
}

pub fn desktop_vault_profile_key(repo: &MobileRepoRef, vault_id: &str) -> String {
    format!("{}/{}#{}", repo.owner, repo.name, vault_id)
}

pub fn classify_desktop_vault_profile(
    envelope: &DesktopVaultProfileEnvelope,
    known_vault_ids: &[String],
) -> MobileDesktopVaultProfileDecision {
    let action = if known_vault_ids
        .iter()
        .any(|known| known.trim() == envelope.vault_id)
    {
        MobileDesktopVaultProfileAction::ExistingProfile
    } else {
        MobileDesktopVaultProfileAction::CreateProfile
    };

    MobileDesktopVaultProfileDecision {
        action,
        profile_binding_key: envelope.profile_binding_key(),
        vault_id: envelope.vault_id.clone(),
        profile: envelope.profile.clone(),
        desktop_platform: envelope.desktop_platform.clone(),
    }
}

pub fn validate_mobile_profile_return_matches_desktop_vault(
    start: &DesktopVaultProfileEnvelope,
    response: &MobileProfileReturnEnvelope,
) -> SafeResult<()> {
    if response.session_id != start.session_id {
        return invalid("mobile profile return flow binding mismatch");
    }
    if response.nonce != start.nonce {
        return invalid("mobile profile return nonce mismatch");
    }
    if response.profile != start.profile {
        return invalid("mobile profile return profile mismatch");
    }
    if response.repo != start.repo {
        return invalid("mobile profile return repo mismatch");
    }
    if response.vault_id != start.vault_id {
        return invalid("mobile profile return vault mismatch");
    }
    Ok(())
}

pub fn parse_mobile_profile_return_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<MobileProfileReturnEnvelope> {
    let value = parse_value(bytes.as_ref(), "mobile profile return envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawMobileProfileReturnEnvelope =
        parse_from_value(value, "mobile profile return envelope")?;
    let repo_source = repo_from_binding(raw.repo.clone(), raw.binding.as_ref());
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.profile.clone())
        }),
        raw.nonce,
        &[MOBILE_PROFILE_RETURN_SCHEMA],
        now,
    )?;
    let repo = validate_repo(repo_source)?;
    let vault_id = validate_profile_id(require_non_empty(raw.vault_id, "vault_id")?, "vault_id")?;
    let mobile_profile_id = validate_profile_id(
        require_non_empty(raw.mobile_profile_id, "mobile_profile_id")?,
        "mobile_profile_id",
    )?;
    let mobile_recipient = require_non_empty(raw.mobile_recipient, "mobile_recipient")?;
    if !mobile_recipient.starts_with("age1") {
        return invalid("mobile_recipient must be a public age recipient");
    }

    Ok(MobileProfileReturnEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        nonce: common.nonce,
        repo,
        profile: common.profile,
        vault_id,
        mobile_profile_id,
        mobile_recipient,
        device_label: normalize_optional(raw.device_label),
    })
}

pub fn parse_enrollment_response_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<EnrollmentResponseEnvelope> {
    let value = parse_value(bytes.as_ref(), "enrollment response envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawEnrollmentResponseEnvelope =
        parse_from_value(value, "enrollment response envelope")?;
    let repo_binding_profile = raw
        .repo_binding
        .as_ref()
        .and_then(|binding| binding.profile.clone());
    let nonce_response = raw.nonce_response.as_ref();
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile
            .or_else(|| {
                raw.binding
                    .as_ref()
                    .and_then(|binding| binding.profile.clone())
            })
            .or(repo_binding_profile),
        raw.nonce.or(raw.request_nonce).or_else(|| {
            nonce_response
                .and_then(|nonce| nonce.enrollment_nonce.clone())
                .or_else(|| nonce_response.and_then(|nonce| nonce.mobile_challenge.clone()))
                .or_else(|| nonce_response.and_then(|nonce| nonce.value.clone()))
        }),
        &[ENROLLMENT_RESPONSE_SCHEMA],
        now,
    )?;
    if raw
        .status
        .as_deref()
        .is_some_and(|status| status != "accepted")
    {
        return invalid("enrollment response status must be accepted");
    }
    let repo = validate_repo(
        repo_from_binding(raw.repo, raw.binding.as_ref()).or_else(|| {
            raw.repo_binding.as_ref().map(|binding| RawRepoRef {
                owner: binding.owner.clone(),
                name: binding.name.clone(),
                reference: None,
            })
        }),
    )?;
    let mobile_recipient = require_non_empty(
        raw.mobile_recipient
            .or_else(|| {
                raw.mobile_identity
                    .as_ref()
                    .and_then(|identity| identity.public_age_recipient.clone())
            })
            .or_else(|| {
                raw.mobile_public_recipient
                    .as_ref()
                    .and_then(|recipient| recipient.recipient.clone())
            }),
        "mobile_recipient",
    )?;
    validate_age_public_recipient(&mobile_recipient, "mobile_recipient")?;
    let vault_identity = validate_vault_identity(raw.vault_identity, &common.profile)?;
    let device_label = raw
        .device_label
        .or_else(|| {
            raw.mobile_identity
                .as_ref()
                .and_then(|identity| identity.device_display_name.clone())
        })
        .or_else(|| {
            raw.mobile_public_recipient
                .as_ref()
                .and_then(|recipient| recipient.label.clone())
        })
        .or_else(|| {
            raw.device
                .as_ref()
                .and_then(|device| device.display_name.clone())
        });

    Ok(EnrollmentResponseEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        nonce: common.nonce,
        repo,
        profile: common.profile,
        vault_identity,
        mobile_recipient,
        device_label: normalize_optional(device_label),
    })
}

pub fn parse_public_qr_receive_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<PublicQrReceiveEnvelope> {
    let value = parse_value(bytes.as_ref(), "public QR receive envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawPublicQrReceiveEnvelope = parse_from_value(value, "public QR receive envelope")?;
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.profile.clone())
        }),
        raw.nonce.or(raw.challenge_nonce),
        &[PUBLIC_QR_RECEIVE_SCHEMA, PUBLIC_QR_RECEIVE_ENVELOPE_SCHEMA],
        now,
    )?;
    let public_key_id = require_non_empty(
        raw.public_key_id.or_else(|| {
            raw.recipient
                .as_ref()
                .and_then(|recipient| recipient.key_id.clone())
        }),
        "public_key_id",
    )?;
    let recipient_public_key = require_non_empty(
        raw.recipient_public_key.or_else(|| {
            raw.recipient
                .as_ref()
                .and_then(|recipient| recipient.public_age_recipient.clone())
        }),
        "recipient_public_key",
    )?;
    validate_age_public_recipient(&recipient_public_key, "recipient_public_key")?;
    let requested_capabilities = raw
        .requested_capabilities
        .or(raw.capabilities)
        .unwrap_or_default();

    Ok(PublicQrReceiveEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        profile: common.profile,
        nonce: common.nonce,
        public_key_id,
        recipient_public_key,
        requested_capabilities,
    })
}

pub fn parse_qr_receive_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<QrReceiveEnvelope> {
    let value = parse_value(bytes.as_ref(), "QR receive envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawQrReceiveEnvelope = parse_from_value(value, "QR receive envelope")?;
    let common = validate_temporal_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        &[QR_RECEIVE_ENVELOPE_SCHEMA],
        now,
    )?;
    let challenge = require_non_empty(raw.challenge, "challenge")?;
    let recipient = raw
        .recipient_ephemeral_key
        .ok_or_else(|| invalid_error("missing recipient_ephemeral_key"))?;
    if recipient.algorithm.as_deref() != Some("x25519") {
        return invalid("recipient_ephemeral_key.algorithm must be x25519");
    }
    validate_supported_algorithms(raw.supported_algorithms.as_ref())?;
    let (frame_count, frame_index) = validate_required_frame(raw.frame.as_ref())?;
    let digest = validate_required_digest(raw.digest.as_ref(), "digest")?;

    Ok(QrReceiveEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        challenge,
        recipient_key_id: require_non_empty(recipient.key_id, "recipient_ephemeral_key.key_id")?,
        recipient_public_key: require_non_empty(
            recipient.public_key,
            "recipient_ephemeral_key.public_key",
        )?,
        digest,
        frame_count,
        frame_index,
    })
}

pub fn parse_qr_share_payload_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<QrSharePayloadEnvelope> {
    let value = parse_value(bytes.as_ref(), "QR share payload envelope")?;
    reject_secret_like_material(&value)?;
    let raw: RawQrSharePayloadEnvelope = parse_from_value(value, "QR share payload envelope")?;
    let common = validate_temporal_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        &[QR_SHARE_PAYLOAD_SCHEMA],
        now,
    )?;
    let encryption = raw
        .encryption
        .ok_or_else(|| invalid_error("missing encryption"))?;
    if encryption.kem.as_deref() != Some("x25519-hkdf-sha256") {
        return invalid("encryption.kem must be x25519-hkdf-sha256");
    }
    match encryption.aead.as_deref() {
        Some("xchacha20-poly1305" | "aes-256-gcm") => {}
        _ => return invalid("encryption.aead must be a supported AEAD"),
    }
    let (frame_count, frame_index) = validate_required_frame(raw.frame.as_ref())?;
    let frame = raw.frame.as_ref().expect("frame validated above");
    let payload_digest = require_non_empty(
        frame.payload_digest_sha256.clone(),
        "frame.payload_digest_sha256",
    )?;
    validate_hex_digest(&payload_digest, "frame.payload_digest_sha256")?;
    let digest = validate_required_digest(raw.digest.as_ref(), "digest")?;

    Ok(QrSharePayloadEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        recipient_key_id: require_non_empty(raw.recipient_key_id, "recipient_key_id")?,
        challenge: require_non_empty(raw.challenge, "challenge")?,
        kem: encryption.kem.unwrap_or_default(),
        aead: encryption.aead.unwrap_or_default(),
        aead_nonce: require_non_empty(encryption.aead_nonce, "encryption.aead_nonce")?,
        ciphertext: require_non_empty(raw.ciphertext, "ciphertext")?,
        digest,
        payload_digest: format!("sha256:{payload_digest}"),
        frame_count,
        frame_index,
        key_name_suggestion: raw
            .secret_metadata
            .and_then(|metadata| metadata.key_name_suggestion)
            .and_then(|value| normalize_optional(Some(value))),
    })
}

pub fn parse_encrypted_qr_payload_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<EncryptedQrPayloadEnvelope> {
    let raw: RawEncryptedQrPayloadEnvelope =
        parse_json(bytes.as_ref(), "encrypted QR payload envelope")?;
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile,
        raw.nonce,
        &[ENCRYPTED_QR_PAYLOAD_SCHEMA],
        now,
    )?;
    let recipient_public_key_id =
        require_non_empty(raw.recipient_public_key_id, "recipient_public_key_id")?;
    let sender_protocol = require_non_empty(raw.sender_protocol, "sender_protocol")?;
    let aead_nonce = require_non_empty(raw.aead_nonce, "aead_nonce")?;
    let ciphertext = require_non_empty(raw.ciphertext, "ciphertext")?;
    let digest = require_non_empty(raw.digest, "digest")?;
    let frame_count = raw.frame_count.unwrap_or(1);
    let frame_index = raw.frame_index.unwrap_or(0);

    if frame_count != 1 {
        return invalid("encrypted QR payload frame count must be 1 outside chunk envelopes");
    }
    if frame_index != 0 {
        return invalid("encrypted QR payload frame index must be 0 outside chunk envelopes");
    }

    let nonce_bytes = validate_base64url_non_empty(&aead_nonce, "aead_nonce")?;
    if nonce_bytes.len() != crypto::NONCE_LEN {
        return invalid(format!(
            "aead_nonce must decode to {} bytes, got {}",
            crypto::NONCE_LEN,
            nonce_bytes.len()
        ));
    }
    validate_base64url_non_empty(&ciphertext, "ciphertext")?;
    validate_digest(&digest, "digest")?;
    let actual = integrity_digest(ciphertext.as_bytes());
    if digest != actual {
        return invalid("digest mismatch for encrypted QR payload ciphertext");
    }

    Ok(EncryptedQrPayloadEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        profile: common.profile,
        nonce: common.nonce,
        recipient_public_key_id,
        sender_protocol,
        aead_nonce,
        ciphertext,
        digest,
        frame_count,
        frame_index,
        key_name_suggestion: normalize_optional(raw.key_name_suggestion),
        metadata: raw.metadata.unwrap_or_default(),
    })
}

pub fn parse_encrypted_qr_chunk_envelope(
    bytes: impl AsRef<[u8]>,
    now: DateTime<Utc>,
) -> SafeResult<EncryptedQrChunkEnvelope> {
    let raw: RawEncryptedQrChunkEnvelope =
        parse_json(bytes.as_ref(), "encrypted QR chunk envelope")?;
    let common = validate_common(
        raw.schema,
        raw.version,
        raw.session_id.or(raw.flow_id),
        raw.created_at,
        raw.expires_at,
        raw.profile.or_else(|| {
            raw.binding
                .as_ref()
                .and_then(|binding| binding.profile.clone())
        }),
        raw.nonce.or(raw.challenge_nonce),
        &[ENCRYPTED_QR_CHUNK_SCHEMA, ENCRYPTED_QR_PAYLOAD_CHUNK_SCHEMA],
        now,
    )?;
    let frame_count = raw
        .frame_count
        .or_else(|| raw.frame.as_ref().and_then(RawFrame::count))
        .ok_or_else(|| invalid_error("missing frame_count"))?;
    let frame_index = raw
        .frame_index
        .or_else(|| raw.frame.as_ref().and_then(RawFrame::index))
        .ok_or_else(|| invalid_error("missing frame_index"))?;
    validate_frame_bounds(frame_count, frame_index)?;
    let payload_digest = require_non_empty(
        raw.payload_digest
            .or_else(|| digest_name(raw.digest.as_ref())),
        "payload_digest",
    )?;
    validate_digest(&payload_digest, "payload_digest")?;
    let chunk = require_non_empty(
        raw.chunk.or_else(|| {
            raw.payload
                .as_ref()
                .and_then(|payload| payload.ciphertext.clone())
        }),
        "chunk",
    )?;

    Ok(EncryptedQrChunkEnvelope {
        schema: common.schema,
        version: common.version,
        session_id: common.session_id,
        created_at: common.created_at,
        expires_at: common.expires_at,
        profile: common.profile,
        nonce: common.nonce,
        frame_count,
        frame_index,
        payload_digest,
        chunk,
    })
}

pub fn assemble_encrypted_qr_chunks(
    frames: &[&[u8]],
    now: DateTime<Utc>,
) -> SafeResult<EncryptedQrPayloadEnvelope> {
    if frames.is_empty() {
        return invalid("frame count must be at least 1");
    }
    if frames.len() > usize::from(MAX_QR_FRAMES) {
        return invalid(format!("frame count exceeds maximum of {MAX_QR_FRAMES}"));
    }

    let chunks = frames
        .iter()
        .map(|frame| parse_encrypted_qr_chunk_envelope(frame, now))
        .collect::<SafeResult<Vec<_>>>()?;
    let expected_count = chunks[0].frame_count;
    if usize::from(expected_count) != chunks.len() {
        return invalid("frame count does not match number of supplied chunks");
    }

    for (expected_index, chunk) in chunks.iter().enumerate() {
        if chunk.frame_count != expected_count {
            return invalid("frame count mismatch across encrypted QR chunks");
        }
        if usize::from(chunk.frame_index) != expected_index {
            return invalid("frame order mismatch across encrypted QR chunks");
        }
        if chunk.session_id != chunks[0].session_id {
            return invalid("session mismatch across encrypted QR chunks");
        }
        if chunk.profile != chunks[0].profile {
            return invalid("profile mismatch across encrypted QR chunks");
        }
        if chunk.nonce != chunks[0].nonce {
            return invalid("nonce mismatch across encrypted QR chunks");
        }
        if chunk.payload_digest != chunks[0].payload_digest {
            return invalid("payload digest mismatch across encrypted QR chunks");
        }
    }

    let payload_json = chunks
        .iter()
        .map(|chunk| chunk.chunk.as_str())
        .collect::<String>();
    let actual_digest = integrity_digest(payload_json.as_bytes());
    if actual_digest != chunks[0].payload_digest {
        return invalid("payload digest mismatch for encrypted QR chunks");
    }

    parse_encrypted_qr_payload_envelope(payload_json.as_bytes(), now)
}

#[derive(Debug, Clone, Deserialize)]
struct RawRepoRef {
    owner: Option<String>,
    name: Option<String>,
    #[serde(rename = "ref")]
    reference: Option<RawGitRef>,
}

#[derive(Debug, Clone, Deserialize)]
struct RawGitRef {
    name: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct RawBinding {
    profile: Option<String>,
    repo: Option<RawRepoRef>,
    team_keys_path: Option<String>,
    vault_path: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawVaultIdentity {
    vault_id: Option<String>,
    vault_fingerprint: Option<String>,
    profile: Option<String>,
    origin_device_id: Option<String>,
    origin_platform: Option<String>,
    label: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct RawPaths {
    team_keys: Option<String>,
    vault_ciphertext: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawRepoBinding {
    owner: Option<String>,
    name: Option<String>,
    profile: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawPublicKey {
    value: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawEnrollmentPublicKey {
    public_key: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawDesktopPublicIdentity {
    display_name: Option<String>,
    enrollment_public_key: Option<RawEnrollmentPublicKey>,
}

#[derive(Debug, Deserialize)]
struct RawDesktop {
    display_name: Option<String>,
    public_key: Option<RawPublicKey>,
}

#[derive(Debug, Deserialize)]
struct RawMobileIdentity {
    public_age_recipient: Option<String>,
    device_display_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawMobilePublicRecipient {
    recipient: Option<String>,
    label: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawDevice {
    display_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawNonceResponse {
    enrollment_nonce: Option<String>,
    mobile_challenge: Option<String>,
    value: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawRecipient {
    public_age_recipient: Option<String>,
    key_id: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawFrame {
    count: Option<u16>,
    index: Option<u16>,
    frame_count: Option<u16>,
    frame_index: Option<u16>,
    chunk_count: Option<u16>,
    chunk_index: Option<u16>,
    payload_digest_sha256: Option<String>,
}

impl RawFrame {
    fn count(&self) -> Option<u16> {
        self.chunk_count.or(self.frame_count).or(self.count)
    }

    fn index(&self) -> Option<u16> {
        self.chunk_index.or(self.frame_index).or(self.index)
    }
}

#[derive(Debug, Deserialize)]
struct RawDigest {
    algorithm: Option<String>,
    value: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawCiphertextPayload {
    ciphertext: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawRecipientEphemeralKey {
    key_id: Option<String>,
    algorithm: Option<String>,
    public_key: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawSupportedAlgorithms {
    kem: Option<Vec<String>>,
    aead: Option<Vec<String>>,
    digest: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
struct RawQrReceiveEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    challenge: Option<String>,
    recipient_ephemeral_key: Option<RawRecipientEphemeralKey>,
    supported_algorithms: Option<RawSupportedAlgorithms>,
    frame: Option<RawFrame>,
    digest: Option<RawDigest>,
}

#[derive(Debug, Deserialize)]
struct RawQrShareEncryption {
    kem: Option<String>,
    aead: Option<String>,
    aead_nonce: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawQrShareSecretMetadata {
    key_name_suggestion: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawQrSharePayloadEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    recipient_key_id: Option<String>,
    challenge: Option<String>,
    encryption: Option<RawQrShareEncryption>,
    ciphertext: Option<String>,
    secret_metadata: Option<RawQrShareSecretMetadata>,
    frame: Option<RawFrame>,
    digest: Option<RawDigest>,
}

#[derive(Debug, Deserialize)]
struct RawEnrollmentStartEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    desktop_public_key: Option<String>,
    desktop: Option<RawDesktop>,
    desktop_public_identity: Option<RawDesktopPublicIdentity>,
    nonce: Option<String>,
    repo: Option<RawRepoRef>,
    binding: Option<RawBinding>,
    paths: Option<RawPaths>,
    branch: Option<String>,
    profile: Option<String>,
    team_keys_path: Option<String>,
    vault_path: Option<String>,
    vault_identity: Option<RawVaultIdentity>,
    requested_capabilities: Option<Vec<String>>,
    capabilities: Option<Vec<String>>,
    display_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawEnrollmentResponseEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    status: Option<String>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    nonce: Option<String>,
    request_nonce: Option<String>,
    nonce_response: Option<RawNonceResponse>,
    repo: Option<RawRepoRef>,
    binding: Option<RawBinding>,
    repo_binding: Option<RawRepoBinding>,
    profile: Option<String>,
    mobile_recipient: Option<String>,
    mobile_identity: Option<RawMobileIdentity>,
    mobile_public_recipient: Option<RawMobilePublicRecipient>,
    vault_identity: Option<RawVaultIdentity>,
    device: Option<RawDevice>,
    device_label: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawDesktopVaultProfileEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    nonce: Option<String>,
    repo: Option<RawRepoRef>,
    binding: Option<RawBinding>,
    paths: Option<RawPaths>,
    branch: Option<String>,
    profile: Option<String>,
    team_keys_path: Option<String>,
    vault_path: Option<String>,
    desktop_platform: Option<String>,
    vault_id: Option<String>,
    vault_fingerprint: Option<String>,
    desktop_public_key: Option<String>,
    requested_capabilities: Option<Vec<String>>,
    capabilities: Option<Vec<String>>,
    display_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawMobileProfileReturnEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    nonce: Option<String>,
    repo: Option<RawRepoRef>,
    binding: Option<RawBinding>,
    profile: Option<String>,
    vault_id: Option<String>,
    mobile_profile_id: Option<String>,
    mobile_recipient: Option<String>,
    device_label: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawPublicQrReceiveEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    profile: Option<String>,
    nonce: Option<String>,
    challenge_nonce: Option<String>,
    binding: Option<RawBinding>,
    recipient: Option<RawRecipient>,
    public_key_id: Option<String>,
    recipient_public_key: Option<String>,
    requested_capabilities: Option<Vec<String>>,
    capabilities: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
struct RawEncryptedQrPayloadEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    profile: Option<String>,
    nonce: Option<String>,
    recipient_public_key_id: Option<String>,
    sender_protocol: Option<String>,
    aead_nonce: Option<String>,
    ciphertext: Option<String>,
    digest: Option<String>,
    frame_count: Option<u16>,
    frame_index: Option<u16>,
    key_name_suggestion: Option<String>,
    metadata: Option<serde_json::Map<String, Value>>,
}

#[derive(Debug, Deserialize)]
struct RawEncryptedQrChunkEnvelope {
    #[serde(alias = "_schema")]
    schema: Option<String>,
    version: Option<Value>,
    flow_id: Option<String>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    profile: Option<String>,
    nonce: Option<String>,
    challenge_nonce: Option<String>,
    binding: Option<RawBinding>,
    frame: Option<RawFrame>,
    frame_count: Option<u16>,
    frame_index: Option<u16>,
    payload_digest: Option<String>,
    digest: Option<RawDigest>,
    payload: Option<RawCiphertextPayload>,
    chunk: Option<String>,
}

struct CommonFields {
    schema: String,
    version: String,
    session_id: String,
    created_at: DateTime<Utc>,
    expires_at: DateTime<Utc>,
    profile: String,
    nonce: String,
}

struct TemporalCommonFields {
    schema: String,
    version: String,
    session_id: String,
    created_at: DateTime<Utc>,
    expires_at: DateTime<Utc>,
}

#[allow(clippy::too_many_arguments)]
fn validate_common(
    schema: Option<String>,
    version: Option<Value>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    profile: Option<String>,
    nonce: Option<String>,
    expected_schemas: &[&str],
    now: DateTime<Utc>,
) -> SafeResult<CommonFields> {
    let schema = require_non_empty(schema, "schema")?;
    validate_schema(&schema, expected_schemas)?;
    let version = normalize_version(version)?;
    validate_major_version(&version)?;
    let session_id = require_non_empty(session_id, "session_id")?;
    let created_at = created_at.ok_or_else(|| invalid_error("missing created_at"))?;
    let expires_at = expires_at.ok_or_else(|| invalid_error("missing expires_at"))?;
    if expires_at <= now {
        return invalid("expired envelope");
    }
    if expires_at <= created_at {
        return invalid("expires_at must be after created_at");
    }
    let profile = require_non_empty(profile, "profile")?;
    let nonce = require_non_empty(nonce, "nonce")?;

    Ok(CommonFields {
        schema,
        version,
        session_id,
        created_at,
        expires_at,
        profile,
        nonce,
    })
}

fn validate_temporal_common(
    schema: Option<String>,
    version: Option<Value>,
    session_id: Option<String>,
    created_at: Option<DateTime<Utc>>,
    expires_at: Option<DateTime<Utc>>,
    expected_schemas: &[&str],
    now: DateTime<Utc>,
) -> SafeResult<TemporalCommonFields> {
    let schema = require_non_empty(schema, "schema")?;
    validate_schema(&schema, expected_schemas)?;
    let version = normalize_version(version)?;
    validate_major_version(&version)?;
    let session_id = require_non_empty(session_id, "session_id")?;
    let created_at = created_at.ok_or_else(|| invalid_error("missing created_at"))?;
    let expires_at = expires_at.ok_or_else(|| invalid_error("missing expires_at"))?;
    if expires_at <= now {
        return invalid("expired envelope");
    }
    if expires_at <= created_at {
        return invalid("expires_at must be after created_at");
    }

    Ok(TemporalCommonFields {
        schema,
        version,
        session_id,
        created_at,
        expires_at,
    })
}

fn validate_schema(schema: &str, expected_schemas: &[&str]) -> SafeResult<()> {
    if schema_major(schema).is_some_and(|major| major != SUPPORTED_MAJOR_VERSION) {
        return invalid(format!(
            "unsupported major version {schema}; expected v{SUPPORTED_MAJOR_VERSION}"
        ));
    }
    if !expected_schemas.contains(&schema) {
        return invalid(format!(
            "invalid schema {schema:?}; expected one of {expected_schemas:?}"
        ));
    }
    Ok(())
}

fn normalize_version(version: Option<Value>) -> SafeResult<String> {
    match version.ok_or_else(|| invalid_error("missing version"))? {
        Value::String(version) => require_non_empty(Some(version), "version"),
        Value::Number(version) if version.is_u64() => Ok(version.to_string()),
        Value::Number(version) => invalid(format!("invalid version {version}")),
        Value::Object(version) => {
            let major = version
                .get("major")
                .and_then(Value::as_u64)
                .ok_or_else(|| invalid_error("version.major must be an unsigned integer"))?;
            let minor = version.get("minor").and_then(Value::as_u64).unwrap_or(0);
            Ok(format!("{major}.{minor}"))
        }
        _ => invalid("version must be a string or unsigned integer"),
    }
}

fn validate_major_version(version: &str) -> SafeResult<()> {
    let major = version
        .split_once('.')
        .map_or(version, |(major, _)| major)
        .parse::<u16>()
        .map_err(|_| invalid_error(format!("invalid version {version:?}")))?;
    if major != SUPPORTED_MAJOR_VERSION {
        return invalid(format!(
            "unsupported major version {major}; expected {SUPPORTED_MAJOR_VERSION}"
        ));
    }
    Ok(())
}

fn schema_major(schema: &str) -> Option<u16> {
    schema
        .rsplit_once("/v")
        .and_then(|(_, major)| major.parse::<u16>().ok())
}

fn validate_repo(repo: Option<RawRepoRef>) -> SafeResult<MobileRepoRef> {
    let repo = repo.ok_or_else(|| invalid_error("missing repo"))?;
    Ok(MobileRepoRef {
        owner: require_non_empty(repo.owner, "repo.owner")?,
        name: require_non_empty(repo.name, "repo.name")?,
    })
}

fn validate_desktop_platform(platform: String) -> SafeResult<String> {
    let normalized = platform.to_ascii_lowercase();
    if !matches!(normalized.as_str(), "macos" | "windows" | "linux") {
        return invalid("desktop_platform must be macos, windows, or linux");
    }
    Ok(normalized)
}

fn validate_profile_id(value: String, field: &str) -> SafeResult<String> {
    if value
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
    {
        Ok(value)
    } else {
        invalid(format!(
            "{field} must contain only ascii letters, digits, dash, underscore, or dot"
        ))
    }
}

fn validate_digest_label(value: &str, field: &str) -> SafeResult<()> {
    let digest = value
        .strip_prefix("sha256:")
        .ok_or_else(|| invalid_error(format!("{field} must be a sha256 digest label")))?;
    if digest.len() == 64 && digest.chars().all(|ch| ch.is_ascii_hexdigit()) {
        Ok(())
    } else {
        invalid(format!("{field} must be a sha256 digest label"))
    }
}

fn validate_vault_identity(
    identity: Option<RawVaultIdentity>,
    envelope_profile: &str,
) -> SafeResult<Option<MobileVaultIdentity>> {
    let Some(identity) = identity else {
        return Ok(None);
    };
    let vault_id = require_non_empty(identity.vault_id, "vault_identity.vault_id")?;
    let vault_fingerprint = require_non_empty(
        identity.vault_fingerprint,
        "vault_identity.vault_fingerprint",
    )?;
    validate_digest(&vault_fingerprint, "vault_identity.vault_fingerprint")?;
    let profile =
        normalize_optional(identity.profile).unwrap_or_else(|| envelope_profile.to_string());
    if profile != envelope_profile {
        return invalid("vault_identity.profile must match envelope profile");
    }
    let origin_device_id =
        require_non_empty(identity.origin_device_id, "vault_identity.origin_device_id")?;
    let origin_platform =
        require_non_empty(identity.origin_platform, "vault_identity.origin_platform")?;
    validate_origin_platform(&origin_platform)?;

    Ok(Some(MobileVaultIdentity {
        vault_id,
        vault_fingerprint,
        profile,
        origin_device_id,
        origin_platform,
        label: normalize_optional(identity.label),
    }))
}

fn validate_origin_platform(platform: &str) -> SafeResult<()> {
    match platform {
        "windows" | "macos" | "linux" | "ios" | "android" => Ok(()),
        _ => {
            invalid("vault_identity.origin_platform must be windows, macos, linux, ios, or android")
        }
    }
}

fn repo_from_binding(repo: Option<RawRepoRef>, binding: Option<&RawBinding>) -> Option<RawRepoRef> {
    repo.or_else(|| binding.and_then(|binding| binding.repo.clone()))
}

fn branch_from_binding(binding: Option<&RawBinding>) -> Option<String> {
    binding
        .and_then(|binding| binding.repo.as_ref())
        .and_then(|repo| repo.reference.as_ref())
        .and_then(|reference| reference.name.clone())
}

fn branch_from_repo(repo: Option<&RawRepoRef>) -> Option<String> {
    repo.and_then(|repo| repo.reference.as_ref())
        .and_then(|reference| reference.name.clone())
}

fn validate_age_public_recipient(recipient: &str, field: &str) -> SafeResult<()> {
    if age_crypto::parse_recipient(recipient).is_ok() || is_contract_age_recipient(recipient) {
        return Ok(());
    }

    invalid(format!("{field} must be a valid age public recipient"))
}

fn is_contract_age_recipient(value: &str) -> bool {
    const BECH32_CHARS: &str = "023456789acdefghjklmnpqrstuvwxyz";

    let Some(payload) = value.strip_prefix("age1") else {
        return false;
    };
    (50..=120).contains(&payload.len()) && payload.chars().all(|ch| BECH32_CHARS.contains(ch))
}

fn validate_frame_bounds(frame_count: u16, frame_index: u16) -> SafeResult<()> {
    if frame_count == 0 {
        return invalid("frame count must be at least 1");
    }
    if frame_count > MAX_QR_FRAMES {
        return invalid(format!("frame count exceeds maximum of {MAX_QR_FRAMES}"));
    }
    if frame_index >= frame_count {
        return invalid("frame index must be less than frame count");
    }
    Ok(())
}

fn validate_required_frame(frame: Option<&RawFrame>) -> SafeResult<(u16, u16)> {
    let frame = frame.ok_or_else(|| invalid_error("missing frame"))?;
    let frame_count = frame
        .count()
        .ok_or_else(|| invalid_error("missing frame count"))?;
    let frame_index = frame
        .index()
        .ok_or_else(|| invalid_error("missing frame index"))?;
    validate_frame_bounds(frame_count, frame_index)?;
    Ok((frame_count, frame_index))
}

fn validate_base64url_non_empty(value: &str, field: &str) -> SafeResult<Vec<u8>> {
    let decoded = crypto::decode_b64(value)
        .map_err(|_| invalid_error(format!("{field} must be valid base64url without padding")))?;
    if decoded.is_empty() {
        return invalid(format!("{field} must not decode to empty bytes"));
    }
    Ok(decoded)
}

fn validate_digest(value: &str, field: &str) -> SafeResult<()> {
    if !run_evidence::is_blake3_hash(value) && !is_sha256_digest(value) {
        return invalid(format!(
            "{field} must be blake3:<64 hex chars> or sha256:<64 hex chars>"
        ));
    }
    Ok(())
}

fn digest_name(digest: Option<&RawDigest>) -> Option<String> {
    let digest = digest?;
    Some(format!(
        "{}:{}",
        digest.algorithm.as_ref()?,
        digest.value.as_ref()?
    ))
}

fn validate_required_digest(digest: Option<&RawDigest>, field: &str) -> SafeResult<String> {
    let digest = digest.ok_or_else(|| invalid_error(format!("missing {field}")))?;
    let algorithm = require_non_empty(digest.algorithm.clone(), &format!("{field}.algorithm"))?;
    if algorithm != "sha256" {
        return invalid(format!("{field}.algorithm must be sha256"));
    }
    let value = require_non_empty(digest.value.clone(), &format!("{field}.value"))?;
    validate_hex_digest(&value, &format!("{field}.value"))?;
    Ok(format!("sha256:{value}"))
}

fn validate_hex_digest(value: &str, field: &str) -> SafeResult<()> {
    if value.len() != 64 || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
        return invalid(format!("{field} must be 64 hex chars"));
    }
    Ok(())
}

fn is_sha256_digest(value: &str) -> bool {
    value
        .strip_prefix("sha256:")
        .is_some_and(|digest| digest.len() == 64 && digest.chars().all(|ch| ch.is_ascii_hexdigit()))
}

fn reject_secret_like_material(value: &Value) -> SafeResult<()> {
    if let Some(path) = secret_like_path(value, "$") {
        return invalid(format!("secret-like material in public envelope at {path}"));
    }
    Ok(())
}

fn secret_like_path(value: &Value, path: &str) -> Option<String> {
    match value {
        Value::String(text) if is_secret_like(text) => Some(path.to_string()),
        Value::Array(items) => items
            .iter()
            .enumerate()
            .find_map(|(index, item)| secret_like_path(item, &format!("{path}[{index}]"))),
        Value::Object(map) => map.iter().find_map(|(key, value)| {
            let child_path = format!("{path}.{key}");
            if is_secret_like_field_name(key) {
                Some(child_path)
            } else {
                secret_like_path(value, &child_path)
            }
        }),
        _ => None,
    }
}

fn is_secret_like_field_name(key: &str) -> bool {
    matches!(
        key.to_ascii_lowercase().as_str(),
        "secret"
            | "plaintext"
            | "password"
            | "token"
            | "github_token"
            | "totp_seed"
            | "totp_code"
            | "age_private_identity"
            | "private_key"
    )
}

fn is_secret_like(text: &str) -> bool {
    let lower = text.to_ascii_lowercase();
    [
        "age-secret-key-",
        "-----begin private key-----",
        "-----begin openssh private key-----",
        "github_pat_",
        "ghp_",
        "gho_",
        "ghu_",
        "ghs_",
        "ghr_",
        "otpauth://",
        "tsafe_agent_token",
        "agent session token",
        "desktop agent session",
    ]
    .iter()
    .any(|pattern| lower.contains(pattern))
}

fn validate_supported_algorithms(algorithms: Option<&RawSupportedAlgorithms>) -> SafeResult<()> {
    let algorithms = algorithms.ok_or_else(|| invalid_error("missing supported_algorithms"))?;
    if !algorithms
        .kem
        .as_ref()
        .is_some_and(|items| items.iter().any(|item| item == "x25519-hkdf-sha256"))
    {
        return invalid("supported_algorithms.kem must include x25519-hkdf-sha256");
    }
    if !algorithms.aead.as_ref().is_some_and(|items| {
        items
            .iter()
            .any(|item| item == "xchacha20-poly1305" || item == "aes-256-gcm")
    }) {
        return invalid("supported_algorithms.aead must include a supported AEAD");
    }
    if !algorithms
        .digest
        .as_ref()
        .is_some_and(|items| items.iter().any(|item| item == "sha256"))
    {
        return invalid("supported_algorithms.digest must include sha256");
    }
    Ok(())
}

fn parse_value(bytes: &[u8], label: &str) -> SafeResult<Value> {
    parse_json(bytes, label)
}

fn parse_json<T: DeserializeOwned>(bytes: &[u8], label: &str) -> SafeResult<T> {
    serde_json::from_slice(bytes)
        .map_err(|err| invalid_error(format!("{label} JSON parse error: {err}")))
}

fn parse_from_value<T: DeserializeOwned>(value: Value, label: &str) -> SafeResult<T> {
    serde_json::from_value(value)
        .map_err(|err| invalid_error(format!("{label} JSON parse error: {err}")))
}

fn require_non_empty(value: Option<String>, field: &str) -> SafeResult<String> {
    let value = value.ok_or_else(|| invalid_error(format!("missing {field}")))?;
    if value.trim().is_empty() {
        return invalid(format!("{field} must not be empty"));
    }
    Ok(value)
}

fn normalize_optional(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        if value.trim().is_empty() {
            None
        } else {
            Some(value)
        }
    })
}

fn integrity_digest(bytes: &[u8]) -> String {
    run_evidence::blake3_hash(bytes)
}

fn invalid<T>(reason: impl Into<String>) -> SafeResult<T> {
    Err(invalid_error(reason))
}

fn invalid_error(reason: impl Into<String>) -> SafeError {
    SafeError::InvalidVault {
        reason: reason.into(),
    }
}
