/*!
DID Universal Resolver Cache Client SDK

Used to easily connect to the DID Universal Resolver Cache.

# Crate features
As this crate can be used either natively or in a WASM environment, the following features are available:
* **local**
  * **default** - Enables the local mode of the SDK. This is the default mode.
* **network**
    * Enables the network mode of the SDK. This mode requires a run-time service address to connect to.
    * This feature is NOT supported in a WASM environment. Will cause a compile error if used in WASM.
*/

#[cfg(all(feature = "network", target_arch = "wasm32"))]
compile_error!("The 'network' feature is not supported on wasm32 targets");

use affinidi_did_common::Document;
use config::DIDCacheConfig;
use errors::DIDCacheError;
use highway::{HighwayHash, HighwayHasher};
use moka::{Expiry, future::Cache};
#[cfg(feature = "network")]
use networking::{
    WSRequest,
    network::{NetworkTask, WSCommands},
};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::{fmt, time::Duration};
#[cfg(feature = "network")]
use tokio::sync::{Mutex, mpsc};
use tracing::debug;
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::*;

pub mod config;
pub mod errors;
#[cfg(feature = "network")]
pub mod networking;
mod resolver;

// Re-export resolver traits and network resolver implementations
pub use affinidi_did_resolver_traits::{
    AsyncResolver, MethodName, Resolution, Resolver, ResolverError,
};
pub use resolver::network_resolvers;

/// DID Methods supported by the DID Universal Resolver Cache
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[wasm_bindgen]
pub enum DIDMethod {
    ETHR,
    JWK,
    KEY,
    PEER,
    PKH,
    WEB,
    WEBVH,
    CHEQD,
    SCID,
    EXAMPLE,
}

/// Helper function to convert a DIDMethod to a string
impl fmt::Display for DIDMethod {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            DIDMethod::ETHR => write!(f, "ethr"),
            DIDMethod::JWK => write!(f, "jwk"),
            DIDMethod::KEY => write!(f, "key"),
            DIDMethod::PEER => write!(f, "peer"),
            DIDMethod::PKH => write!(f, "pkh"),
            DIDMethod::WEB => write!(f, "web"),
            DIDMethod::WEBVH => write!(f, "webvh"),
            DIDMethod::CHEQD => write!(f, "cheqd"),
            DIDMethod::SCID => write!(f, "scid"),
            DIDMethod::EXAMPLE => write!(f, "example"),
        }
    }
}

/// Helper function to convert a string to a DIDMethod
impl TryFrom<String> for DIDMethod {
    type Error = DIDCacheError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        value.as_str().try_into()
    }
}

impl TryFrom<&str> for DIDMethod {
    type Error = DIDCacheError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        match value.to_lowercase().as_str() {
            "ethr" => Ok(DIDMethod::ETHR),
            "jwk" => Ok(DIDMethod::JWK),
            "key" => Ok(DIDMethod::KEY),
            "peer" => Ok(DIDMethod::PEER),
            "pkh" => Ok(DIDMethod::PKH),
            "web" => Ok(DIDMethod::WEB),
            "webvh" => Ok(DIDMethod::WEBVH),
            "cheqd" => Ok(DIDMethod::CHEQD),
            "scid" => Ok(DIDMethod::SCID),
            #[cfg(feature = "did_example")]
            "example" => Ok(DIDMethod::EXAMPLE),
            _ => Err(DIDCacheError::UnsupportedMethod(value.to_string())),
        }
    }
}

impl DIDMethod {
    /// Returns `true` for DID methods whose documents are fetched from external
    /// infrastructure and can change over time (web, webvh, cheqd, scid).
    ///
    /// Returns `false` for deterministic methods where the document is derived
    /// entirely from the DID string itself (key, peer, jwk, ethr, pkh, example).
    pub fn is_mutable(&self) -> bool {
        matches!(
            self,
            DIDMethod::WEB | DIDMethod::WEBVH | DIDMethod::CHEQD | DIDMethod::SCID
        )
    }
}

#[derive(Debug)]
pub struct ResolveResponse {
    pub did: String,
    pub method: DIDMethod,
    pub did_hash: [u64; 2],
    pub doc: Document,
    pub cache_hit: bool,
}

/// Per-entry expiry policy for the DID document cache.
///
/// - **Immutable methods** (key, peer, jwk, ethr, pkh): no expiry — entries
///   stay cached until evicted by capacity pressure.
/// - **Mutable methods** (web, webvh, cheqd, scid): expire after `mutable_ttl`
///   so that updated documents are eventually re-fetched.
struct DIDExpiry {
    mutable_ttl: Duration,
}

impl Expiry<[u64; 2], Document> for DIDExpiry {
    fn expire_after_create(
        &self,
        _key: &[u64; 2],
        value: &Document,
        _created_at: std::time::Instant,
    ) -> Option<Duration> {
        let did_str = value.id.as_str();

        // Extract the method name from "did:<method>:..."
        let is_mutable = did_str
            .split(':')
            .nth(1)
            .and_then(|m| DIDMethod::try_from(m).ok())
            .is_some_and(|m| m.is_mutable());

        if is_mutable {
            Some(self.mutable_ttl)
        } else {
            None // no expiry — evicted only by capacity
        }
    }
}

// ***************************************************************************

/// [DIDCacheClient] is how you interact with the DID Universal Resolver Cache
/// config: Configuration for the SDK
/// cache: Local cache for resolved DIDs
/// network_task: OPTIONAL: Task to handle network requests
/// network_rx: OPTIONAL: Channel to listen for responses from the network task
#[wasm_bindgen(getter_with_clone)]
pub struct DIDCacheClient {
    config: DIDCacheConfig,
    cache: Cache<[u64; 2], Document>,
    #[cfg(feature = "network")]
    network_task_tx: Option<mpsc::Sender<WSCommands>>,
    #[cfg(feature = "network")]
    network_task_rx: Option<Arc<Mutex<mpsc::Receiver<WSCommands>>>>,
    #[cfg(feature = "did_example")]
    did_example_cache: did_example::DiDExampleCache,
    resolvers: Arc<HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>>>,
}

impl Clone for DIDCacheClient {
    fn clone(&self) -> Self {
        Self {
            config: self.config.clone(),
            cache: self.cache.clone(),
            #[cfg(feature = "network")]
            network_task_tx: self.network_task_tx.clone(),
            #[cfg(feature = "network")]
            network_task_rx: self.network_task_rx.clone(),
            #[cfg(feature = "did_example")]
            did_example_cache: self.did_example_cache.clone(),
            resolvers: self.resolvers.clone(),
        }
    }
}

impl DIDCacheClient {
    /// Get a mutable reference to the inner resolver map.
    ///
    /// # Panics
    /// Panics if the client has already been cloned (Arc refcount > 1).
    /// All resolver mutations must happen during setup, before the client is shared.
    fn resolvers_mut(&mut self) -> &mut HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>> {
        Arc::get_mut(&mut self.resolvers)
            .expect("Cannot modify resolvers after DIDCacheClient has been cloned")
    }

    /// Replace all resolvers for a method with a single resolver.
    ///
    /// This is the most common operation — "I want this resolver for this method."
    /// Clears any existing resolvers for the method before inserting.
    pub fn set_resolver(&mut self, method: MethodName, resolver: Box<dyn AsyncResolver>) {
        let map = self.resolvers_mut();
        let deque = map.entry(method).or_default();
        deque.clear();
        deque.push_back(resolver);
    }

    /// Add a resolver to the front of the chain for a method (highest priority).
    ///
    /// Returns `Err` if a resolver with the same `name()` already exists for this method.
    pub fn prepend_resolver(
        &mut self,
        method: MethodName,
        resolver: Box<dyn AsyncResolver>,
    ) -> Result<(), DIDCacheError> {
        let name = resolver.name().to_string();
        let map = self.resolvers_mut();
        let deque = map.entry(method.clone()).or_default();
        if deque.iter().any(|r| r.name() == name) {
            return Err(DIDCacheError::DIDError(format!(
                "Resolver '{name}' already registered for method '{method}'"
            )));
        }
        deque.push_front(resolver);
        Ok(())
    }

    /// Add a resolver to the back of the chain for a method (lowest priority / fallback).
    ///
    /// Returns `Err` if a resolver with the same `name()` already exists for this method.
    pub fn append_resolver(
        &mut self,
        method: MethodName,
        resolver: Box<dyn AsyncResolver>,
    ) -> Result<(), DIDCacheError> {
        let name = resolver.name().to_string();
        let map = self.resolvers_mut();
        let deque = map.entry(method.clone()).or_default();
        if deque.iter().any(|r| r.name() == name) {
            return Err(DIDCacheError::DIDError(format!(
                "Resolver '{name}' already registered for method '{method}'"
            )));
        }
        deque.push_back(resolver);
        Ok(())
    }

    /// Remove all resolvers for a method.
    pub fn clear_resolvers(&mut self, method: &MethodName) {
        self.resolvers_mut().remove(method);
    }

    /// Remove a resolver at a specific index in a method's chain.
    ///
    /// Returns the removed resolver, or `None` if the index is out of bounds.
    pub fn remove_resolver(
        &mut self,
        method: &MethodName,
        index: usize,
    ) -> Option<Box<dyn AsyncResolver>> {
        let map = self.resolvers_mut();
        let deque = map.get_mut(method)?;
        let removed = deque.remove(index);
        if deque.is_empty() {
            map.remove(method);
        }
        removed
    }

    /// Find a resolver by name within a method's chain.
    ///
    /// Returns the index if found, suitable for use with `remove_resolver`.
    pub fn find_resolver(&self, method: &MethodName, name: &str) -> Option<usize> {
        self.resolvers
            .get(method)?
            .iter()
            .position(|r| r.name() == name)
    }

    /// Front end for resolving a DID
    /// Will check the cache first, and if not found, will resolve the DID
    /// Returns the initial DID, the hashed DID, and the resolved DID Document
    /// NOTE: The DID Document id may be different to the requested DID due to the DID having been updated.
    ///       The original DID should be in the `also_known_as` field of the DID Document.
    pub async fn resolve(&self, did: &str) -> Result<ResolveResponse, DIDCacheError> {
        use affinidi_did_common::DID;

        // Size guard before any parsing
        if did.len() > self.config.max_did_size_in_bytes {
            return Err(DIDCacheError::DIDError(format!(
                "The DID size of {0} bytes exceeds the limit of {1}. Please ensure the size is less than {1}.",
                did.len(),
                self.config.max_did_size_in_bytes
            )));
        }

        // Parse the DID string (validates "did:method:specific-id" format)
        let parsed_did: DID = did
            .parse()
            .map_err(|e| DIDCacheError::DIDError(format!("Failed to parse DID: {e}")))?;

        // Check max parts in the method-specific-id
        let method_specific_id = parsed_did.method_specific_id();
        let key_parts: Vec<&str> = method_specific_id.split('.').collect();
        if key_parts.len() > self.config.max_did_parts {
            return Err(DIDCacheError::DIDError(format!(
                "The total number of keys and/or services must be less than or equal to {}, but {} were found.",
                self.config.max_did_parts,
                key_parts.len()
            )));
        }

        let method: DIDMethod = parsed_did.method().to_string().as_str().try_into()?;

        let hash = DIDCacheClient::hash_did(did);

        #[cfg(feature = "did_example")]
        // Short-circuit for example DIDs
        if matches!(method, DIDMethod::EXAMPLE)
            && let Some(doc) = self.did_example_cache.get(did)
        {
            return Ok(ResolveResponse {
                did: did.to_string(),
                method,
                did_hash: hash,
                doc: doc.clone(),
                cache_hit: true,
            });
        }

        // Check if the DID is in the cache
        if let Some(doc) = self.cache.get(&hash).await {
            debug!("found did ({}) in cache", did);
            Ok(ResolveResponse {
                did: did.to_string(),
                method,
                did_hash: hash,
                doc,
                cache_hit: true,
            })
        } else {
            debug!("did ({}) NOT in cache hash ({:#?})", did, hash);
            // If the DID is not in the cache, resolve it (local or via network)
            #[cfg(feature = "network")]
            let doc = {
                if self.config.service_address.is_some() {
                    self.network_resolve(did, hash).await?
                } else {
                    self.local_resolve(&parsed_did).await?
                }
            };

            #[cfg(not(feature = "network"))]
            let doc = self.local_resolve(&parsed_did).await?;

            debug!("adding did ({}) to cache ({:#?})", did, hash);
            self.cache.insert(hash, doc.clone()).await;
            Ok(ResolveResponse {
                did: did.to_string(),
                method,
                did_hash: hash,
                doc,
                cache_hit: false,
            })
        }
    }

    /// If you want to interact directly with the DID Document cache
    /// This will return a clone of the cache (the clone is cheap, and the cache is shared)
    /// For example, accessing cache statistics or manually inserting a DID Document
    pub fn get_cache(&self) -> Cache<[u64; 2], Document> {
        self.cache.clone()
    }

    /// Stops the network task if it is running and removes any resources
    #[cfg(feature = "network")]
    pub fn stop(&self) {
        if let Some(tx) = self.network_task_tx.as_ref() {
            let _ = tx.blocking_send(WSCommands::Exit);
        }
    }

    /// Removes the specified DID from the cache
    /// Returns the removed DID Document if it was in the cache, or None if it was not
    pub async fn remove(&self, did: &str) -> Option<Document> {
        self.cache.remove(&DIDCacheClient::hash_did(did)).await
    }

    /// Add a DID Document to the cache manually
    pub async fn add_did_document(&mut self, did: &str, doc: Document) {
        let hash = DIDCacheClient::hash_did(did);
        debug!("manually adding did ({}) hash({:#?}) to cache", did, hash);
        self.cache.insert(hash, doc).await;
    }

    /// Convenience function to hash a DID
    pub fn hash_did(did: &str) -> [u64; 2] {
        // Use a consistent Seed so it always hashes to the same value
        HighwayHasher::default().hash128(did.as_bytes())
    }
}

/// Following are the WASM bindings for the DIDCacheClient
#[wasm_bindgen]
impl DIDCacheClient {
    /// Create a new DIDCacheClient with configuration generated from [ClientConfigBuilder](config::ClientConfigBuilder)
    ///
    /// Will return an error if the configuration is invalid.
    ///
    /// Establishes websocket connection and sets up the cache.
    // using Self instead of DIDCacheClient leads to E0401 errors in dependent crates
    // this is due to wasm_bindgen generated code (check via `cargo expand`)
    pub async fn new(config: DIDCacheConfig) -> Result<DIDCacheClient, DIDCacheError> {
        // Create the cache with per-entry expiry:
        // - Immutable DID methods (key, peer, jwk, ethr, pkh) → no TTL (evicted only by capacity)
        // - Mutable DID methods (web, webvh, cheqd, scid) → expire after cache_ttl seconds
        let cache = Cache::builder()
            .max_capacity(config.cache_capacity.into())
            .expire_after(DIDExpiry {
                mutable_ttl: Duration::from_secs(config.cache_ttl.into()),
            })
            .build();

        // Register built-in resolvers
        let mut resolvers: HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>> = HashMap::new();

        // Local (sync) resolvers via blanket impl
        resolvers
            .entry(MethodName::Key)
            .or_default()
            .push_back(Box::new(affinidi_did_resolver_traits::KeyResolver));
        resolvers
            .entry(MethodName::Peer)
            .or_default()
            .push_back(Box::new(affinidi_did_resolver_traits::PeerResolver));
        // Network resolvers
        resolvers
            .entry(MethodName::Ethr)
            .or_default()
            .push_back(Box::new(network_resolvers::EthrResolver));
        resolvers
            .entry(MethodName::Pkh)
            .or_default()
            .push_back(Box::new(network_resolvers::PkhResolver));
        resolvers
            .entry(MethodName::Web)
            .or_default()
            .push_back(Box::new(network_resolvers::WebResolver));
        #[cfg(feature = "did-jwk")]
        resolvers
            .entry(MethodName::Jwk)
            .or_default()
            .push_back(Box::new(network_resolvers::JwkResolver));
        #[cfg(feature = "did-webvh")]
        resolvers
            .entry(MethodName::Webvh)
            .or_default()
            .push_back(Box::new(network_resolvers::WebvhResolver));
        #[cfg(feature = "did-cheqd")]
        resolvers
            .entry(MethodName::Cheqd)
            .or_default()
            .push_back(Box::new(network_resolvers::CheqdResolver));
        #[cfg(feature = "did-scid")]
        resolvers
            .entry(MethodName::Scid)
            .or_default()
            .push_back(Box::new(network_resolvers::ScidResolver));

        let resolvers = Arc::new(resolvers);

        #[cfg(feature = "network")]
        let mut client = Self {
            config,
            cache,
            network_task_tx: None,
            network_task_rx: None,
            #[cfg(feature = "did_example")]
            did_example_cache: did_example::DiDExampleCache::new(),
            resolvers: resolvers.clone(),
        };
        #[cfg(not(feature = "network"))]
        let client = Self {
            config,
            cache,
            #[cfg(feature = "did_example")]
            did_example_cache: did_example::DiDExampleCache::new(),
            resolvers,
        };

        #[cfg(feature = "network")]
        {
            if client.config.service_address.is_some() {
                // Running in network mode

                // Channel to communicate from SDK to network task
                let (sdk_tx, mut task_rx) = mpsc::channel(32);
                // Channel to communicate from network task to SDK
                let (task_tx, sdk_rx) = mpsc::channel(32);

                client.network_task_tx = Some(sdk_tx);
                client.network_task_rx = Some(Arc::new(Mutex::new(sdk_rx)));

                // Start the network task
                let _config = client.config.clone();
                tokio::spawn(async move {
                    let _ = NetworkTask::run(_config, &mut task_rx, &task_tx).await;
                });

                if let Some(arc_rx) = client.network_task_rx.as_ref() {
                    // Wait for the network task to be ready
                    let mut rx = arc_rx.lock().await;
                    rx.recv().await.unwrap();
                }
            }
        }

        Ok(client)
    }

    pub async fn wasm_resolve(&self, did: &str) -> Result<JsValue, DIDCacheError> {
        let response = self.resolve(did).await?;

        match serde_wasm_bindgen::to_value(&response.doc) {
            Ok(values) => Ok(values),
            Err(err) => Err(DIDCacheError::DIDError(format!(
                "Error serializing DID Document: {err}",
            ))),
        }
    }

    #[cfg(feature = "did_example")]
    pub fn add_example_did(&mut self, doc: &str) -> Result<(), DIDCacheError> {
        self.did_example_cache
            .insert_from_string(doc)
            .map_err(|e| DIDCacheError::DIDError(format!("Couldn't parse example DID: {e}")))
    }
}

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

    const DID_KEY: &str = "did:key:z6MkiToqovww7vYtxm1xNM15u9JzqzUFZ1k7s7MazYJUyAxv";

    async fn basic_local_client() -> DIDCacheClient {
        let config = config::DIDCacheConfigBuilder::default().build();
        DIDCacheClient::new(config).await.unwrap()
    }

    // -----------------------------------------------------------------------
    // Cache operations
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn remove_existing_cached_did() {
        let client = basic_local_client().await;

        // Resolve a DID which automatically adds it to the cache
        let response = client.resolve(DID_KEY).await.unwrap();
        let removed_doc = client.remove(DID_KEY).await;
        assert_eq!(removed_doc, Some(response.doc));
    }

    #[tokio::test]
    async fn remove_non_existing_cached_did() {
        let client = basic_local_client().await;

        // We haven't resolved the cache, so it shouldn't be in the cache
        let removed_doc = client.remove(DID_KEY).await;
        assert_eq!(removed_doc, None);
    }

    #[tokio::test]
    async fn resolve_returns_cache_hit_on_second_call() {
        let client = basic_local_client().await;

        let first = client.resolve(DID_KEY).await.unwrap();
        assert!(!first.cache_hit);

        let second = client.resolve(DID_KEY).await.unwrap();
        assert!(second.cache_hit);
        assert_eq!(first.doc, second.doc);
        assert_eq!(first.did_hash, second.did_hash);
    }

    #[tokio::test]
    async fn add_did_document_makes_it_retrievable() {
        let mut client = basic_local_client().await;

        // Resolve to get a valid document, then remove it
        let response = client.resolve(DID_KEY).await.unwrap();
        client.remove(DID_KEY).await;

        // Manually add it back
        client.add_did_document(DID_KEY, response.doc.clone()).await;

        // Should be a cache hit now
        let cached = client.resolve(DID_KEY).await.unwrap();
        assert!(cached.cache_hit);
        assert_eq!(cached.doc, response.doc);
    }

    #[tokio::test]
    async fn get_cache_returns_shared_cache() {
        let client = basic_local_client().await;
        client.resolve(DID_KEY).await.unwrap();

        let cache = client.get_cache();
        let hash = DIDCacheClient::hash_did(DID_KEY);
        assert!(cache.get(&hash).await.is_some());
    }

    #[tokio::test]
    async fn clone_shares_cache() {
        let client = basic_local_client().await;
        let cloned = client.clone();

        // Resolve on original, visible on clone
        client.resolve(DID_KEY).await.unwrap();
        let from_clone = cloned.resolve(DID_KEY).await.unwrap();
        assert!(from_clone.cache_hit);
    }

    // -----------------------------------------------------------------------
    // resolve() validation
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn resolve_rejects_did_exceeding_size_limit() {
        let config = config::DIDCacheConfigBuilder::default()
            .with_max_did_size_in_bytes(20)
            .build();
        let client = DIDCacheClient::new(config).await.unwrap();

        let result = client.resolve(DID_KEY).await;
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("exceeds the limit"), "got: {err}");
    }

    #[tokio::test]
    async fn resolve_rejects_malformed_did() {
        let client = basic_local_client().await;

        // Not enough colons
        let result = client.resolve("not-a-did").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn resolve_rejects_too_many_parts() {
        let config = config::DIDCacheConfigBuilder::default()
            .with_max_did_parts(1)
            .build();
        let client = DIDCacheClient::new(config).await.unwrap();

        // did:peer DIDs have multiple dot-separated parts in method-specific-id
        let did = "did:peer:2.Vz6MkiToqovww7vYtxm1xNM15u9JzqzUFZ1k7s7MazYJUyAxv.EzQ3shQLqRUza6AMJFbPuMdvFRFWm1wKviQRnQSC1fScovJN4s";
        let result = client.resolve(did).await;
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("keys and/or services"), "got: {err}");
    }

    #[tokio::test]
    async fn resolve_populates_response_fields() {
        let client = basic_local_client().await;

        let response = client.resolve(DID_KEY).await.unwrap();
        assert_eq!(response.did, DID_KEY);
        assert_eq!(response.method, DIDMethod::KEY);
        assert_eq!(response.did_hash, DIDCacheClient::hash_did(DID_KEY));
        assert!(!response.cache_hit);
        assert_eq!(response.doc.id.as_str(), DID_KEY);
    }

    // -----------------------------------------------------------------------
    // hash_did
    // -----------------------------------------------------------------------

    #[test]
    fn hash_did_is_deterministic() {
        let hash1 = DIDCacheClient::hash_did(DID_KEY);
        let hash2 = DIDCacheClient::hash_did(DID_KEY);
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn hash_did_differs_for_different_dids() {
        let hash1 = DIDCacheClient::hash_did("did:key:abc");
        let hash2 = DIDCacheClient::hash_did("did:key:def");
        assert_ne!(hash1, hash2);
    }

    // -----------------------------------------------------------------------
    // DIDMethod Display / TryFrom
    // -----------------------------------------------------------------------

    #[test]
    fn did_method_display_roundtrips() {
        let methods = [
            DIDMethod::ETHR,
            DIDMethod::JWK,
            DIDMethod::KEY,
            DIDMethod::PEER,
            DIDMethod::PKH,
            DIDMethod::WEB,
            DIDMethod::WEBVH,
            DIDMethod::CHEQD,
            DIDMethod::SCID,
        ];
        for method in &methods {
            let s = method.to_string();
            let back: DIDMethod = s.as_str().try_into().unwrap();
            assert_eq!(&back, method);
        }
    }

    #[test]
    fn did_method_try_from_is_case_insensitive() {
        let result: Result<DIDMethod, _> = "KEY".try_into();
        assert_eq!(result.unwrap(), DIDMethod::KEY);

        let result: Result<DIDMethod, _> = "Ethr".try_into();
        assert_eq!(result.unwrap(), DIDMethod::ETHR);
    }

    #[test]
    fn did_method_try_from_string_works() {
        let result: Result<DIDMethod, _> = String::from("peer").try_into();
        assert_eq!(result.unwrap(), DIDMethod::PEER);
    }

    #[test]
    fn did_method_try_from_unknown_returns_error() {
        let result: Result<DIDMethod, _> = "unknown".try_into();
        assert!(result.is_err());
        match result.unwrap_err() {
            DIDCacheError::UnsupportedMethod(m) => assert_eq!(m, "unknown"),
            other => panic!("expected UnsupportedMethod, got: {other:?}"),
        }
    }

    // -----------------------------------------------------------------------
    // DIDMethod::is_mutable
    // -----------------------------------------------------------------------

    #[test]
    fn immutable_methods_are_not_mutable() {
        assert!(!DIDMethod::KEY.is_mutable());
        assert!(!DIDMethod::PEER.is_mutable());
        assert!(!DIDMethod::JWK.is_mutable());
        assert!(!DIDMethod::ETHR.is_mutable());
        assert!(!DIDMethod::PKH.is_mutable());
        assert!(!DIDMethod::EXAMPLE.is_mutable());
    }

    #[test]
    fn mutable_methods_are_mutable() {
        assert!(DIDMethod::WEB.is_mutable());
        assert!(DIDMethod::WEBVH.is_mutable());
        assert!(DIDMethod::CHEQD.is_mutable());
        assert!(DIDMethod::SCID.is_mutable());
    }

    // -----------------------------------------------------------------------
    // Per-method cache TTL
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn immutable_did_survives_beyond_ttl() {
        // Configure a very short TTL (1 second)
        let config = config::DIDCacheConfigBuilder::default()
            .with_cache_ttl(1)
            .build();
        let client = DIDCacheClient::new(config).await.unwrap();

        // Resolve a did:key (immutable)
        client.resolve(DID_KEY).await.unwrap();

        // Wait longer than the TTL
        tokio::time::sleep(Duration::from_secs(2)).await;

        // Sync Moka's internal state so expired entries are actually evicted
        client.cache.run_pending_tasks().await;

        // Immutable DID should still be cached (no TTL applied)
        let result = client.resolve(DID_KEY).await.unwrap();
        assert!(
            result.cache_hit,
            "immutable did:key should survive beyond TTL"
        );
    }
}
