//! Precondition gate for the unified component lifecycle endpoint.
//!
//! Validates that all capability providers required by a component are operationally
//! healthy before a write operation is enqueued. Called by every handler under
//! `/api/v1/workflows/component/{op}`.
//!
//! Fast-fail path: reads `.provisioning-state.ncl` via nickel export before issuing
//! live SSH probes. A 15-second global timeout covers the entire chain.

use std::backtrace::Backtrace;
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::time::timeout;
use tracing::{debug, info, warn};

use crate::probes::{check_component, LiveStatus, ProbeConfig, ProbeReport};

const GATE_TIMEOUT_SECS: u64 = 15;

/// Describes a precondition failure with enough detail to render the plan-P3 error format.
#[derive(Debug)]
pub struct PreconditionError {
    pub kind: PreconditionErrorKind,
    pub context: String,
    source: Option<Box<dyn Error + Send + Sync>>,
    backtrace: Backtrace,
}

#[derive(Debug)]
pub enum PreconditionErrorKind {
    /// No workspace component provides the required capability.
    ProviderNotFound { capability: String },
    /// State file reports the provider is in a terminal-bad state.
    StateFailFast { component: String, state: String },
    /// Live probe returned Failed.
    LiveProbeFailed(ProbeReport),
    /// Capability resolution detected a cycle.
    CircularDependency { chain: Vec<String> },
    /// Gate timed out before all probes completed.
    Timeout { after_secs: u64 },
}

impl PreconditionError {
    fn new(kind: PreconditionErrorKind, context: impl Into<String>) -> Self {
        Self {
            kind,
            context: context.into(),
            source: None,
            backtrace: Backtrace::capture(),
        }
    }

    fn with_source(
        mut self,
        src: impl Error + Send + Sync + 'static,
    ) -> Self {
        self.source = Some(Box::new(src));
        self
    }
}

impl fmt::Display for PreconditionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.kind {
            PreconditionErrorKind::ProviderNotFound { capability } => {
                write!(f, "no provider found for capability '{capability}'")
            }
            PreconditionErrorKind::StateFailFast { component, state } => {
                write!(f, "provider '{component}' is in non-recoverable state '{state}' — fix or reinstall before retrying")
            }
            PreconditionErrorKind::LiveProbeFailed(report) => {
                write!(
                    f,
                    "provider '{}' (mode={}, server={}) live=FAILED — probe: {}",
                    report.component, report.mode, report.target, report.detail
                )
            }
            PreconditionErrorKind::CircularDependency { chain } => {
                write!(f, "circular capability dependency: {}", chain.join(" → "))
            }
            PreconditionErrorKind::Timeout { after_secs } => {
                write!(
                    f,
                    "precondition gate timed out after {after_secs}s — retry or run `prvng component health <name>`"
                )
            }
        }
    }
}

impl Error for PreconditionError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|e| e.as_ref() as &dyn Error)
    }
}

// ── Workspace types loaded via nickel export ──────────────────────────────────

#[derive(Debug, Deserialize)]
struct ExtensionMetadata {
    #[serde(default)]
    provides: Vec<CapabilityEntry>,
    #[serde(default)]
    requires: Vec<RequiresEntry>,
    #[serde(default)]
    modes: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct CapabilityEntry {
    id: String,
}

#[derive(Debug, Deserialize)]
struct RequiresEntry {
    capability: String,
    #[serde(default)]
    kind: String,
}

#[derive(Debug, Deserialize)]
struct ComponentState {
    state: Option<String>,
}

#[derive(Debug, Deserialize)]
struct StateFile {
    #[serde(flatten)]
    components: HashMap<String, ComponentState>,
}

// ── Public interface ──────────────────────────────────────────────────────────

/// Reference to a workspace component.
pub struct ComponentRef<'a> {
    pub name: &'a str,
    pub mode: &'a str,
    /// SSH target: server hostname (taskserv) or cluster controller (cluster/container).
    pub target: &'a str,
    /// Kubernetes namespace, if mode=cluster.
    pub namespace: Option<&'a str>,
    /// SSH user for probing.
    pub ssh_user: &'a str,
    /// SSH key path for probing.
    pub ssh_key_path: Option<&'a str>,
}

/// Path to a workspace.
pub struct WorkspacePath<'a> {
    /// Absolute path to workspace root (e.g. `~/project/workspaces/libre-daoshi`).
    pub root: &'a str,
    /// Infra subdirectory name under `infra/`.
    pub infra: &'a str,
    /// Absolute path to the provisioning repo root (value of `$PROVISIONING`).
    pub provisioning: &'a str,
}

/// Verify all capability preconditions for a component operation.
///
/// Returns `Ok(())` if all providers are healthy, or `Err(PreconditionError)` with
/// a structured description of the first failure found. Enforces a 15-second overall
/// timeout across all probes.
///
/// Skips gate for read-only ops (`status`, `list`, `show`, `health`) and for taskserv
/// mode (it is the root provider — no dependencies to check).
pub async fn verify_component_preconditions(
    workspace: &WorkspacePath<'_>,
    component: &ComponentRef<'_>,
    op: &str,
) -> Result<(), PreconditionError> {
    // Read-only ops and taskserv providers skip the gate.
    if matches!(op, "status" | "list" | "show" | "health") {
        return Ok(());
    }
    if component.mode == "taskserv" {
        return Ok(());
    }

    let future = run_precondition_checks(workspace, component);
    match timeout(Duration::from_secs(GATE_TIMEOUT_SECS), future).await {
        Ok(result) => result,
        Err(_) => Err(PreconditionError::new(
            PreconditionErrorKind::Timeout {
                after_secs: GATE_TIMEOUT_SECS,
            },
            format!(
                "gate timed out checking preconditions for '{}' op='{op}'",
                component.name
            ),
        )),
    }
}

async fn run_precondition_checks(
    workspace: &WorkspacePath<'_>,
    component: &ComponentRef<'_>,
) -> Result<(), PreconditionError> {
    let meta = load_extension_metadata(workspace.provisioning, component.name)?;

    // Components with no capability requirements always pass.
    if meta.requires.is_empty() {
        return Ok(());
    }

    for req in &meta.requires {
        if req.kind.to_lowercase() == "optional" {
            continue;
        }

        let provider_name =
            find_capability_provider(workspace, &req.capability, component.name)?;

        debug!(
            "precondition: '{}' requires '{}' → provider '{}'",
            component.name, req.capability, provider_name
        );

        // Fast-fail: check state file before doing SSH.
        let provider_state = load_provider_state(workspace, &provider_name);
        match provider_state.as_deref() {
            Some(s @ "failed") | Some(s @ "error") => {
                let s = s.to_string();
                return Err(PreconditionError::new(
                    PreconditionErrorKind::StateFailFast {
                        component: provider_name.clone(),
                        state: s.clone(),
                    },
                    format!(
                        "provider '{}' (for capability '{}') is in state '{s}'",
                        provider_name, req.capability,
                    ),
                ));
            }
            _ => {}
        }

        // Live probe — skipped for taskserv providers.
        //
        // Taskserv components install K8s resources or OS-level config and then complete.
        // They leave no persistent daemon to probe via SSH; the state file `running` entry
        // is the authoritative health signal. Probing via systemd would always return
        // `inactive` (exit=3) for K8s addons like CSI drivers, CNI plugins, etc.
        let provider_meta = load_extension_metadata(workspace.provisioning, &provider_name)
            .unwrap_or_else(|_| ExtensionMetadata::default());
        let provider_mode = resolve_provider_mode(workspace, &provider_name, &provider_meta);

        if provider_mode == "taskserv" {
            info!(
                "precondition OK (state-file): '{}' → '{}' mode=taskserv state={:?}",
                req.capability, provider_name, provider_state
            );
            continue;
        }

        let probe_cfg = ProbeConfig {
            ssh_user: component.ssh_user.to_string(),
            ssh_key_path: component.ssh_key_path.map(str::to_string),
            timeout: Duration::from_secs(8),
            namespace: component.namespace.map(str::to_string),
        };

        let report =
            check_component(&provider_name, &provider_mode, component.target, &probe_cfg).await;

        if report.live == LiveStatus::Failed {
            return Err(PreconditionError::new(
                PreconditionErrorKind::LiveProbeFailed(report),
                format!(
                    "provider '{}' for capability '{}' failed live probe",
                    provider_name, req.capability
                ),
            ));
        }

        info!(
            "precondition OK: '{}' → '{}' live={}",
            req.capability, provider_name, report.live
        );
    }

    Ok(())
}

// ── Helpers ───────────────────────────────────────────────────────────────────

fn load_extension_metadata(
    provisioning: &str,
    name: &str,
) -> Result<ExtensionMetadata, PreconditionError> {
    let meta_path = Path::new(provisioning)
        .join("extensions")
        .join("components")
        .join(name)
        .join("metadata.ncl");

    if !meta_path.exists() {
        return Ok(ExtensionMetadata::default());
    }

    let output = std::process::Command::new("nickel")
        .arg("export")
        .arg("--import-path")
        .arg(provisioning)
        .arg(meta_path.to_str().unwrap_or_default())
        .output()
        .map_err(|e| {
            PreconditionError::new(
                PreconditionErrorKind::ProviderNotFound {
                    capability: name.to_string(),
                },
                format!("failed to run nickel export for metadata of '{name}': {e}"),
            )
        })?;

    if !output.status.success() {
        return Ok(ExtensionMetadata::default());
    }

    Ok(serde_json::from_slice(&output.stdout).unwrap_or_else(|_| ExtensionMetadata::default()))
}

fn find_capability_provider(
    workspace: &WorkspacePath<'_>,
    capability: &str,
    requesting_component: &str,
) -> Result<String, PreconditionError> {
    let comp_dir = Path::new(workspace.root)
        .join("infra")
        .join(workspace.infra)
        .join("components");

    let entries = std::fs::read_dir(&comp_dir).map_err(|e| {
        PreconditionError::new(
            PreconditionErrorKind::ProviderNotFound {
                capability: capability.to_string(),
            },
            format!("cannot read components dir {}: {e}", comp_dir.display()),
        )
    })?;

    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
            continue;
        }
        let cname = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or_default()
            .to_string();

        // Avoid self-reference.
        if cname == requesting_component {
            continue;
        }

        let meta_path = Path::new(workspace.provisioning)
            .join("extensions")
            .join("components")
            .join(&cname)
            .join("metadata.ncl");

        if !meta_path.exists() {
            continue;
        }

        let output = std::process::Command::new("nickel")
            .arg("export")
            .arg("--import-path")
            .arg(workspace.provisioning)
            .arg(meta_path.to_str().unwrap_or_default())
            .output();

        if let Ok(out) = output {
            if out.status.success() {
                let meta: ExtensionMetadata =
                    serde_json::from_slice(&out.stdout).unwrap_or_default();
                if meta.provides.iter().any(|p| p.id == capability) {
                    return Ok(cname);
                }
            }
        }
    }

    Err(PreconditionError::new(
        PreconditionErrorKind::ProviderNotFound {
            capability: capability.to_string(),
        },
        format!(
            "no workspace component provides capability '{capability}' — required by '{requesting_component}'"
        ),
    ))
}

fn load_provider_state(workspace: &WorkspacePath<'_>, provider: &str) -> Option<String> {
    // State file lives at workspace root, not inside infra/. See domain/workspace/state.nu.
    let state_path = Path::new(workspace.root).join(".provisioning-state.ncl");

    if !state_path.exists() {
        return None;
    }

    let output = std::process::Command::new("nickel")
        .arg("export")
        .arg("--import-path")
        .arg(workspace.provisioning)
        .arg(state_path.to_str().unwrap_or_default())
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
    json.get(provider)
        .and_then(|v| v.get("state"))
        .and_then(|s| s.as_str())
        .map(str::to_string)
}

fn resolve_provider_mode(
    workspace: &WorkspacePath<'_>,
    provider: &str,
    meta: &ExtensionMetadata,
) -> String {
    // Try workspace declaration first; fall back to first catalog mode.
    let comp_ncl = Path::new(workspace.root)
        .join("infra")
        .join(workspace.infra)
        .join("components")
        .join(format!("{provider}.ncl"));

    if comp_ncl.exists() {
        let output = std::process::Command::new("nickel")
            .arg("export")
            .arg("--import-path")
            .arg(workspace.provisioning)
            .arg(comp_ncl.to_str().unwrap_or_default())
            .output();

        if let Ok(out) = output {
            if out.status.success() {
                if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
                    if let Some(mode) = json
                        .get(provider)
                        .and_then(|v| v.get("mode"))
                        .and_then(|m| m.as_str())
                    {
                        return mode.to_string();
                    }
                }
            }
        }
    }

    meta.modes.first().cloned().unwrap_or_else(|| "taskserv".to_string())
}

impl Default for ExtensionMetadata {
    fn default() -> Self {
        Self {
            provides: vec![],
            requires: vec![],
            modes: vec!["taskserv".to_string()],
        }
    }
}
