// Machine registry reads the flat `~/.ccd/machines/` layout.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

use crate::paths::state::{validate_machine_id, ResolvedCoordinationScope, StateLayout};

#[derive(Debug, Clone, Serialize)]
pub(crate) struct MachineRecord {
    pub id: String,
    pub display_name: Option<String>,
    pub trust_class: MachineTrustClass,
    pub profiles: Vec<String>,
    pub capabilities: Vec<String>,
    pub source: MachineSource,
    pub manifest_path: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MachineSource {
    Flat,
}

impl MachineSource {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Flat => "flat",
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct MachineManifestFile {
    machine: MachineSection,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct MachineSection {
    id: String,
    display_name: Option<String>,
    trust_class: MachineTrustClass,
    profiles: Vec<String>,
    capabilities: Vec<String>,
}

/// Read every machine manifest under `~/.ccd/machines/` (or the `ccd_root`
/// override). Fails closed on malformed manifests or duplicate ids.
pub(crate) fn load_all_machines(ccd_root: &Path) -> Result<Vec<MachineRecord>> {
    let mut records = load_flat_machines(ccd_root)?;

    records.sort_by(|a, b| a.id.cmp(&b.id));
    Ok(records)
}

fn load_flat_machines(ccd_root: &Path) -> Result<Vec<MachineRecord>> {
    let root = ccd_root.join("machines");
    let entries = read_dir_sorted(&root)?;

    let mut records: Vec<MachineRecord> = Vec::new();
    let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();

    for entry in entries {
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let manifest_path = entry.path().join("machine.toml");
        // `presence/` and any other directory without a `machine.toml` is
        // skipped so the presence subtree can coexist with identity dirs
        // under the same root.
        if !manifest_path.is_file() {
            continue;
        }
        let record = load_manifest(&manifest_path, MachineSource::Flat)?;
        if let Some(existing) = seen.get(&record.id) {
            bail!(
                "duplicate machine id `{}` in flat layout: {} and {}",
                record.id,
                existing.display(),
                manifest_path.display()
            );
        }
        seen.insert(record.id.clone(), manifest_path.clone());
        records.push(record);
    }

    Ok(records)
}

fn load_manifest(manifest_path: &Path, source: MachineSource) -> Result<MachineRecord> {
    let raw = fs::read_to_string(manifest_path)
        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
    let manifest: MachineManifestFile = toml::from_str(&raw)
        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;

    if manifest.machine.id.trim().is_empty() {
        bail!(
            "{} must declare a non-empty machine.id",
            manifest_path.display()
        );
    }
    validate_machine_id(&manifest.machine.id)?;

    for profile in &manifest.machine.profiles {
        if profile.trim().is_empty() {
            bail!(
                "{} contains an empty profile entry in machine.profiles",
                manifest_path.display()
            );
        }
    }

    Ok(MachineRecord {
        id: manifest.machine.id,
        display_name: manifest.machine.display_name,
        trust_class: manifest.machine.trust_class,
        profiles: manifest.machine.profiles,
        capabilities: manifest.machine.capabilities,
        source,
        manifest_path: manifest_path.display().to_string(),
    })
}

fn read_dir_sorted(root: &Path) -> Result<Vec<fs::DirEntry>> {
    let Ok(iter) = fs::read_dir(root) else {
        return Ok(Vec::new());
    };
    let mut entries: Vec<fs::DirEntry> = iter.collect::<std::io::Result<Vec<_>>>()?;
    entries.sort_by_key(|entry| entry.file_name());
    Ok(entries)
}

#[derive(Debug, Clone, Serialize)]
pub(crate) struct CoordinationScopeView {
    pub status: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<&'static str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub config_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shared_root: Option<String>,
}

#[derive(Debug, Clone)]
pub(crate) struct LoadedMachineIdentity {
    pub id: String,
    pub display_name: Option<String>,
    pub manifest_path: PathBuf,
    pub trust_class: MachineTrustClass,
    pub available_profiles: Vec<String>,
    pub capabilities: Vec<String>,
}

impl From<MachineRecord> for LoadedMachineIdentity {
    fn from(record: MachineRecord) -> Self {
        Self {
            id: record.id,
            display_name: record.display_name,
            manifest_path: PathBuf::from(record.manifest_path),
            trust_class: record.trust_class,
            available_profiles: record.profiles,
            capabilities: record.capabilities,
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MachineTrustClass {
    #[default]
    Owned,
    Limited,
    Observer,
}

#[derive(Debug, Clone, Serialize)]
pub(crate) struct MachineIdentityView {
    pub status: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub manifest_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trust_class: Option<MachineTrustClass>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub available_profiles: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub capabilities: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

pub(crate) fn resolve_coordination_scope_view(
    layout: &StateLayout,
    locality_id: &str,
) -> Result<CoordinationScopeView> {
    let resolved = layout.resolved_coordination_scope(locality_id)?;
    Ok(coordination_scope_view_from(resolved.as_ref()))
}

pub(crate) fn resolve_active_machine_identity(
    layout: &StateLayout,
) -> Result<Option<LoadedMachineIdentity>> {
    let profile_name = layout.profile().as_str();
    let records = load_flat_machines(layout.ccd_root())?;
    let mut matches = Vec::new();
    for record in &records {
        if record
            .profiles
            .iter()
            .any(|profile| profile == profile_name)
        {
            matches.push(record.clone());
        }
    }

    if matches.len() > 1 {
        let machine_ids = matches
            .iter()
            .map(|record| record.id.clone())
            .collect::<Vec<_>>()
            .join(", ");
        bail!(
            "profile `{profile_name}` is claimed by multiple flat machine identities ({machine_ids}); edit `~/.ccd/machines/*/machine.toml` so one active machine declares the profile"
        );
    }

    if let Some(record) = matches.into_iter().next() {
        return Ok(Some(record.into()));
    }

    Ok(None)
}

pub(crate) fn resolve_machine_identity_view(layout: &StateLayout) -> Result<MachineIdentityView> {
    let active_machine = resolve_active_machine_identity(layout)?;
    Ok(machine_identity_view_from(active_machine.as_ref(), layout))
}

fn machine_identity_view_from(
    machine_identity: Option<&LoadedMachineIdentity>,
    layout: &StateLayout,
) -> MachineIdentityView {
    match machine_identity {
        Some(identity) => MachineIdentityView {
            status: "declared",
            id: Some(identity.id.clone()),
            display_name: identity.display_name.clone(),
            manifest_path: Some(identity.manifest_path.display().to_string()),
            trust_class: Some(identity.trust_class),
            available_profiles: identity.available_profiles.clone(),
            capabilities: identity.capabilities.clone(),
            reason: None,
        },
        None => MachineIdentityView {
            status: "missing",
            id: None,
            display_name: None,
            manifest_path: None,
            trust_class: None,
            available_profiles: Vec::new(),
            capabilities: Vec::new(),
            reason: Some(format!(
                "machine identity is optional for profile/worktree-local workflows; no active flat machine identity declares profile `{}`",
                layout.profile()
            )),
        },
    }
}

fn coordination_scope_view_from(
    scope: Option<&ResolvedCoordinationScope>,
) -> CoordinationScopeView {
    match scope {
        Some(scope) => CoordinationScopeView {
            status: "configured",
            name: Some(scope.name.clone()),
            source: Some(scope.source.as_str()),
            config_path: Some(scope.config_path.display().to_string()),
            shared_root: Some(scope.shared_root.display().to_string()),
        },
        None => CoordinationScopeView {
            status: "missing",
            name: None,
            source: None,
            config_path: None,
            shared_root: None,
        },
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;

    fn write(path: &std::path::Path, body: &str) {
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, body).unwrap();
    }

    const FLAT_MANIFEST: &str = r#"[machine]
id = "laptop-amsterdam"
display_name = "Operator Laptop"
trust_class = "owned"
profiles = ["main"]
capabilities = ["git", "cargo"]
"#;

    #[test]
    fn returns_empty_when_no_layout_exists() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        let records = load_all_machines(&ccd_root).unwrap();
        assert!(records.is_empty());
    }

    #[test]
    fn reads_flat_layout_only() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/laptop-amsterdam/machine.toml"),
            FLAT_MANIFEST,
        );
        let records = load_all_machines(&ccd_root).unwrap();
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].id, "laptop-amsterdam");
        assert_eq!(records[0].source, MachineSource::Flat);
    }

    #[test]
    fn skips_presence_subdir_in_flat_layout() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/laptop-amsterdam/machine.toml"),
            FLAT_MANIFEST,
        );
        write(
            &ccd_root.join("machines/presence/laptop-amsterdam.json"),
            "{}",
        );
        let records = load_all_machines(&ccd_root).unwrap();
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].id, "laptop-amsterdam");
    }

    #[test]
    fn rejects_empty_machine_id() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/bad/machine.toml"),
            r#"[machine]
id = ""
trust_class = "owned"
profiles = ["main"]
"#,
        );
        let err = load_all_machines(&ccd_root).expect_err("empty id must fail");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("non-empty machine.id"),
            "expected non-empty machine.id error, got: {msg}"
        );
    }

    #[test]
    fn rejects_whitespace_only_machine_id() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/bad/machine.toml"),
            "[machine]\nid = \"   \"\ntrust_class = \"owned\"\nprofiles = [\"main\"]\n",
        );
        let err = load_all_machines(&ccd_root).expect_err("whitespace id must fail");
        assert!(format!("{err:#}").contains("non-empty machine.id"));
    }

    #[test]
    fn rejects_reserved_presence_machine_id() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/presence/machine.toml"),
            r#"[machine]
id = "presence"
trust_class = "owned"
profiles = ["main"]
"#,
        );
        let err = load_all_machines(&ccd_root).expect_err("reserved id must fail");
        assert!(format!("{err:#}").contains("reserved for the machine presence relay"));
    }

    #[test]
    fn rejects_empty_profile_entry() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/laptop/machine.toml"),
            r#"[machine]
id = "laptop"
trust_class = "owned"
profiles = ["main", ""]
"#,
        );
        let err = load_all_machines(&ccd_root).expect_err("empty profile entry must fail");
        assert!(format!("{err:#}").contains("empty profile entry"));
    }

    #[test]
    fn rejects_duplicate_flat_layout_ids() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/alpha/machine.toml"),
            r#"[machine]
id = "same"
trust_class = "owned"
profiles = ["main"]
"#,
        );
        write(
            &ccd_root.join("machines/beta/machine.toml"),
            r#"[machine]
id = "same"
trust_class = "owned"
profiles = ["main"]
"#,
        );
        let err = load_all_machines(&ccd_root).expect_err("duplicate flat id must fail");
        let msg = format!("{err:#}");
        assert!(msg.contains("duplicate machine id `same`"));
        assert!(msg.contains("machines/alpha/machine.toml"));
        assert!(msg.contains("machines/beta/machine.toml"));
    }

    #[test]
    fn resolves_active_machine_from_flat_profile_claim() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/laptop-amsterdam/machine.toml"),
            FLAT_MANIFEST,
        );
        let layout = StateLayout::new(
            ccd_root,
            temp.path().join("repo/.git/ccd"),
            crate::profile::ProfileName::new("main").unwrap(),
        );
        let identity = resolve_active_machine_identity(&layout)
            .unwrap()
            .expect("active machine");
        assert_eq!(identity.id, "laptop-amsterdam");
    }

    #[test]
    fn does_not_fallback_to_single_machine_without_profile_claim() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/laptop-amsterdam/machine.toml"),
            r#"[machine]
id = "laptop-amsterdam"
trust_class = "owned"
profiles = ["work"]
"#,
        );
        let layout = StateLayout::new(
            ccd_root,
            temp.path().join("repo/.git/ccd"),
            crate::profile::ProfileName::new("main").unwrap(),
        );
        let identity = resolve_active_machine_identity(&layout).unwrap();
        assert!(identity.is_none());
    }

    #[test]
    fn rejects_multiple_flat_machines_claiming_active_profile() {
        let temp = tempdir().unwrap();
        let ccd_root = temp.path().join(".ccd");
        write(
            &ccd_root.join("machines/alpha/machine.toml"),
            r#"[machine]
id = "alpha"
trust_class = "owned"
profiles = ["main"]
"#,
        );
        write(
            &ccd_root.join("machines/beta/machine.toml"),
            r#"[machine]
id = "beta"
trust_class = "owned"
profiles = ["main"]
"#,
        );
        let layout = StateLayout::new(
            ccd_root,
            temp.path().join("repo/.git/ccd"),
            crate::profile::ProfileName::new("main").unwrap(),
        );
        let err = resolve_active_machine_identity(&layout).expect_err("duplicate claims fail");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("multiple flat machine identities"),
            "got: {msg}"
        );
    }
}
