//! Wave A of voice-multi-instance — boot-loop for the
//! `plugin.configure` JSON-RPC payload.
//!
//! Two paths:
//! - `Single` shape ⇒ legacy back-compat. The single config block
//!   becomes the implicit `default` tenant; routed as catch-all.
//! - `Many` shape ⇒ multi-tenant declarative path: sanitise each
//!   tenant label, resolve per-tenant `data_dir`, build an
//!   `Arc<VoiceToolContext>` per entry via the caller-supplied
//!   factory, and register agent_id routing.
//!
//! `apply_configure` is generic over a factory closure so tests +
//! the subprocess `main.rs` can construct contexts with their own
//! SQLite paths / provider stacks. The subprocess wraps this with a
//! closure that opens `<state_dir>/voice/<tenant>/voice.db` and
//! instantiates the configured provider pool.

use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;

use crate::config::{VoicePluginConfig, VoicePluginShape};
use crate::instance_registry;
use crate::tools::VoiceToolContext;

/// Sanitise a tenant label. Mirrors the rules used by other nexo
/// plugins: ASCII alphanumeric + `_` + `-`, lowercased, max 64
/// chars. Rejects empty / control / path-traversal inputs.
pub fn sanitize_tenant_label(raw: &str) -> Result<String, String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err("tenant label is empty".to_string());
    }
    if trimmed.chars().count() > 64 {
        return Err(format!("tenant label `{trimmed}` exceeds 64 characters"));
    }
    for c in trimmed.chars() {
        if !(c.is_ascii_alphanumeric() || c == '_' || c == '-') {
            return Err(format!(
                "tenant label `{trimmed}` has invalid characters (allowed: [A-Za-z0-9_-])"
            ));
        }
    }
    Ok(trimmed.to_ascii_lowercase())
}

/// Resolve the per-tenant `data_dir` rooted at `base`. Tenants get
/// `<base>/voice/<tenant>/`; the legacy single-tenant default is
/// `<base>/voice/default/`.
pub fn resolve_tenant_data_dir(base: &Path, tenant: &str) -> std::path::PathBuf {
    base.join("voice").join(tenant)
}

/// Apply a `plugin.configure` payload.
///
/// `factory` constructs a `VoiceToolContext` from a (resolved) cfg
/// + per-tenant data_dir. Tests pass a stub; production wraps the
/// subprocess provider stack + SQLite store via closure capture.
///
/// Behaviour:
/// 1. Parse `value` as either single object or array shape.
/// 2. Resolve labels + reject duplicates / cross-tenant agent_id
///    collisions BEFORE touching the registry — early-abort keeps
///    a misconfigured operator from clobbering live state.
/// 3. Diff vs registry: unregister stale labels.
/// 4. Build + register each declared tenant via `factory`.
/// 5. Rebuild the agent_id → tenant route map.
/// 6. Mark the first tenant with empty `allow_agents` (or the only
///    tenant if all have lists) as catch-all.
pub async fn apply_configure<F, Fut>(
    value: serde_yaml::Value,
    data_root: &Path,
    factory: F,
) -> Result<(), String>
where
    F: Fn(VoicePluginConfig, std::path::PathBuf) -> Fut,
    Fut: std::future::Future<Output = Result<Arc<VoiceToolContext>, String>>,
{
    let shape: VoicePluginShape =
        serde_yaml::from_value(value).map_err(|e| format!("invalid voice config: {e}"))?;

    let mut configs: Vec<VoicePluginConfig> = match shape {
        VoicePluginShape::Single(c) => vec![c],
        VoicePluginShape::Many(v) => v,
    };

    if configs.is_empty() {
        // Empty array — treat as "drop all instances".
        shutdown_all_registered().await;
        instance_registry::clear_agent_routes();
        return Ok(());
    }

    let mut resolved: Vec<(String, VoicePluginConfig, std::path::PathBuf)> =
        Vec::with_capacity(configs.len());
    let mut seen_labels: HashSet<String> = HashSet::new();
    let mut claimed_agents: std::collections::HashMap<String, String> =
        std::collections::HashMap::new();
    for mut cfg in configs.drain(..) {
        let raw_label = cfg.instance.clone().unwrap_or_else(|| "default".into());
        let label = sanitize_tenant_label(&raw_label)?;
        if !seen_labels.insert(label.clone()) {
            return Err(format!("duplicate voice tenant label: `{label}`"));
        }
        for agent_id in &cfg.allow_agents {
            if let Some(prior) = claimed_agents.get(agent_id) {
                if prior != &label {
                    return Err(format!(
                        "agent_id `{agent_id}` claimed by both tenants `{prior}` and `{label}`"
                    ));
                }
            }
            claimed_agents.insert(agent_id.clone(), label.clone());
        }
        cfg.instance = Some(label.clone());
        let data_dir = resolve_tenant_data_dir(data_root, &label);
        resolved.push((label, cfg, data_dir));
    }

    // Diff vs prior registry: unregister labels no longer present.
    let prev: HashSet<String> = instance_registry::entries()
        .into_iter()
        .map(|(k, _)| k)
        .collect();
    let now: HashSet<String> = resolved.iter().map(|(k, _, _)| k.clone()).collect();
    for stale in prev.difference(&now) {
        if instance_registry::unregister(stale).is_some() {
            tracing::info!(
                target: "plugin.voice",
                tenant = %stale,
                "unregistered stale tenant"
            );
        }
    }

    // Reset agent_id routes — about to rebuild from scratch.
    instance_registry::clear_agent_routes();

    let mut catch_all_label: Option<String> = None;
    for (label, cfg, data_dir) in resolved {
        let allow_agents = cfg.allow_agents.clone();
        let ctx = factory(cfg, data_dir)
            .await
            .map_err(|e| format!("factory failed for tenant `{label}`: {e}"))?;
        instance_registry::register(&label, ctx);
        for agent_id in &allow_agents {
            instance_registry::add_agent_route(agent_id, &label);
        }
        if allow_agents.is_empty() && catch_all_label.is_none() {
            catch_all_label = Some(label.clone());
        }
        tracing::info!(
            target: "plugin.voice",
            tenant = %label,
            agent_routes = allow_agents.len(),
            "registered declared tenant"
        );
    }

    // Mark catch-all. When no tenant has empty allow_agents, the
    // first tenant wins by deterministic iteration order so a
    // misconfigured "everyone whitelisted" deployment still has a
    // fallback rather than returning Unavailable for unknown
    // agent_ids.
    if catch_all_label.is_none() {
        if let Some((first, _)) = instance_registry::entries().into_iter().next() {
            catch_all_label = Some(first);
        }
    }
    if let Some(label) = catch_all_label {
        instance_registry::set_catch_all(&label);
        tracing::info!(
            target: "plugin.voice",
            tenant = %label,
            "marked catch-all tenant"
        );
    }

    Ok(())
}

async fn shutdown_all_registered() {
    for (label, _) in instance_registry::entries() {
        if instance_registry::unregister(&label).is_some() {
            tracing::info!(
                target: "plugin.voice",
                tenant = %label,
                "configure: dropped tenant during transition"
            );
        }
    }
}

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

    #[test]
    fn sanitize_accepts_canonical_label() {
        assert_eq!(sanitize_tenant_label("empresa_a").unwrap(), "empresa_a");
        assert_eq!(sanitize_tenant_label("Marketing").unwrap(), "marketing");
        assert_eq!(sanitize_tenant_label("t-01").unwrap(), "t-01");
        assert_eq!(sanitize_tenant_label("default").unwrap(), "default");
    }

    #[test]
    fn sanitize_rejects_empty_and_dotdot_and_unicode() {
        assert!(sanitize_tenant_label("").is_err());
        assert!(sanitize_tenant_label("   ").is_err());
        assert!(sanitize_tenant_label("..").is_err());
        assert!(sanitize_tenant_label("a/b").is_err());
        assert!(sanitize_tenant_label("ana.es").is_err());
        assert!(sanitize_tenant_label("ana-ñ").is_err());
    }

    #[test]
    fn sanitize_rejects_too_long() {
        let s = "a".repeat(65);
        assert!(sanitize_tenant_label(&s).is_err());
    }

    #[test]
    fn resolve_tenant_data_dir_joins_voice_then_tenant() {
        let p = resolve_tenant_data_dir(Path::new("/var/lib/nexo"), "empresa_a");
        assert_eq!(p, std::path::PathBuf::from("/var/lib/nexo/voice/empresa_a"));
    }
}
