pub mod addon;
pub mod deploy;
pub mod log_collector;
pub mod registry;
pub mod watcher;

use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::debug;

use crate::config::model::{ClusterConfig, ClusterRegistryAuth};

/// Manages the lifecycle of a k3d Kubernetes cluster for a devrig project.
pub struct K3dManager {
    cluster_name: String,
    slug: String,
    kubeconfig_path: PathBuf,
    network_name: String,
    config_dir: PathBuf,
    config: ClusterConfig,
}

impl K3dManager {
    /// Create a new K3dManager for the given project slug and cluster configuration.
    pub fn new(
        slug: &str,
        config: &ClusterConfig,
        state_dir: &Path,
        network_name: &str,
        config_dir: &Path,
    ) -> Self {
        let cluster_name = format!("devrig-{}", slug);
        let kubeconfig_path = state_dir.join("kubeconfig");
        Self {
            cluster_name,
            slug: slug.to_string(),
            kubeconfig_path,
            network_name: network_name.to_string(),
            config_dir: config_dir.to_path_buf(),
            config: config.clone(),
        }
    }

    /// Create the k3d cluster if it does not already exist (idempotent).
    pub async fn create_cluster(&self) -> Result<()> {
        if self.cluster_exists().await? {
            debug!(cluster = %self.cluster_name, "cluster already exists, skipping create");
            return Ok(());
        }

        let mut args = vec![
            "cluster".to_string(),
            "create".to_string(),
            self.cluster_name.clone(),
            "--network".to_string(),
            self.network_name.clone(),
            "--agents".to_string(),
            self.config.agents.to_string(),
            "--kubeconfig-update-default=false".to_string(),
            "--kubeconfig-switch-context=false".to_string(),
            "--api-port".to_string(),
            "127.0.0.1:0".to_string(),
        ];

        for entry in &self.config.ports {
            args.push("-p".to_string());
            args.push(entry.clone());
        }

        for entry in &self.config.volumes {
            args.push("--volume".to_string());
            args.push(self.resolve_volume_path(entry));
        }

        for entry in &self.config.k3s_args {
            args.push("--k3s-arg".to_string());
            args.push(format!("{}@server:*", entry));
        }

        if self.config.registry {
            args.push("--registry-create".to_string());
            args.push(format!("k3d-{}-reg:0.0.0.0:0", self.cluster_name));
        }

        // If external registries are configured, generate registries.yaml
        if !self.config.registries.is_empty() {
            let registries_yaml = generate_registries_yaml(&self.config.registries);
            let registries_path = self.kubeconfig_path.parent()
                .unwrap_or_else(|| Path::new("."))
                .join("registries.yaml");
            std::fs::write(&registries_path, registries_yaml.as_bytes())
                .context("writing registries.yaml")?;
            args.push("--registry-config".to_string());
            args.push(registries_path.to_string_lossy().to_string());
            debug!(path = %registries_path.display(), "generated registries.yaml for external registries");
        }

        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
        self.run_k3d(&arg_refs).await?;
        debug!(cluster = %self.cluster_name, "cluster created");

        Ok(())
    }

    /// Delete the k3d cluster and remove the local kubeconfig file if it exists.
    pub async fn delete_cluster(&self) -> Result<()> {
        self.run_k3d(&["cluster", "delete", &self.cluster_name])
            .await?;
        debug!(cluster = %self.cluster_name, "cluster deleted");

        if self.kubeconfig_path.exists() {
            tokio::fs::remove_file(&self.kubeconfig_path)
                .await
                .context("removing kubeconfig file")?;
        }

        Ok(())
    }

    /// Check whether the k3d cluster already exists.
    pub async fn cluster_exists(&self) -> Result<bool> {
        let output = self.run_k3d(&["cluster", "list", "-o", "json"]).await?;
        let clusters: Vec<serde_json::Value> =
            serde_json::from_str(&output).context("parsing k3d cluster list JSON")?;
        let exists = clusters
            .iter()
            .any(|c| c.get("name").and_then(|n| n.as_str()) == Some(&self.cluster_name));
        Ok(exists)
    }

    /// Write the cluster kubeconfig to the local state directory.
    ///
    /// After writing, checks whether the kubeconfig contains an unresolved
    /// API server port (`:0`) — this happens when `--api-port 127.0.0.1:0`
    /// is used and k3d doesn't resolve the actual port. If detected, the
    /// actual port is discovered from the k3d serverlb Docker container and
    /// the kubeconfig is rewritten with the correct port.
    pub async fn write_kubeconfig(&self) -> Result<()> {
        let kubeconfig = self
            .run_k3d(&["kubeconfig", "get", &self.cluster_name])
            .await?;
        tokio::fs::write(&self.kubeconfig_path, kubeconfig.as_bytes())
            .await
            .context("writing kubeconfig file")?;

        // Fix unresolved port 0 if k3d didn't resolve it
        self.fix_kubeconfig_port().await?;

        debug!(path = %self.kubeconfig_path.display(), "kubeconfig written");
        Ok(())
    }

    /// If the kubeconfig contains a server URL with port 0, discover the actual
    /// API server port from the k3d serverlb Docker container and fix it.
    async fn fix_kubeconfig_port(&self) -> Result<()> {
        let content = tokio::fs::read_to_string(&self.kubeconfig_path)
            .await
            .context("reading kubeconfig for port fix")?;

        // Check if any server line ends with :0
        let needs_fix = content.lines().any(|line| {
            let trimmed = line.trim();
            trimmed.starts_with("server:") && trimmed.ends_with(":0")
        });

        if !needs_fix {
            return Ok(());
        }

        debug!("kubeconfig contains unresolved port 0, discovering actual API server port");

        // The k3d serverlb container proxies to the API server on port 6443.
        // Its name is k3d-{cluster_name}-serverlb.
        let container = format!("k3d-{}-serverlb", self.cluster_name);
        let output = Command::new("docker")
            .args([
                "inspect",
                &container,
                "--format",
                "{{(index .NetworkSettings.Ports \"6443/tcp\" 0).HostPort}}",
            ])
            .output()
            .await
            .context("inspecting serverlb container for API port")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "failed to discover API server port from '{}': {}",
                container,
                stderr.trim()
            );
        }

        let actual_port = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if actual_port.is_empty() || actual_port == "0" {
            bail!(
                "API server port could not be resolved (got '{}')",
                actual_port
            );
        }

        // Replace port 0 with actual port in server URLs
        let fixed = content
            .replace(
                "https://127.0.0.1:0",
                &format!("https://127.0.0.1:{}", actual_port),
            )
            .replace(
                "https://0.0.0.0:0",
                &format!("https://127.0.0.1:{}", actual_port),
            );

        tokio::fs::write(&self.kubeconfig_path, fixed.as_bytes())
            .await
            .context("writing fixed kubeconfig")?;

        debug!(port = %actual_port, "fixed kubeconfig API server port");
        Ok(())
    }

    /// Run kubectl with the cluster kubeconfig, returning stdout on success.
    pub async fn kubectl(&self, args: &[&str]) -> Result<String> {
        let output = Command::new("kubectl")
            .args(args)
            .env("KUBECONFIG", &self.kubeconfig_path)
            .output()
            .await
            .context("running kubectl")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "kubectl {} failed: {}",
                args.first().unwrap_or(&""),
                stderr.trim()
            );
        }

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }

    /// Execute a k3d command, returning stdout on success or bailing with stderr.
    async fn run_k3d(&self, args: &[&str]) -> Result<String> {
        let output = Command::new("k3d")
            .args(args)
            .output()
            .await
            .context("running k3d")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "k3d {} failed: {}",
                args.first().unwrap_or(&""),
                stderr.trim()
            );
        }

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }

    /// Return the cluster name.
    pub fn cluster_name(&self) -> &str {
        &self.cluster_name
    }

    /// Return the path to the kubeconfig file.
    pub fn kubeconfig_path(&self) -> &Path {
        &self.kubeconfig_path
    }

    /// Resolve a k3d volume spec, making the host path absolute relative to config_dir.
    ///
    /// Format: `host_path:container_path[@node_filter]`
    /// If `host_path` is relative, it's resolved against `config_dir`.
    fn resolve_volume_path(&self, spec: &str) -> String {
        // Split on first ':' to get host_path and the rest
        if let Some((host_path, rest)) = spec.split_once(':') {
            let path = Path::new(host_path);
            if path.is_relative() {
                let resolved = self.config_dir.join(path);
                // Canonicalize if possible, otherwise use the joined path
                let absolute = resolved
                    .canonicalize()
                    .unwrap_or(resolved);
                return format!("{}:{}", absolute.display(), rest);
            }
        }
        spec.to_string()
    }

    /// Return the Docker network name the cluster is attached to.
    pub fn network_name(&self) -> &str {
        &self.network_name
    }

    /// Return the project slug.
    pub fn slug(&self) -> &str {
        &self.slug
    }
}

/// Generate a k3d registries.yaml for external registry authentication.
///
/// Produces YAML with `mirrors` (to route image pulls through the registry)
/// and `configs` (to provide auth credentials) sections.
fn generate_registries_yaml(registries: &[ClusterRegistryAuth]) -> String {
    let mut yaml = String::new();
    yaml.push_str("mirrors:\n");
    for reg in registries {
        yaml.push_str(&format!("  \"{}\":\n", reg.url));
        yaml.push_str("    endpoint:\n");
        yaml.push_str(&format!("      - \"https://{}\"\n", reg.url));
    }
    yaml.push_str("configs:\n");
    for reg in registries {
        yaml.push_str(&format!("  \"{}\":\n", reg.url));
        yaml.push_str("    auth:\n");
        yaml.push_str(&format!("      username: \"{}\"\n", reg.username));
        yaml.push_str(&format!("      password: \"{}\"\n", reg.password));
    }
    yaml
}

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

    #[test]
    fn registries_yaml_single_registry() {
        let registries = vec![ClusterRegistryAuth {
            url: "ghcr.io".to_string(),
            username: "user".to_string(),
            password: "token".to_string(),
        }];
        let yaml = generate_registries_yaml(&registries);
        assert!(yaml.contains("ghcr.io"));
        assert!(yaml.contains("username: \"user\""));
        assert!(yaml.contains("password: \"token\""));
        assert!(yaml.contains("https://ghcr.io"));
    }

    #[test]
    fn registries_yaml_multiple_registries() {
        let registries = vec![
            ClusterRegistryAuth {
                url: "ghcr.io".to_string(),
                username: "user1".to_string(),
                password: "pass1".to_string(),
            },
            ClusterRegistryAuth {
                url: "docker.io".to_string(),
                username: "user2".to_string(),
                password: "pass2".to_string(),
            },
        ];
        let yaml = generate_registries_yaml(&registries);
        assert!(yaml.contains("ghcr.io"));
        assert!(yaml.contains("docker.io"));
        assert!(yaml.contains("username: \"user1\""));
        assert!(yaml.contains("username: \"user2\""));
    }

    #[test]
    fn registries_yaml_empty() {
        let yaml = generate_registries_yaml(&[]);
        assert_eq!(yaml, "mirrors:\nconfigs:\n");
    }

    fn make_k3d_mgr(config_dir: &Path) -> K3dManager {
        K3dManager::new(
            "test-abc123",
            &ClusterConfig {
                name: None,
                agents: 1,
                ports: vec![],
                volumes: vec![],
                registry: false,
                images: BTreeMap::new(),
                deploy: BTreeMap::new(),
                addons: BTreeMap::new(),
                logs: None,
                registries: vec![],
                k3s_args: vec![],
            },
            &config_dir.join(".devrig"),
            "test-net",
            config_dir,
        )
    }

    #[test]
    fn resolve_volume_absolute_path_unchanged() {
        let mgr = make_k3d_mgr(Path::new("/home/user/project"));
        assert_eq!(
            mgr.resolve_volume_path("/data:/workspace@server:*"),
            "/data:/workspace@server:*"
        );
    }

    #[test]
    fn resolve_volume_relative_path_joined_with_config_dir() {
        let mgr = make_k3d_mgr(Path::new("/home/user/project"));
        let resolved = mgr.resolve_volume_path("../:/workspace@server:*");
        // Should start with the resolved absolute path, not "../"
        assert!(!resolved.starts_with("../"), "expected absolute path, got: {resolved}");
        assert!(resolved.ends_with(":/workspace@server:*"));
    }

    #[test]
    fn resolve_volume_no_colon_unchanged() {
        let mgr = make_k3d_mgr(Path::new("/home/user/project"));
        assert_eq!(mgr.resolve_volume_path("just-a-name"), "just-a-name");
    }
}
