//! Shared on-disk config/state directory. Both the desktop app and the
//! `cambium` CLI read this directory; it's what makes them act as one
//! device (same device identity, same host-key pins, same session token,
//! same per-workspace changelists/manifests).
//!
//! Layout under `CambiumDirs::resolve()`'s root:
//!   pins.json, device.json, workspaces.json, session.json  — top-level files
//!   client-state/<sha256(key)>.json                         — via state_store,
//!     keys "changelist::<key>" and "synced::<key>"
//!
//! All NEW file types (pins/device/workspaces/session/changelist) carry
//! `"formatVersion": 1` and use camelCase field names. The synced-hash
//! manifest predates this module and has neither: it is a bare
//! `{"path": "hash-or-null", ...}` object, byte- and path-scheme-compatible
//! with the GUI's existing `synced::<key>` state-store files.

use crate::state_store;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

/// Read `path` as JSON, refusing files whose `formatVersion` is newer than
/// this program understands (1). Returns `Ok(None)` if the file is missing.
pub(crate) fn read_versioned<T: DeserializeOwned>(path: &Path, label: &str) -> Result<Option<T>, String> {
    let text = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e.to_string()),
    };
    let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
    if let Some(v) = value.get("formatVersion").and_then(|v| v.as_u64()) {
        if v > 1 {
            return Err(format!(
                "{label} was written by a newer Cambium (format {v}); update this program."
            ));
        }
    }
    let parsed: T = serde_json::from_value(value).map_err(|e| e.to_string())?;
    Ok(Some(parsed))
}

pub(crate) fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
    let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
    state_store::write_atomic(path, &json).map_err(|e| e.to_string())
}

// ------------------------------------------------------------------ dirs

pub struct CambiumDirs {
    pub root: PathBuf,
}

impl CambiumDirs {
    /// `$CAMBIUM_CONFIG_DIR` override, else the per-OS path from the spec:
    /// Linux `~/.config/cambium`, macOS `~/Library/Application Support/Cambium`,
    /// Windows `%APPDATA%\Cambium`. Creates the directory if missing.
    pub fn resolve() -> Result<Self, String> {
        let root = if let Ok(dir) = std::env::var("CAMBIUM_CONFIG_DIR") {
            PathBuf::from(dir)
        } else {
            let base = dirs::config_dir()
                .ok_or_else(|| "could not determine the OS config directory".to_string())?;
            let leaf = if cfg!(any(target_os = "macos", target_os = "windows")) {
                "Cambium"
            } else {
                "cambium"
            };
            base.join(leaf)
        };
        std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
        Ok(Self { root })
    }

    /// Base dir passed to `state_store::state_file_path` — which appends
    /// "client-state/" itself, so per-key files land at `<root>/client-state/`.
    pub fn state_base(&self) -> &Path {
        &self.root
    }
}

// ------------------------------------------------------------- device.json

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceFile {
    format_version: u32,
    device_id: String,
    device_name: String,
}

pub struct DeviceIdentity {
    pub device_id: String,
    pub device_name: String,
}

impl DeviceIdentity {
    /// Read device.json; create it (uuid v4 + hostname, or the fallbacks)
    /// when missing. A second call always reads the existing file — the
    /// fallbacks only apply to the file's initial creation.
    pub fn load_or_create(
        dirs: &CambiumDirs,
        fallback_id: Option<String>,
        fallback_name: Option<String>,
    ) -> Result<Self, String> {
        let path = dirs.root.join("device.json");
        if let Some(existing) = read_versioned::<DeviceFile>(&path, "device.json")? {
            return Ok(Self {
                device_id: existing.device_id,
                device_name: existing.device_name,
            });
        }
        let device_id = fallback_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
        let device_name = fallback_name.unwrap_or_else(crate::workspace::get_device_name);
        write_json(
            &path,
            &DeviceFile {
                format_version: 1,
                device_id: device_id.clone(),
                device_name: device_name.clone(),
            },
        )?;
        Ok(Self {
            device_id,
            device_name,
        })
    }
}

// ---------------------------------------------------------------- pins.json

#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PinsFile {
    format_version: u32,
    pins: BTreeMap<String, String>,
}

pub struct PinStore<'a> {
    dirs: &'a CambiumDirs,
}

impl<'a> PinStore<'a> {
    pub fn new(dirs: &'a CambiumDirs) -> PinStore<'a> {
        PinStore { dirs }
    }

    fn path(&self) -> PathBuf {
        self.dirs.root.join("pins.json")
    }

    fn load(&self) -> Result<PinsFile, String> {
        Ok(read_versioned::<PinsFile>(&self.path(), "pins.json")?.unwrap_or_default())
    }

    pub fn get(&self, host: &str) -> Result<Option<String>, String> {
        Ok(self.load()?.pins.get(host).cloned())
    }

    pub fn set(&self, host: &str, fingerprint: &str) -> Result<(), String> {
        let mut file = self.load()?;
        file.format_version = 1;
        file.pins.insert(host.to_string(), fingerprint.to_string());
        write_json(&self.path(), &file)
    }

    pub fn remove(&self, host: &str) -> Result<(), String> {
        let mut file = self.load()?;
        file.format_version = 1;
        file.pins.remove(host);
        write_json(&self.path(), &file)
    }

    pub fn all(&self) -> Result<BTreeMap<String, String>, String> {
        Ok(self.load()?.pins)
    }
}

// ----------------------------------------------------------- workspaces.json

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceEntry {
    pub name: String,
    pub folder: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspacesFile {
    pub format_version: u32,
    pub active_project: Option<String>,
    pub workspaces: BTreeMap<String, WorkspaceEntry>,
}

impl WorkspacesFile {
    fn path(dirs: &CambiumDirs) -> PathBuf {
        dirs.root.join("workspaces.json")
    }

    pub fn load(dirs: &CambiumDirs) -> Result<Self, String> {
        Ok(
            read_versioned::<WorkspacesFile>(&Self::path(dirs), "workspaces.json")?.unwrap_or(
                WorkspacesFile {
                    format_version: 1,
                    active_project: None,
                    workspaces: BTreeMap::new(),
                },
            ),
        )
    }

    pub fn save(&self, dirs: &CambiumDirs) -> Result<(), String> {
        write_json(&Self::path(dirs), self)
    }
}

// -------------------------------------------------------------- session.json

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCache {
    pub format_version: u32,
    pub host: String,
    pub username: String,
    pub token: String,
}

impl SessionCache {
    fn path(dirs: &CambiumDirs) -> PathBuf {
        dirs.root.join("session.json")
    }

    pub fn load(dirs: &CambiumDirs) -> Result<Option<Self>, String> {
        read_versioned::<SessionCache>(&Self::path(dirs), "session.json")
    }

    pub fn save(&self, dirs: &CambiumDirs) -> Result<(), String> {
        write_json(&Self::path(dirs), self)
    }

    pub fn clear(dirs: &CambiumDirs) -> Result<(), String> {
        match std::fs::remove_file(Self::path(dirs)) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(e.to_string()),
        }
    }
}

// --------------------------------------------------- changelists + manifests

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Changelist {
    pub add: BTreeSet<String>,
    pub del: BTreeSet<String>,
    pub del_folders: BTreeSet<String>,
}

#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangelistFile {
    format_version: u32,
    #[serde(flatten)]
    changelist: Changelist,
}

/// key is the GUI's `<projectId>::<@user>::<workspacePath>` string.
pub fn load_changelist(dirs: &CambiumDirs, key: &str) -> Result<Changelist, String> {
    let path = state_store::state_file_path(dirs.state_base(), &format!("changelist::{key}"));
    Ok(
        read_versioned::<ChangelistFile>(&path, "changelist")?
            .unwrap_or_default()
            .changelist,
    )
}

pub fn save_changelist(dirs: &CambiumDirs, key: &str, cl: &Changelist) -> Result<(), String> {
    let path = state_store::state_file_path(dirs.state_base(), &format!("changelist::{key}"));
    let file = ChangelistFile {
        format_version: 1,
        changelist: Changelist {
            add: cl.add.clone(),
            del: cl.del.clone(),
            del_folders: cl.del_folders.clone(),
        },
    };
    write_json(&path, &file)
}

/// Manifest: path -> base hash (None = base unknown). Stored under
/// state-store key "synced::<key>" — the SAME key+format the GUI uses
/// today, so both programs read one manifest. Bare object, no
/// formatVersion wrapper (it predates this module).
pub fn load_manifest(
    dirs: &CambiumDirs,
    key: &str,
) -> Result<BTreeMap<String, Option<String>>, String> {
    let path = state_store::state_file_path(dirs.state_base(), &format!("synced::{key}"));
    match std::fs::read_to_string(&path) {
        Ok(s) => serde_json::from_str(&s).map_err(|e| e.to_string()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
        Err(e) => Err(e.to_string()),
    }
}

pub fn save_manifest(
    dirs: &CambiumDirs,
    key: &str,
    m: &BTreeMap<String, Option<String>>,
) -> Result<(), String> {
    let path = state_store::state_file_path(dirs.state_base(), &format!("synced::{key}"));
    // Compact, no pretty-printing: matches the GUI's `JSON.stringify(obj)`
    // byte-for-byte so this file remains the same file across programs.
    let json = serde_json::to_string(m).map_err(|e| e.to_string())?;
    state_store::write_atomic(&path, &json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------- tests

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

    // CAMBIUM_CONFIG_DIR is process-global; serialize every test that
    // touches it so parallel `cargo test` threads don't stomp each other.
    static ENV_GUARD: Mutex<()> = Mutex::new(());

    struct EnvGuard {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
    }

    impl Drop for EnvGuard {
        fn drop(&mut self) {
            std::env::remove_var("CAMBIUM_CONFIG_DIR");
        }
    }

    fn test_dirs() -> (EnvGuard, CambiumDirs) {
        let lock = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("CAMBIUM_CONFIG_DIR", tmp.path());
        let dirs = CambiumDirs::resolve().unwrap();
        (
            EnvGuard {
                _lock: lock,
                _tmp: tmp,
            },
            dirs,
        )
    }

    #[test]
    fn device_identity_created_once() {
        let (_g, dirs) = test_dirs();
        let a = DeviceIdentity::load_or_create(&dirs, None, None).unwrap();
        let b = DeviceIdentity::load_or_create(&dirs, Some("other".into()), None).unwrap();
        assert_eq!(a.device_id, b.device_id); // second call reads, not recreates
        assert_eq!(a.device_id.len(), 36); // uuid v4
        assert!(!a.device_name.is_empty());
    }

    #[test]
    fn fallback_id_used_when_creating() {
        let (_g, dirs) = test_dirs();
        let a =
            DeviceIdentity::load_or_create(&dirs, Some("legacy-uuid".into()), Some("mac".into()))
                .unwrap();
        assert_eq!(a.device_id, "legacy-uuid");
        assert_eq!(a.device_name, "mac");
    }

    #[test]
    fn pins_roundtrip_and_remove() {
        let (_g, dirs) = test_dirs();
        let store = PinStore::new(&dirs);
        assert_eq!(store.get("host1").unwrap(), None);
        assert_eq!(store.all().unwrap(), BTreeMap::new());

        store.set("host1", "fingerprint-a").unwrap();
        store.set("host2", "fingerprint-b").unwrap();
        assert_eq!(
            store.get("host1").unwrap(),
            Some("fingerprint-a".to_string())
        );
        let all = store.all().unwrap();
        assert_eq!(all.len(), 2);
        assert_eq!(all.get("host2"), Some(&"fingerprint-b".to_string()));

        store.remove("host1").unwrap();
        assert_eq!(store.get("host1").unwrap(), None);
        assert_eq!(store.all().unwrap().len(), 1);
    }

    #[test]
    fn workspaces_roundtrip() {
        let (_g, dirs) = test_dirs();
        let mut workspaces = BTreeMap::new();
        workspaces.insert(
            "proj-1".to_string(),
            WorkspaceEntry {
                name: "My Project".to_string(),
                folder: "/workspaces/ws".to_string(),
            },
        );
        let file = WorkspacesFile {
            format_version: 1,
            active_project: Some("proj-1".to_string()),
            workspaces,
        };
        file.save(&dirs).unwrap();

        let loaded = WorkspacesFile::load(&dirs).unwrap();
        assert_eq!(loaded.active_project, Some("proj-1".to_string()));
        assert_eq!(loaded.workspaces.len(), 1);
        let entry = &loaded.workspaces["proj-1"];
        assert_eq!(entry.name, "My Project");
        assert_eq!(entry.folder, "/workspaces/ws");
    }

    #[test]
    fn newer_format_version_refused() {
        let (_g, dirs) = test_dirs();
        std::fs::write(
            dirs.root.join("workspaces.json"),
            r#"{"formatVersion": 99, "activeProject": null, "workspaces": {}}"#,
        )
        .unwrap();
        let err = WorkspacesFile::load(&dirs).unwrap_err();
        assert!(err.contains("newer Cambium"), "{err}");
    }

    #[test]
    fn changelist_and_manifest_roundtrip() {
        let (_g, dirs) = test_dirs();
        let key = "proj-1::@alice::/workspaces/ws";

        let mut cl = Changelist::default();
        cl.add.insert("Assets/a.cs".to_string());
        cl.del.insert("Assets/b.cs".to_string());
        cl.del_folders.insert("Assets/OldFolder".to_string());
        save_changelist(&dirs, key, &cl).unwrap();

        let loaded_cl = load_changelist(&dirs, key).unwrap();
        assert_eq!(loaded_cl.add, cl.add);
        assert_eq!(loaded_cl.del, cl.del);
        assert_eq!(loaded_cl.del_folders, cl.del_folders);

        let mut manifest = BTreeMap::new();
        manifest.insert("Assets/a.cs".to_string(), Some("abc123".to_string()));
        manifest.insert("Assets/unknown.cs".to_string(), None);
        save_manifest(&dirs, key, &manifest).unwrap();

        let loaded_manifest = load_manifest(&dirs, key).unwrap();
        assert_eq!(loaded_manifest, manifest);

        // Manifest file itself: bare object, no formatVersion wrapper, and
        // the null hash survives as JSON null (not the string "null").
        let path = state_store::state_file_path(dirs.state_base(), &format!("synced::{key}"));
        let raw = std::fs::read_to_string(&path).unwrap();
        let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
        assert!(value.get("formatVersion").is_none());
        assert_eq!(value["Assets/unknown.cs"], serde_json::Value::Null);
    }
}
