//! Cache utilities for device specification queries.
//!
//! `SpecClient` supports a two-level cache:
//! - In-memory FIFO cache for fast hot-path reads.
//! - Optional disk cache for persistence across process restarts.
//!
//! # Example
//!
//! ```rust,no_run
//! use mijia_spec::MijiaSpecClient;
//!
//! // Build a client with both memory and disk cache enabled.
//! // The example below demonstrates the sync API when the `sync` feature
//! // is enabled, and the async API when the `async` feature is enabled.
//! #[cfg(feature = "sync")]
//! {
//!     let client = MijiaSpecClient::new()
//!         .with_mem_cache(128)
//!         .with_disk_cache("./.cache/mijia")?;
//!
//!     let urn = "urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3:1";
//!     let spec = client.get(urn)?;
//!     println!("{}", spec.instance().description);
//! }
//!
//! #[cfg(feature = "async")]
//! async fn _example_async() -> Result<(), mijia_spec::SpecError> {
//!     let client = MijiaSpecClient::new()
//!         .with_mem_cache(128)
//!         .with_disk_cache("./.cache/mijia")?;
//!
//!     let urn = "urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3:1";
//!     let spec = client.get_async(urn).await?;
//!     println!("{}", spec.instance().description);
//!     Ok(())
//! }
//!
//! # Ok::<(), mijia_spec::SpecError>(())
//! ```

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use crate::cache::MemCache;
use crate::miot_spec_v2::{DeviceInstance, Translation};
use crate::{DeviceSpec, SpecError};

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct DiskCachePayload {
    instance: DeviceInstance,
    translation: Option<Translation>,
}

fn cache_file_name(urn: &str) -> String {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";

    let mut encoded = String::with_capacity(urn.len() * 3);
    for byte in urn.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
                encoded.push(char::from(byte));
            }
            _ => {
                encoded.push('%');
                encoded.push(char::from(HEX[(byte >> 4) as usize]));
                encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
            }
        }
    }

    format!("{encoded}.json")
}

/// Device spec query client with optional memory and disk cache.
#[derive(Debug, Clone, Default)]
pub struct SpecClient {
    mem_cache: Option<Arc<Mutex<MemCache>>>,
    disk_dir: Option<PathBuf>,
}

impl SpecClient {
    pub fn new() -> Self {
        Self::default()
    }

    /// Enables in-memory cache with a maximum number of entries.
    pub fn with_mem_cache(mut self, capacity: usize) -> Self {
        if capacity > 0 {
            self.mem_cache = Some(Arc::new(Mutex::new(MemCache::new(capacity))));
        }
        self
    }

    /// Enables disk cache and sets the storage directory.
    pub fn with_disk_cache<P: Into<PathBuf>>(mut self, dir: P) -> Result<Self, SpecError> {
        let path = dir.into();
        std::fs::create_dir_all(&path)?;
        self.disk_dir = Some(path);
        Ok(self)
    }

    /// Reads from memory cache first, then disk cache.
    fn read_cache(&self, urn: &str) -> Option<DeviceSpec> {
        if let Some(mem) = &self.mem_cache {
            if let Ok(guard) = mem.lock() {
                if let Some(spec) = guard.map.get(urn) {
                    return Some(spec.clone());
                }
            }
        }

        if let Some(dir) = &self.disk_dir {
            let file_path = dir.join(cache_file_name(urn));
            if let Ok(json) = std::fs::read_to_string(file_path)
                && let Ok(payload) = serde_json::from_str::<DiskCachePayload>(&json)
            {
                let spec = DeviceSpec::new(payload.instance, payload.translation);
                self.write_mem(urn, &spec);
                return Some(spec);
            }
        }

        None
    }

    /// Writes cache entries to disk (best effort) and memory.
    fn write_cache(&self, urn: &str, spec: &DeviceSpec) {
        if let Some(dir) = &self.disk_dir {
            let payload = DiskCachePayload {
                instance: spec.instance().clone(),
                translation: spec.translation().cloned(),
            };
            // Disk cache is best-effort and should not break the fetch flow.
            if let Ok(json) = serde_json::to_string(&payload) {
                let file_path = dir.join(cache_file_name(urn));
                let _ = std::fs::write(file_path, json);
            }
        }

        self.write_mem(urn, spec);
    }

    /// Writes cache entries to memory.
    fn write_mem(&self, urn: &str, spec: &DeviceSpec) {
        if let Some(mem) = &self.mem_cache {
            if let Ok(mut guard) = mem.lock() {
                guard.insert(urn.to_string(), spec.clone());
            }
        }
    }

    #[cfg(feature = "sync")]
    pub fn get(&self, urn: &str) -> Result<DeviceSpec, SpecError> {
        use crate::miot_spec_v2::{fetch_spec, fetch_translation};

        if let Some(spec) = self.read_cache(urn) {
            return Ok(spec);
        }

        let instance = fetch_spec(urn)?;
        let translation = fetch_translation(urn).ok();
        let spec = DeviceSpec::new(instance, translation);
        self.write_cache(urn, &spec);
        Ok(spec)
    }

    #[cfg(feature = "async")]
    pub async fn get_async(&self, urn: &str) -> Result<DeviceSpec, SpecError> {
        use crate::miot_spec_v2::{fetch_spec_async, fetch_translation_async};

        if let Some(spec) = self.read_cache(urn) {
            return Ok(spec);
        }

        let instance = fetch_spec_async(urn).await?;
        let translation = fetch_translation_async(urn).await.ok();
        let spec = DeviceSpec::new(instance, translation);
        self.write_cache(urn, &spec);
        Ok(spec)
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::SpecClient;
    use crate::DeviceSpec;
    use crate::miot_spec_v2::{Action, DeviceInstance, Property, Service};

    fn sample_spec(urn: &str) -> DeviceSpec {
        let instance = DeviceInstance {
            urn: urn.to_string(),
            description: format!("desc-{urn}"),
            services: vec![Service {
                iid: 2,
                urn: "urn:miot-spec-v2:service:light:00000001:test-device:1".to_string(),
                description: "Light service".to_string(),
                properties: vec![Property {
                    iid: 1,
                    urn: "urn:miot-spec-v2:property:power:00000006:test-device:1".to_string(),
                    description: "Power switch".to_string(),
                    format: "bool".to_string(),
                    access: vec!["read".to_string(), "write".to_string()],
                    unit: None,
                    value_range: None,
                    value_list: None,
                }],
                actions: vec![Action {
                    iid: 1,
                    urn: "urn:miot-spec-v2:action:toggle:00002801:test-device:1".to_string(),
                    description: "Toggle power".to_string(),
                    in_args: vec![],
                    out_args: vec![],
                }],
                events: vec![],
            }],
        };

        DeviceSpec::new(instance, None)
    }

    fn unique_temp_dir() -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock should be after UNIX_EPOCH")
            .as_nanos();
        std::env::temp_dir().join(format!("mijia-spec-cache-test-{nanos}"))
    }

    #[test]
    fn mem_cache_uses_fifo_eviction() {
        let client = SpecClient::new().with_mem_cache(2);
        let urn_a = "urn:miot-spec-v2:device:model-a:00000001:test-device:1";
        let urn_b = "urn:miot-spec-v2:device:model-b:00000001:test-device:1";
        let urn_c = "urn:miot-spec-v2:device:model-c:00000001:test-device:1";

        client.write_mem(urn_a, &sample_spec(urn_a));
        client.write_mem(urn_b, &sample_spec(urn_b));
        client.write_mem(urn_c, &sample_spec(urn_c));

        assert!(client.read_cache(urn_a).is_none());
        assert_eq!(client.read_cache(urn_b).unwrap().instance().urn, urn_b);
        assert_eq!(client.read_cache(urn_c).unwrap().instance().urn, urn_c);
    }

    #[test]
    fn disk_cache_roundtrip_and_mem_backfill() {
        let dir = unique_temp_dir();
        let urn = "urn:miot-spec-v2:device:model-disk:00000001:test-device:1";
        let spec = sample_spec(urn);

        let writer = SpecClient::new()
            .with_disk_cache(&dir)
            .expect("disk cache should be enabled");
        writer.write_cache(urn, &spec);

        let reader = SpecClient::new()
            .with_mem_cache(4)
            .with_disk_cache(&dir)
            .expect("disk cache should be enabled");

        let loaded = reader
            .read_cache(urn)
            .expect("spec should be read from disk");
        assert_eq!(loaded.instance(), spec.instance());
        assert_eq!(
            loaded
                .get_property(2, 1)
                .expect("cached property should be queryable")
                .name,
            "power"
        );

        let has_backfilled = reader
            .mem_cache
            .as_ref()
            .expect("memory cache should be enabled")
            .lock()
            .expect("memory cache lock should not be poisoned")
            .map
            .contains_key(urn);
        assert!(has_backfilled);

        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn with_disk_cache_creates_directory() {
        let dir = unique_temp_dir();
        assert!(!dir.exists());

        let _client = SpecClient::new()
            .with_disk_cache(&dir)
            .expect("disk cache should be enabled");

        assert!(dir.exists());

        let _ = std::fs::remove_dir_all(dir);
    }
}
