//! bevy-dlc — DLC gating utilities for Bevy 0.18
//!
//! Preload encrypted assets and unlock them using offline-signed (Ed25519)
//! tokens. See `DlcKey` and `DlcPack` for the runtime API and examples.

use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};

use secure_gate::{ExposeSecret, dynamic_alias, fixed_alias};

mod asset_loader;
mod encrypt_key_registry;
mod ext;

// convenience helpers shipped as macros
mod macros;

// the macros themselves are `#[macro_export]` so they are available at the
// crate root (`bevy_dlc::pack_items!()` etc.) and do not need an explicit
// `pub use` here.  keep the module around so it gets built.

use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
pub use asset_loader::{DlcLoader, DlcPack, DlcPackLoader, EncryptedAsset, parse_encrypted};

// ring is used directly for Ed25519 operations — no external signer trait.
use serde::{Deserialize, Serialize};

use thiserror::Error;

use crate::asset_loader::DlcPackLoaded;

// expose `AppExt` at the crate root so downstream code (examples/tests/macros)
// can refer to the trait without touching the private `ext` module.
pub use crate::ext::AppExt;

pub mod prelude {
    pub use crate::ext::*;
    pub use crate::{
        // Core
        DlcPlugin, DlcId, DlcKey, DlcPack, Product, SignedLicense, asset_loader::DlcPackEntry, PackItem,

        // Asset handling
        DlcLoader, DlcPackLoader, EncryptedAsset, VerifiedLicense,
        
        // Utility functions and conditions
        is_dlc_loaded, is_dlc_entry_loaded,

        // Events
        asset_loader::DlcPackLoaded,
        // Error
        DlcError,
    };
}

/// Bevy plugin that enables DLC!
///
/// About `SignedLicense`:
/// - `SignedLicense` is a zeroized `String` containing a compact
///   offline-signed token (format: `payload_base64url.signature_base64url`).
/// - Tokens are produced by `DlcKey::create_signed_license` (private key)
///   or by platform tooling. When generated by a private `DlcKey` a token
///   may include an embedded `encrypt_key` used to decrypt DLC assets at
///   runtime — that symmetric key is not exposed by `VerifiedLicense`.
/// - Treat `SignedLicense` as sensitive: provision it securely (platform,
///   secure backend, or out-of-band) and do not leak raw secrets in logs.
///
/// Usage: provide the game with the public `DlcKey` and hand the signed
/// license token (for example, from your platform or the CLI pack output)
/// to the plugin; the plugin will extract and register encryption keys from it.
pub struct DlcPlugin {
    // keep the provided key (public or private) so `build` can insert it into
    // app resources; field is private to avoid exposing internals.
    dlc_key: DlcKey,
    // Signed license token provided at construction; applied during `build`
    // to extract and register the encryption key.
    signed_license: SignedLicense,
}

impl DlcPlugin {
    /// Create the plugin from a `DlcKey` and a `SignedLicense`.
    ///
    /// The plugin will extract the encryption key from the signed license
    /// during `build` and register it in the global key registry.
    pub fn new(dlc_key: DlcKey, signed_license: SignedLicense) -> Self {
        Self {
            dlc_key,
            signed_license,
        }
    }
}

impl Plugin for DlcPlugin {
    fn build(&self, app: &mut App) {
        // Extract and register the encryption key from the signed license
        if let Some(encrypt_key) = extract_encrypt_key_from_license(&self.signed_license) {
            let dlcs = extract_dlc_ids_from_license(&self.signed_license);
            for dlc_id in dlcs {
                // Register the encryption key for each DLC ID
                let key_for_dlc = encrypt_key.with_secret(|kb| EncryptionKey::from(kb.to_vec()));
                encrypt_key_registry::insert(&dlc_id, key_for_dlc);
            }
        }

        app.init_resource::<asset_loader::DlcPackRegistrarFactories>();

        // Insert provided key + manager so other systems/resources can access them
        app.insert_resource(self.dlc_key.clone())
            .init_asset_loader::<asset_loader::DlcLoader<Image>>()
            .init_asset_loader::<asset_loader::DlcLoader<Scene>>()
            .init_asset_loader::<asset_loader::DlcLoader<bevy::mesh::Mesh>>()
            .init_asset_loader::<asset_loader::DlcLoader<Font>>()
            .init_asset_loader::<asset_loader::DlcLoader<AudioSource>>()
            .init_asset_loader::<asset_loader::DlcLoader<ColorMaterial>>()
            .init_asset_loader::<asset_loader::DlcLoader<bevy::pbr::StandardMaterial>>()
            .init_asset_loader::<asset_loader::DlcLoader<bevy::gltf::Gltf>>()
            .init_asset_loader::<asset_loader::DlcLoader<bevy::gltf::GltfMesh>>()
            .init_asset_loader::<asset_loader::DlcLoader<Shader>>()
            .init_asset_loader::<asset_loader::DlcLoader<DynamicScene>>()
            .init_asset_loader::<asset_loader::DlcLoader<AnimationClip>>()
            .init_asset_loader::<asset_loader::DlcLoader<AnimationGraph>>();

        // Build `DlcPackLoader`
        let factories = app
            .world()
            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
            .cloned();
        let pack_loader = asset_loader::DlcPackLoader {
            registrars: asset_loader::collect_pack_registrars(factories.as_ref()),
            factories,
        };

        app.register_asset_loader(pack_loader);
        app.init_asset::<asset_loader::DlcPack>();

        // Trigger events for DLC packs and entries when they are added to Assets
        app.add_systems(Update, trigger_dlc_events);
    }
}

/// System that monitors `AssetEvent<DlcPack>` and triggers observer-friendly events.
fn trigger_dlc_events(
    mut events: MessageReader<AssetEvent<DlcPack>>,
    packs: Res<Assets<DlcPack>>,
    mut commands: Commands,
) {
    for event in events.read() {
        match event {
            AssetEvent::Added { id } => {
                if let Some(pack) = packs.get(*id) {
                    let dlc_id = pack.id().clone();
                    commands.trigger(DlcPackLoaded::new(
                        dlc_id.clone(),
                        pack.clone(),
                    ));
                }
            }
            _ => {}
        }
    }
}

/// A Bevy system condition that returns `true` when a DLC pack has been loaded.
///
/// A DLC is considered loaded when `DlcPackLoader` has successfully loaded a `.dlcpack` file
/// containing that `DlcId`.
///
/// This is useful for gating systems that should only run when specific DLC content is actually
/// available.
///
/// # Example
/// ```rust
/// use bevy::prelude::*;
/// use bevy_dlc::is_dlc_loaded;
///
/// fn spawn_dlc_content() {}
///
/// let mut app = App::new();
/// app.add_systems(Update, spawn_dlc_content.run_if(is_dlc_loaded("dlcA")));
/// ```
pub fn is_dlc_loaded(dlc_id: impl Into<DlcId>) -> impl Fn() -> bool + Send + Sync + 'static {
    let id_string = dlc_id.into().0;
    move || !encrypt_key_registry::asset_path_for(&id_string).is_empty()
}

/// A Bevy system condition that returns `true` when a specific DLC pack entry is loaded.
pub fn is_dlc_entry_loaded(
    dlc_id: impl Into<DlcId>,
    entry: impl Into<String>,
) -> impl Fn(Res<Assets<DlcPack>>) -> bool + Send + Sync + 'static {
    let id_string = dlc_id.into().0;
    let entry_name = entry.into();
    move |dlc_packs: Res<Assets<DlcPack>>| {
        if !encrypt_key_registry::asset_path_for(&id_string).is_empty()
        {
            dlc_packs
                .iter()
                .filter(|p| p.1.id() == &DlcId::from(id_string.clone()))
                .any(|pack| pack.1.find_entry(&entry_name).is_some())
        } else {
            false
        }
    }
}

/// Strongly-typed DLC identifier (string-backed).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct DlcId(pub String);

impl From<&str> for DlcId {
    fn from(s: &str) -> Self {
        DlcId(s.to_owned())
    }
}

impl From<String> for DlcId {
    fn from(s: String) -> Self {
        DlcId(s)
    }
}

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

// secure-gate aliases for secrets used with `DlcKey`
fixed_alias!(pub PrivateKey, 32, "A secure wrapper for a 32-byte Ed25519 signing seed (private key) used to create signed licenses. This should be protected and never exposed in logs or error messages.");

/// PublicKey wrapper (32 bytes)
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct PublicKey([u8; 32]);

impl PublicKey {
    pub fn get(&self) -> &[u8; 32] {
        &self.0
    }
}

impl From<[u8; 32]> for PublicKey {
    fn from(v: [u8; 32]) -> Self {
        PublicKey(v)
    }
}

impl std::fmt::Debug for PublicKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PublicKey({} bytes)", 32)
    }
}

// `SignedLicense` — secure, heap-backed string wrapper (secure-gate)
dynamic_alias!(pub SignedLicense, String, "A compact offline-signed token containing DLC ids and an optional embedded encrypt key. Treat as sensitive and do not leak raw secrets in logs.  This ");

// `ExposeSecret` methods are provided by the alias; keeps the same public API surface.

/// Extract the embedded encryption key from a signed license's payload (base64url-encoded).
/// Returns `None` if the token is malformed or contains no encrypt_key.
pub fn extract_encrypt_key_from_license(license: &SignedLicense) -> Option<EncryptionKey> {
    license.with_secret(|token_str| {
        let parts: Vec<&str> = token_str.split('.').collect();
        if parts.len() != 2 {
            return None;
        }
        let payload = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()).ok()?;
        let payload_json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
        let key_b64 = payload_json.get("encrypt_key").and_then(|v| v.as_str())?;
        URL_SAFE_NO_PAD
            .decode(key_b64.as_bytes())
            .ok()
            .map(EncryptionKey::from)
    })
}

/// Extract the DLC IDs from a signed license's payload.
/// Returns an empty vec if the token is malformed or contains no dlcs array.
pub fn extract_dlc_ids_from_license(license: &SignedLicense) -> Vec<String> {
    license.with_secret(|token_str| {
        let parts: Vec<&str> = token_str.split('.').collect();
        if parts.len() != 2 {
            return Vec::new();
        }
        if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
            if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
                if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
                    let mut dlcs = Vec::new();
                    for dlc in dlcs_array {
                        if let Some(dlc_id) = dlc.as_str() {
                            dlcs.push(dlc_id.to_string());
                        }
                    }
                    return dlcs;
                }
            }
        }
        Vec::new()
    })
}

/// Product: non-secret identifier wrapper (keeps same API surface)
#[derive(Resource, Clone, PartialEq, Eq, Debug)]
pub struct Product(String);

impl Product {
    pub fn get(&self) -> &String {
        &self.0
    }
}

impl From<String> for Product {
    fn from(s: String) -> Self {
        Product(s)
    }
}
impl From<&str> for Product {
    fn from(s: &str) -> Self {
        Product(s.to_owned())
    }
}

// `ContentKey`: secure heap-backed secret for symmetric keys
// use secure-gate's `Dynamic<Vec<u8>>` API (methods are provided by the alias)
dynamic_alias!(pub EncryptionKey, Vec<u8>, "A secure encrypt key (symmetric key for encrypting DLC pack entries). This should be protected and never exposed in logs or error messages.");

/// Client-side wrapper for Ed25519 key operations: verify tokens and (when
/// private) create compact signed tokens.
#[derive(Resource)]
pub enum DlcKey {
    /// Private key (protected seed + public bytes + public key tag)
    Private {
        /// Protected signing seed (secure wrapper)
        privkey: PrivateKey,
        /// Public key bytes (wrapped)
        pubkey: PublicKey,
    },

    /// Public-only key (public bytes)
    Public {
        /// Public key bytes (wrapped)
        pubkey: PublicKey,
    },
}

impl DlcKey {
    pub fn new(pubkey: &str, privkey: &str) -> Result<Self, DlcError> {
        let decoded_pub = URL_SAFE_NO_PAD
            .decode(pubkey.as_bytes())
            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
        if decoded_pub.len() != 32 {
            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
        }
        let mut pub_bytes = [0u8; 32];
        pub_bytes.copy_from_slice(&decoded_pub);

        let decoded_priv = URL_SAFE_NO_PAD
            .decode(privkey.as_bytes())
            .map_err(|e| DlcError::CryptoError(format!("invalid privkey base64: {}", e)))?;
        if decoded_priv.len() != 32 {
            return Err(DlcError::CryptoError("private key must be 32 bytes".into()));
        }
        let mut priv_bytes = [0u8; 32];
        priv_bytes.copy_from_slice(&decoded_priv);

        Self::from_priv_and_pub(PrivateKey::from(priv_bytes), PublicKey::from(pub_bytes))
    }

    /// Construct a `DlcKey::Public` from a base64url-encoded public key string.
    pub fn public(pubkey: &str) -> Result<Self, DlcError> {
        let decoded_pub = URL_SAFE_NO_PAD
            .decode(pubkey.as_bytes())
            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
        if decoded_pub.len() != 32 {
            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
        }
        let mut pub_bytes = [0u8; 32];
        pub_bytes.copy_from_slice(&decoded_pub);

        Ok(DlcKey::Public {
            pubkey: PublicKey::from(pub_bytes),
        })
    }

    /// Construct a `DlcKey::Private` from a private key and public key.
    pub(crate) fn from_priv_and_pub(
        privkey: PrivateKey,
        publickey: PublicKey,
    ) -> Result<Self, DlcError> {
        // derive public bytes from the protected seed and validate via
        // `from_seed_and_public_key` so we never sign using only the seed
        let kp = privkey
            .with_secret(|priv_bytes| {
                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, publickey.get())
            })
            .map_err(|e| DlcError::CryptoError(format!("invalid seed: {:?}", e)))?;
        let mut pub_bytes = [0u8; 32];
        pub_bytes.copy_from_slice(kp.public_key().as_ref());

        // validate construction using both seed + public (preferred API)
        privkey
            .with_secret(|priv_bytes| {
                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, &pub_bytes)
            })
            .map_err(|e| DlcError::CryptoError(format!("keypair validation failed: {:?}", e)))?;

        Ok(DlcKey::Private {
            privkey,
            pubkey: PublicKey::from(pub_bytes),
        })
    }

    /// Generate a new `DlcKey::Private` with a random seed and derived public key.
    ///
    /// The public key is derived from the generated seed so the keypair is valid.
    pub fn generate_random() -> Self {
        // generate a random seed and derive the matching public key from it
        let privkey: PrivateKey = PrivateKey::from_random();
        // derive public bytes from the seed using ring
        let pair = privkey
            .with_secret(|priv_bytes| Ed25519KeyPair::from_seed_unchecked(priv_bytes))
            .expect("derive public key from seed");
        let mut pub_bytes = [0u8; 32];
        pub_bytes.copy_from_slice(pair.public_key().as_ref());

        Self::from_priv_and_pub(privkey, PublicKey::from(pub_bytes))
            .unwrap_or_else(|e| panic!("generate_complete failed: {:?}", e))
    }

    pub fn get_public_key(&self) -> &PublicKey {
        match self {
            DlcKey::Private { pubkey, .. } => pubkey,
            DlcKey::Public { pubkey: public } => public,
        }
    }

    /// Create a compact offline-signed token that can be verified by this key's public key.
    ///
    /// Returns a `SignedLicense` (zeroized on drop). The license payload includes
    /// the provided DLC ids and product binding.
    /// Create a compact offline-signed token (SignedLicense).
    pub fn create_signed_license<D>(
        &self,
        dlcs: impl IntoIterator<Item = D>,
        product: Product,
    ) -> Result<SignedLicense, DlcError>
    where
        D: std::fmt::Display,
    {
        let mut payload = serde_json::Map::new();
        payload.insert(
            "dlcs".to_string(),
            serde_json::Value::Array(
                dlcs.into_iter()
                    .map(|s| serde_json::Value::String(s.to_string()))
                    .collect(),
            ),
        );

        // product is required and always embedded
        payload.insert(
            "product".to_string(),
            serde_json::Value::String(product.get().to_string()),
        );

        match self {
            DlcKey::Private { privkey, pubkey } => {
                privkey.with_secret(|encrypt_key_bytes| {
                    // use the private seed itself as the symmetric encrypt key
                    payload.insert(
                        "encrypt_key".to_string(),
                        serde_json::Value::String(URL_SAFE_NO_PAD.encode(encrypt_key_bytes)),
                    );

                    let payload_value = serde_json::Value::Object(payload);
                    let payload_bytes = serde_json::to_vec(&payload_value)
                        .map_err(|e| DlcError::TokenCreationFailed(e.to_string()))?;

                    let pair =
                        Ed25519KeyPair::from_seed_and_public_key(encrypt_key_bytes, pubkey.get())
                            .map_err(|e| DlcError::CryptoError(format!("keypair: {:?}", e)))?;
                    let sig = pair.sign(&payload_bytes);
                    Ok(SignedLicense::from(format!(
                        "{}.{}",
                        URL_SAFE_NO_PAD.encode(&payload_bytes),
                        URL_SAFE_NO_PAD.encode(sig.as_ref())
                    )))
                })
            }
            DlcKey::Public { .. } => Err(DlcError::PrivateKeyRequired),
        }
    }

    /// Extend an existing signed license by merging additional DLC ids while preserving backwards compatibility.
    ///
    /// Extracts the DLC ids from an existing (unsigned or unverified) license payload, merges them
    /// with the provided new DLC ids (with deduplication), and creates a fresh signed license
    /// with the combined list. The product must match this key's current product.
    ///
    /// **Important**: This creates a NEW signed token with a potentially different signature,
    /// but the payload contains all previous DLC ids plus the new ones. Existing DLC packs
    /// remain unlocked by the new license.
    ///
    /// # Example
    /// ```rust
    /// use bevy_dlc::{DlcKey, SignedLicense, Product};
    ///
    /// // in a real program you would obtain a key and product from your build
    /// // process or configuration.  we use simple constructors here so the
    /// // example compiles without panicking.
    /// let dlc_key: DlcKey = DlcKey::generate_random();
    /// let product: Product = Product::from("my_game");
    ///
    /// let old_license = SignedLicense::from("...existing token...");
    /// let _new_license = dlc_key
    ///     .extend_signed_license(&old_license, &["new_expansion"], product)
    ///     .unwrap();
    /// ```
    pub fn extend_signed_license<D>(
        &self,
        existing: &SignedLicense,
        new_dlcs: impl IntoIterator<Item = D>,
        product: Product,
    ) -> Result<SignedLicense, DlcError>
    where
        D: std::fmt::Display,
    {
        // Extract dlc_ids from existing license without verification
        let mut combined_dlcs: Vec<String> = existing.with_secret(|token_str| {
            let parts: Vec<&str> = token_str.split('.').collect();
            if parts.len() != 2 {
                return Vec::new();
            }
            if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
                if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
                    if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
                        let mut dlcs = Vec::new();
                        for dlc in dlcs_array {
                            if let Some(dlc_id) = dlc.as_str() {
                                dlcs.push(dlc_id.to_string());
                            }
                        }
                        return dlcs;
                    }
                }
            }
            Vec::new()
        });

        // Merge in new DLC ids (with deduplication)
        for new_dlc in new_dlcs {
            let dlc_str = new_dlc.to_string();
            if !combined_dlcs.contains(&dlc_str) {
                combined_dlcs.push(dlc_str);
            }
        }

        // Create new signed license with combined dlc_ids
        self.create_signed_license(combined_dlcs, product)
    }

    /// Verify a compact signed-license (signature + payload) using this key's public key
    /// and return a typed `VerifiedLicense`. This only checks signature + parsing.
    pub fn verify_signed_license(
        &self,
        license: &SignedLicense,
    ) -> Result<VerifiedLicense, DlcError> {
        license.with_secret(|full_token| {
            let parts: Vec<&str> = full_token.split('.').collect();
            if parts.len() != 2 {
                return Err(DlcError::MalformedLicense(
                    "expected signed-license with two dot-separated parts".into(),
                ));
            }

            let payload = URL_SAFE_NO_PAD
                .decode(parts[0])
                .map_err(|e| DlcError::MalformedLicense(format!("payload base64: {}", e)))?;
            let sig_bytes = URL_SAFE_NO_PAD
                .decode(parts[1])
                .map_err(|e| DlcError::MalformedLicense(format!("signature base64: {}", e)))?;

            if sig_bytes.len() != 64 {
                return Err(DlcError::MalformedLicense(
                    "signature bytes length must be 64".into(),
                ));
            }

            let public = self.get_public_key().0;
            let public_key = UnparsedPublicKey::new(&ED25519, public);
            public_key
                .verify(&payload, &sig_bytes)
                .map_err(|_| DlcError::SignatureInvalid)?;

            let lic: LicensePayload = serde_json::from_slice(&payload)
                .map_err(|e| DlcError::PayloadInvalid(e.to_string()))?;

            // Ignore any embedded `encrypt_key` in the token here — `verify_signed_license`
            // only validates signature and returns the logical license fields.
            Ok(VerifiedLicense {
                dlcs: lic.dlcs,
                iat: lic.iat,
                nonce: lic.nonce,
                product: lic.product,
            })
        })
    }
}

// Manual Clone impl to allow `DlcKey` to be duplicated while keeping the
// secret data zeroized in the new copy (we cannot rely on secure-gate's
// `Clone` impl for aliases because inner types are not `CloneableSecret`).
impl Clone for DlcKey {
    fn clone(&self) -> Self {
        match self {
            DlcKey::Private { privkey, pubkey } => {
                // copy the seed bytes into a new `PrivateKey`
                let seed = privkey.with_secret(|s| *s);
                DlcKey::Private {
                    privkey: PrivateKey::new(seed),
                    pubkey: *pubkey,
                }
            }
            DlcKey::Public { pubkey } => DlcKey::Public { pubkey: *pubkey },
        }
    }
}

impl std::fmt::Display for DlcKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", URL_SAFE_NO_PAD.encode(self.get_public_key().get()))
    }
}

impl From<&DlcKey> for String {
    fn from(k: &DlcKey) -> Self {
        k.to_string()
    }
}

#[derive(Serialize, Deserialize, Debug)]
struct LicensePayload {
    pub dlcs: Vec<String>,
    pub iat: Option<u64>,
    pub nonce: Option<String>,
    pub product: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub encrypt_key: Option<String>,
}

/// Typed, verified private key returned by `DlcKey::verify_signed_license`.
#[derive(Debug, Clone)]
pub struct VerifiedLicense {
    /// List of DLC ids included in the token (as strings; these are converted to `DlcId` when applied/unlocked).
    pub dlcs: Vec<String>,
    /// Issued-at timestamp (optional) — not validated by the crate but can be used
    pub iat: Option<u64>,
    /// Optional nonce (e.g. for replay protection) — not validated by the crate but can be used by platforms that require it.
    pub nonce: Option<String>,
    /// Product binding (string) — this is checked against the manager's product when unlocking, but the crate does not enforce any particular format for this field.
    pub product: String,
}

// AES-GCM helper used to decrypt shipped assets once the DLC is unlocked.
fn decrypt_with_key(
    key: &EncryptionKey,
    ciphertext: &[u8],
    nonce: &[u8],
) -> Result<Vec<u8>, DlcError> {
    key.with_secret(|key_bytes| {
        if key_bytes.len() != 32 {
            return Err(DlcError::InvalidEncryptKey(
                "encrypt key must be 32 bytes (AES-256)".into(),
            ));
        }
        if nonce.len() != 12 {
            return Err(DlcError::InvalidNonce(
                "nonce must be 12 bytes (AES-GCM)".into(),
            ));
        }
        let cipher = Aes256Gcm::new_from_slice(key_bytes)
            .map_err(|e| DlcError::CryptoError(e.to_string()))?;
        let nonce = Nonce::from_slice(nonce);
        cipher.decrypt(nonce, ciphertext).map_err(|_| {
            DlcError::DecryptionFailed(
                "authentication failed (incorrect key or corrupted ciphertext)".to_string(),
            )
        })
    })
}

/// Helper struct for building DLC pack entries with optional metadata.
/// Provides a builder pattern for creating entries to pack into a `.dlcpack` container.
#[derive(Clone, Debug)]
pub struct PackItem {
    pub path: String,
    pub original_extension: Option<String>,
    pub type_path: Option<String>,
    pub plaintext: Vec<u8>,
}

#[allow(dead_code)]
impl PackItem {
    pub fn new(path: impl Into<String>, plaintext: impl Into<Vec<u8>>) -> Self {
        let path = path.into();
        let ext = std::path::Path::new(&path).extension();
        Self {
            path: path.clone(),
            original_extension: ext.and_then(|e| e.to_str()).map(|s| s.to_string()),
            type_path: None,
            plaintext: plaintext.into(),
        }
    }

    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
        self.original_extension = Some(ext.into());
        self
    }

    pub fn with_type_path(mut self, type_path: impl Into<String>) -> Self {
        self.type_path = Some(type_path.into());
        self
    }

    pub fn with_type<T: Asset>(self) -> Self {
        self.with_type_path(T::type_path())
    }
}

impl From<PackItem> for (String, Option<String>, Option<String>, Vec<u8>) {
    fn from(item: PackItem) -> Self {
        (
            item.path,
            item.original_extension,
            item.type_path,
            item.plaintext,
        )
    }
}

/// Pack multiple entries into a single `.dlcpack` container.
///
/// Arguments:
/// - `dlc_id`: the DLC ID this pack belongs to (used for registry lookup and validation)
/// - `items`: a list of items to include in the pack, where each item is a tuple of (relative path, optional original file extension, optional type path, plaintext bytes)
/// - `product`: the product identifier to bind the pack to
/// - `dlc_key`: the `DlcKey` containing the private key used to sign the pack (must be a `DlcKey::Private`)
/// - `key`: the symmetric encryption key used to encrypt the pack contents (must be 32 bytes for AES-256)
pub fn pack_encrypted_pack(
    dlc_id: &DlcId,
    items: &[PackItem],
    product: &Product,
    dlc_key: &DlcKey,
    key: &EncryptionKey,
) -> Result<Vec<u8>, DlcError> {
    if key.len() != 32 {
        return Err(DlcError::InvalidEncryptKey(
            "encryption key must be 32 bytes (AES-256)".into(),
        ));
    }

    // Get the private key for signing
    let privkey_bytes = match dlc_key {
        DlcKey::Private { privkey, .. } => privkey,
        DlcKey::Public { .. } => {
            return Err(DlcError::Other(
                "cannot sign pack with public-only key; use private key".into(),
            ));
        }
    };

    // refuse inputs that already look like an existing pack container
    for item in items {
        if item.plaintext.len() >= 4 && item.plaintext.starts_with(DLC_PACK_MAGIC) {
            return Err(DlcError::Other(format!(
                "cannot pack existing dlcpack container as an item: {}",
                item.path
            )));
        }
    }

    // Build a tar.gz archive (in-memory) containing the plaintext files at
    // their requested relative paths.
    use flate2::{Compression, write::GzEncoder};
    use tar::Builder;

    let mut tar_gz: Vec<u8> = Vec::new();
    {
        let enc = GzEncoder::new(&mut tar_gz, Compression::default());
        let mut tar = Builder::new(enc);
        for item in items {
            let mut header = tar::Header::new_gnu();
            header.set_size(item.plaintext.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            // append_data takes a reader; Cursor over the slice is convenient
            tar.append_data(
                &mut header,
                &item.path,
                &mut std::io::Cursor::new(&item.plaintext),
            )
            .map_err(|e| DlcError::Other(e.to_string()))?;
        }
        // finish the tar builder to flush into the gzip encoder
        let enc = tar
            .into_inner()
            .map_err(|e| DlcError::Other(e.to_string()))?;
        // finish the gzip encoder explicitly to ensure all data is written
        let _ = enc.finish().map_err(|e| DlcError::Other(e.to_string()))?;
    }

    // produce AES-GCM ciphertext for the whole compressed archive
    let cipher = key.with_secret(|kb| {
        Aes256Gcm::new_from_slice(kb.as_slice()).map_err(|e| DlcError::CryptoError(e.to_string()))
    })?;
    // generate a random nonce for this encryption (12 bytes for AES-GCM)
    let nonce_bytes: [u8; 12] = rand::random();
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ciphertext = cipher
        .encrypt(nonce, tar_gz.as_slice())
        .map_err(|_| DlcError::EncryptionFailed("encryption failed".into()))?;

    // prepare manifest (JSON) with per-entry metadata
    #[derive(serde::Serialize)]
    struct ManifestEntry<'a> {
        path: &'a str,
        #[serde(skip_serializing_if = "Option::is_none")]
        original_extension: Option<&'a str>,
        #[serde(skip_serializing_if = "Option::is_none")]
        type_path: Option<&'a str>,
    }

    let mut manifest: Vec<ManifestEntry<'_>> = Vec::with_capacity(items.len());
    for item in items {
        manifest.push(ManifestEntry {
            path: item.path.as_str(),
            original_extension: item.original_extension.as_deref(),
            type_path: item.type_path.as_deref(),
        });
    }
    let manifest_bytes =
        serde_json::to_vec(&manifest).map_err(|e| DlcError::Other(e.to_string()))?;

    // Sign the metadata (product + dlc_id) with the private key
    // This proves the DLC belongs to the authorized product
    let product_str = product.get();
    let dlc_id_str = dlc_id.to_string();
    let signature = privkey_bytes.with_secret(|priv_bytes| {
        let pair = Ed25519KeyPair::from_seed_unchecked(priv_bytes)
            .map_err(|e| DlcError::CryptoError(format!("keypair: {:?}", e)))?;
        let mut signature_preimage = Vec::new();
        signature_preimage.extend_from_slice(product_str.as_bytes());
        signature_preimage.extend_from_slice(dlc_id_str.as_bytes());
        Ok::<_, DlcError>(pair.sign(&signature_preimage).as_ref().to_vec())
    })?;

    // serialize BDLP v3: magic | version | product_len | product | signature(64) | dlc_len | dlc_id | manifest_len | manifest | nonce | ciphertext_len | ciphertext
    let mut out = Vec::new();
    out.extend_from_slice(DLC_PACK_MAGIC);
    out.push(3u8); // version 3 (with signature + product)

    let product_bytes = product_str.as_bytes();
    out.extend_from_slice(&(product_bytes.len() as u16).to_be_bytes());
    out.extend_from_slice(product_bytes);

    if signature.len() != 64 {
        return Err(DlcError::Other(format!(
            "ed25519 signature must be 64 bytes, got {}",
            signature.len()
        )));
    }
    out.extend_from_slice(&signature);

    let dlc_bytes = dlc_id_str.as_bytes();
    out.extend_from_slice(&(dlc_bytes.len() as u16).to_be_bytes());
    out.extend_from_slice(dlc_bytes);

    out.extend_from_slice(&(manifest_bytes.len() as u32).to_be_bytes());
    out.extend_from_slice(&manifest_bytes);

    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&(ciphertext.len() as u32).to_be_bytes());
    out.extend_from_slice(&ciphertext);

    Ok(out)
}

/// .dlcpack container magic header (4 bytes) used to identify encrypted pack containers.
pub const DLC_PACK_MAGIC: &[u8; 4] = b"BDLP";

/// Current supported .dlcpack format version. This is stored in the container header and used to determine how to parse the contents.
pub const DLC_PACK_VERSION: u8 = 3;

/// Parse a `.dlcpack` container and return product, embedded dlc_id, and a list
/// of `(path, EncryptedAsset)` pairs. For v3 format, also validates the signature
/// against the authorized product public key.
///
/// Returns: (product, dlc_id, entries, signature_bytes_if_v3)
pub fn parse_encrypted_pack(
    bytes: &[u8],
) -> Result<
    (
        String,
        String,
        usize,
        Vec<(String, crate::asset_loader::EncryptedAsset)>,
    ),
    std::io::Error,
> {
    use std::io::ErrorKind;

    // basic validation and header parsing
    if bytes.len() < 4 + 1 {
        return Err(std::io::Error::new(
            ErrorKind::InvalidData,
            "dlcpack too small",
        ));
    }
    if &bytes[0..4] != DLC_PACK_MAGIC {
        return Err(std::io::Error::new(
            ErrorKind::InvalidData,
            "invalid dlcpack magic",
        ));
    }
    let version = bytes[4];
    let mut offset = 5usize;

    // v3: product | signature(64) | dlc_len | dlc_id | ...
    let product_str = if version == DLC_PACK_VERSION {
        if offset + 2 > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "v3: missing product_len",
            ));
        }
        let product_len = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
        offset += 2;
        if offset + product_len > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "v3: invalid product length",
            ));
        }
        let prod = String::from_utf8(bytes[offset..offset + product_len].to_vec())
            .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
        offset += product_len;

        // read signature (64 bytes for ed25519)
        if offset + 64 > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "v3: missing signature",
            ));
        }
        let _signature = &bytes[offset..offset + 64].to_vec();
        offset += 64;
        // Signature validation would happen in loader/manager with public key
        // For now, we parse and store it but don't verify (verification happens at load time)

        prod
    } else if version < DLC_PACK_VERSION {
        // v1/v2 formats are product-less (legacy); treat as empty/unknown product
        String::new()
    } else {
        return Err(std::io::Error::new(
            ErrorKind::InvalidData,
            format!("unsupported pack version: {}", version),
        ));
    };

    // read dlc_id (remains same for all versions)
    let dlc_len = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
    offset += 2;
    if offset + dlc_len > bytes.len() {
        return Err(std::io::Error::new(
            ErrorKind::InvalidData,
            "invalid dlc id length",
        ));
    }
    let dlc_id = String::from_utf8(bytes[offset..offset + dlc_len].to_vec())
        .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
    offset += dlc_len;

    // Versioned parsing: v1 = per-entry encrypted items, v2 = single encrypted gzip archive + plaintext manifest
    if version == 1 {
        // legacy format
        let entry_count = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
        offset += 2;

        let mut entries = Vec::with_capacity(entry_count);
        for _ in 0..entry_count {
            if offset + 2 > bytes.len() {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "missing path_len",
                ));
            }
            let path_len = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
            offset += 2;
            if offset + path_len > bytes.len() {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "invalid path length",
                ));
            }
            let path = String::from_utf8(bytes[offset..offset + path_len].to_vec())
                .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
            offset += path_len;

            if offset + 1 > bytes.len() {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "missing ext_len",
                ));
            }
            let ext_len = bytes[offset] as usize;
            offset += 1;
            let original_extension = if ext_len == 0 {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "missing original extension",
                ));
            } else {
                if offset + ext_len > bytes.len() {
                    return Err(std::io::Error::new(
                        ErrorKind::InvalidData,
                        "invalid ext length",
                    ));
                }
                let s = String::from_utf8(bytes[offset..offset + ext_len].to_vec())
                    .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
                offset += ext_len;
                s
            };

            // version 1+ stores an optional serialized type identifier per entry
            let original_type = if version >= 1 {
                if offset + 2 > bytes.len() {
                    return Err(std::io::Error::new(
                        ErrorKind::InvalidData,
                        "missing type_path len",
                    ));
                }
                let tlen = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
                offset += 2;
                if tlen == 0 {
                    None
                } else {
                    if offset + tlen > bytes.len() {
                        return Err(std::io::Error::new(
                            ErrorKind::InvalidData,
                            "invalid type_path length",
                        ));
                    }
                    let s = String::from_utf8(bytes[offset..offset + tlen].to_vec())
                        .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;
                    offset += tlen;
                    Some(s)
                }
            } else {
                None
            };

            if offset + 12 + 4 > bytes.len() {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "truncated entry",
                ));
            }
            let mut nonce = [0u8; 12];
            nonce.copy_from_slice(&bytes[offset..offset + 12]);
            offset += 12;
            let ciphertext_len = u32::from_be_bytes([
                bytes[offset],
                bytes[offset + 1],
                bytes[offset + 2],
                bytes[offset + 3],
            ]) as usize;
            offset += 4;
            if offset + ciphertext_len > bytes.len() {
                return Err(std::io::Error::new(
                    ErrorKind::InvalidData,
                    "truncated ciphertext",
                ));
            }
            let ciphertext: std::sync::Arc<[u8]> = bytes[offset..offset + ciphertext_len].into();
            offset += ciphertext_len;

            let enc = crate::asset_loader::EncryptedAsset {
                dlc_id: dlc_id.clone(),
                original_extension,
                type_path: original_type,
                nonce,
                ciphertext,
            };
            entries.push((path, enc));
        }

        Ok((product_str, dlc_id, 1usize, entries))
    } else if version >= 2 {
        // new archive-encrypted format: read manifest (u32 len + JSON), then nonce + ciphertext
        if offset + 4 > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "missing manifest_len",
            ));
        }
        let manifest_len = u32::from_be_bytes([
            bytes[offset],
            bytes[offset + 1],
            bytes[offset + 2],
            bytes[offset + 3],
        ]) as usize;
        offset += 4;
        if offset + manifest_len > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "truncated manifest",
            ));
        }
        let manifest_bytes = &bytes[offset..offset + manifest_len];
        offset += manifest_len;
        let manifest: Vec<serde_json::Value> = serde_json::from_slice(manifest_bytes)
            .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?;

        if offset + 12 + 4 > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "truncated archive",
            ));
        }
        let mut nonce = [0u8; 12];
        nonce.copy_from_slice(&bytes[offset..offset + 12]);
        offset += 12;
        let ciphertext_len = u32::from_be_bytes([
            bytes[offset],
            bytes[offset + 1],
            bytes[offset + 2],
            bytes[offset + 3],
        ]) as usize;
        offset += 4;
        if offset + ciphertext_len > bytes.len() {
            return Err(std::io::Error::new(
                ErrorKind::InvalidData,
                "truncated ciphertext",
            ));
        }
        let ciphertext: std::sync::Arc<[u8]> = bytes[offset..offset + ciphertext_len].into();

        // Construct per-entry metadata entries that reference the shared ciphertext
        let mut entries = Vec::with_capacity(manifest.len());
        for v in manifest.into_iter() {
            let path = v
                .get("path")
                .and_then(|p| p.as_str())
                .ok_or_else(|| {
                    std::io::Error::new(ErrorKind::InvalidData, "manifest entry missing path")
                })?
                .to_string();
            let original_extension = v
                .get("original_extension")
                .and_then(|e| e.as_str())
                .unwrap_or("");
            let type_path = v
                .get("type_path")
                .and_then(|t| t.as_str())
                .map(|s| s.to_string());

            let enc = crate::asset_loader::EncryptedAsset {
                dlc_id: dlc_id.clone(),
                original_extension: original_extension.to_string(),
                type_path,
                nonce,
                ciphertext: ciphertext.clone(),
            };
            entries.push((path, enc));
        }

        Ok((product_str, dlc_id, version as usize, entries))
    } else {
        Err(std::io::Error::new(
            ErrorKind::InvalidData,
            "unsupported dlcpack version",
        ))
    }
}

/// Verify the signature of a .dlcpack v3 container against a public key.
///
/// Returns `Ok(true)` if the signature is valid, `Ok(false)` if invalid, or `Err` if parsing fails.
/// Only works for v3 packs which include product and signature.
pub fn verify_pack_signature(pack_bytes: &[u8], pub_key_str: &str, version: u8) -> Result<bool, DlcError> {
    // Check magic and version
    if pack_bytes.len() < 5 || &pack_bytes[0..4] != DLC_PACK_MAGIC {
        return Err(DlcError::Other("not a valid dlcpack".into()));
    }
    let pack_version = pack_bytes[4];

    if pack_version < version {
        return Err(DlcError::DeprecatedVersion(format!(
            "{}", version).into(),
        ));
    }

    let mut offset = 5usize;

    // Extract product
    if offset + 2 > pack_bytes.len() {
        return Err(DlcError::Other("truncated pack format".into()));
    }
    let product_len = u16::from_be_bytes([pack_bytes[offset], pack_bytes[offset + 1]]) as usize;
    offset += 2;
    if offset + product_len > pack_bytes.len() {
        return Err(DlcError::Other("invalid product length".into()));
    }
    let product_str = String::from_utf8(pack_bytes[offset..offset + product_len].to_vec())
        .map_err(|_| DlcError::Other("product not valid UTF-8".into()))?;
    offset += product_len;

    // Extract signature (64 bytes)
    if offset + 64 > pack_bytes.len() {
        return Err(DlcError::Other("truncated signature".into()));
    }
    let signature_bytes = pack_bytes[offset..offset + 64].to_vec();
    offset += 64;

    // Extract dlc_id
    if offset + 2 > pack_bytes.len() {
        return Err(DlcError::Other("truncated dlc_id length".into()));
    }
    let dlc_len = u16::from_be_bytes([pack_bytes[offset], pack_bytes[offset + 1]]) as usize;
    offset += 2;
    if offset + dlc_len > pack_bytes.len() {
        return Err(DlcError::Other("invalid dlc_id length".into()));
    }
    let dlc_id_str = String::from_utf8(pack_bytes[offset..offset + dlc_len].to_vec())
        .map_err(|_| DlcError::Other("dlc_id not valid UTF-8".into()))?;

    // Verify signature: signature is over (product + dlc_id)
    let verifier = DlcKey::public(pub_key_str)?;
    let mut preimage = Vec::new();
    preimage.extend_from_slice(product_str.as_bytes());
    preimage.extend_from_slice(dlc_id_str.as_bytes());

    let pubkey = verifier.get_public_key();
    let unparsed_public_key = UnparsedPublicKey::new(&ED25519, pubkey.get());
    match unparsed_public_key.verify(&preimage, &signature_bytes) {
        Ok(()) => Ok(true),
        Err(_) => Ok(false),
    }
}

#[derive(Error, Debug, Clone)]
pub enum DlcError {
    #[error("invalid public key: {0}")]
    InvalidPublicKey(String),
    #[error("malformed private key: {0}")]
    MalformedLicense(String),
    #[error("signature verification failed")]
    SignatureInvalid,
    #[error("payload parse failed: {0}")]
    PayloadInvalid(String),

    // private key / crypto specific
    #[error("private key creation failed: {0}")]
    TokenCreationFailed(String),
    #[error("private key required for this operation")]
    PrivateKeyRequired,
    #[error("invalid public key")]
    InvalidPassphrase,
    #[error("crypto error: {0}")]
    CryptoError(String),

    // encryption / decryption
    #[error("encryption failed: {0}")]
    EncryptionFailed(String),
    #[error("{0}")]
    DecryptionFailed(String),
    #[error("invalid encrypt key: {0}")]
    InvalidEncryptKey(String),
    #[error("invalid nonce: {0}")]
    InvalidNonce(String),

    // DLC / encrypt-key state
    #[error("dlc locked: {0}")]
    DlcLocked(String),
    #[error("no encrypt key for dlc: {0}")]
    NoEncryptKey(String),

    // private key binding mismatches
    #[error("private key product does not match")]
    TokenProductMismatch,

    // versioning
    #[error("deprecated version: v{0}")]
    DeprecatedVersion(String),

    #[error("bad formatted version: {0}")]
    BadVersionFormat(String),

    // fallback
    #[error("{0}")]
    Other(String),
}

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

    use super::*;

    #[test]
    fn pack_encrypted_pack_rejects_nested_dlc() {
        let encrypt_key = EncryptionKey::from_random(32);
        let dlc_id = DlcId::from("pack_test");
        let product = Product::from("test");
        let dlc_key = DlcKey::generate_random();
        let mut items: Vec<PackItem> = Vec::new();
        let mut v = Vec::new();
        v.extend_from_slice(DLC_PACK_MAGIC);
        v.extend_from_slice(b"inner");
        items.push(PackItem::new("a.txt", v));
        let res = pack_encrypted_pack(&dlc_id, &items, &product, &dlc_key, &encrypt_key);
        assert!(matches!(res, Err(DlcError::Other(_))));
    }

    #[test]
    fn pack_encrypted_pack_rejects_nested_dlcpack() {
        let key = EncryptionKey::from_random(32);
        let dlc_id = DlcId::from("pack_test");
        let product = Product::from("test");
        let dlc_key = DlcKey::generate_random();
        let mut items: Vec<PackItem> = Vec::new();
        let mut v = Vec::new();
        v.extend_from_slice(b"BDLP");
        v.extend_from_slice(b"innerpack");
        items.push(PackItem::new("b.bin", v));
        let res = pack_encrypted_pack(&dlc_id, &items, &product, &dlc_key, &key);
        assert!(matches!(res, Err(DlcError::Other(_))));
    }

    #[test]
    fn dlc_id_serde_roundtrip() {
        let id = DlcId::from("expansion_serde");
        let s = serde_json::to_string(&id).expect("serialize dlc id");
        assert_eq!(s, "\"expansion_serde\"");
        let decoded: DlcId = serde_json::from_str(&s).expect("deserialize dlc id");
        assert_eq!(decoded.to_string(), "expansion_serde");
    }

    #[test]
    fn extend_signed_license_merges_dlc_ids() {
        let product = Product::from("test_product");
        let dlc_key = DlcKey::generate_random();

        // Create initial license with two DLC ids
        let initial = dlc_key
            .create_signed_license(&["expansion_a", "expansion_b"], product.clone())
            .expect("create initial license");

        // Extend with a new DLC id
        let extended = dlc_key
            .extend_signed_license(&initial, &["expansion_c"], product.clone())
            .expect("extend license");

        // Verify the extended license contains all three DLC ids
        let verified = dlc_key
            .verify_signed_license(&extended)
            .expect("verify extended license");
        assert_eq!(verified.dlcs.len(), 3);
        assert!(verified.dlcs.contains(&"expansion_a".to_string()));
        assert!(verified.dlcs.contains(&"expansion_b".to_string()));
        assert!(verified.dlcs.contains(&"expansion_c".to_string()));
    }

    #[test]
    fn extend_signed_license_deduplicates() {
        let product = Product::from("test_product");
        let dlc_key = DlcKey::generate_random();

        // Create initial license with a DLC id
        let initial = dlc_key
            .create_signed_license(&["expansion_a"], product.clone())
            .expect("create initial license");

        // Try to extend with the same DLC id (should deduplicate)
        let extended = dlc_key
            .extend_signed_license(&initial, &["expansion_a"], product.clone())
            .expect("extend license");

        // Verify there's only one instance of expansion_a
        let verified = dlc_key
            .verify_signed_license(&extended)
            .expect("verify extended license");
        let count = verified.dlcs.iter().filter(|d| d == &"expansion_a").count();
        assert_eq!(count, 1, "Should not duplicate dlc_ids");
    }

    #[test]
    #[serial_test::serial]
    fn register_dlc_type_adds_pack_registrar_factory() {
        let mut app = App::new();
        app.add_plugins(AssetPlugin::default());
        #[derive(Asset, TypePath)]
        struct TestAsset;
        app.init_asset::<TestAsset>();
        app.register_dlc_type::<TestAsset>();

        let factories = app
            .world()
            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
            .expect("should have factories resource");
        assert!(
            factories
                .0
                .read()
                .unwrap()
                .iter()
                .any(|f| asset_loader::fuzzy_type_path_match(
                    f.type_name(),
                    TestAsset::type_path()
                ))
        );
    }

    #[test]
    #[serial_test::serial]
    fn register_dlc_type_is_idempotent_for_pack_factories() {
        let mut app = App::new();
        app.add_plugins(AssetPlugin::default());
        #[derive(Asset, TypePath)]
        struct TestAsset2;
        app.init_asset::<TestAsset2>();
        app.register_dlc_type::<TestAsset2>();
        app.register_dlc_type::<TestAsset2>();

        let factories = app
            .world()
            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
            .expect("should have factories resource");
        let count = factories
            .0
            .read()
            .unwrap()
            .iter()
            .filter(|f| asset_loader::fuzzy_type_path_match(f.type_name(), TestAsset2::type_path()))
            .count();
        assert_eq!(count, 1);
    }
}

// ============================================================================
// Test helpers
// ============================================================================

/// Test helpers for integration tests. These provide controlled access to the
/// internal registry to support test scenarios. Do not use in production code.
///
/// The registry should only be updated by `DlcPackLoader` when assets are loaded
/// or by systems processing `SignedLicense` tokens in production. This module
/// allows integration tests to bypass normal flow by directly registering keys.
#[cfg(test)]
#[allow(dead_code)]
pub mod test_helpers {
    use crate::{EncryptionKey, encrypt_key_registry};

    /// Insert a test encryption key into the registry for a given DLC id.
    ///
    /// **Test-only**: This bypasses normal production flows and should only be used
    /// in integration tests that need to load assets without processing signed licenses.
    pub fn register_test_encryption_key(dlc_id: &str, key: EncryptionKey) {
        encrypt_key_registry::insert(dlc_id, key);
    }

    /// Register an asset path in the test registry.
    ///
    /// **Test-only**: Used to simulate what `DlcPackLoader` does during asset loading.
    pub fn register_test_asset_path(dlc_id: &str, path: &str) {
        encrypt_key_registry::register_asset_path(dlc_id, path);
    }

    /// Clear the entire test registry.
    ///
    /// **Test-only**: Call this in test cleanup to reset state.
    pub fn clear_test_registry() {
        encrypt_key_registry::clear_all();
    }
}
