//! DAG domain tools — show, validate, export.

use crate::protocol::{ToolCategory, ToolError};
use crate::sources::{NclCache, get_workspace};
use crate::tool::{Context, Tool};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;

// ── dag_show ──────────────────────────────────────────────────────────────────

pub struct DagShow {
    cache: Arc<NclCache>,
}

impl DagShow {
    pub fn new(cache: Arc<NclCache>) -> Self { Self { cache } }
}

#[async_trait]
impl Tool for DagShow {
    fn name(&self) -> &'static str { "dag_show" }
    fn description(&self) -> &'static str { "Show the composition DAG for a workspace environment" }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "required": ["workspace", "env"],
            "properties": {
                "workspace": { "type": "string" },
                "env":       { "type": "string" }
            }
        })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, params: Value, ctx: &Context) -> Result<Value, ToolError> {
        let (ws_name, env_name) = extract_ws_env(&params)?;
        let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?;
        let dag = self.cache.get(&dag_path).await?;
        Ok((*dag).clone())
    }
}

// ── dag_validate ──────────────────────────────────────────────────────────────

pub struct DagValidate {
    cache: Arc<NclCache>,
}

impl DagValidate {
    pub fn new(cache: Arc<NclCache>) -> Self { Self { cache } }
}

#[async_trait]
impl Tool for DagValidate {
    fn name(&self) -> &'static str { "dag_validate" }
    fn description(&self) -> &'static str { "Validate the DAG NCL for a workspace environment" }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "required": ["workspace", "env"],
            "properties": {
                "workspace": { "type": "string" },
                "env":       { "type": "string" }
            }
        })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, params: Value, ctx: &Context) -> Result<Value, ToolError> {
        let (ws_name, env_name) = extract_ws_env(&params)?;
        let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?;

        match self.cache.get(&dag_path).await {
            Ok(dag) => {
                let formula_count = dag["composition"]["formulas"]
                    .as_array()
                    .map(|a| a.len())
                    .unwrap_or(0);
                Ok(json!({
                    "valid": true,
                    "workspace": ws_name,
                    "env": env_name,
                    "formula_count": formula_count,
                }))
            }
            Err(e) => Ok(json!({
                "valid": false,
                "workspace": ws_name,
                "env": env_name,
                "error": e.to_string(),
            })),
        }
    }
}

// ── dag_export ────────────────────────────────────────────────────────────────

pub struct DagExport {
    cache: Arc<NclCache>,
}

impl DagExport {
    pub fn new(cache: Arc<NclCache>) -> Self { Self { cache } }
}

#[async_trait]
impl Tool for DagExport {
    fn name(&self) -> &'static str { "dag_export" }
    fn description(&self) -> &'static str { "Export the DAG as a DOT graph or JSON" }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "required": ["workspace", "env"],
            "properties": {
                "workspace": { "type": "string" },
                "env":       { "type": "string" },
                "format": {
                    "type": "string",
                    "enum": ["dot", "json"],
                    "default": "dot"
                }
            }
        })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, params: Value, ctx: &Context) -> Result<Value, ToolError> {
        let (ws_name, env_name) = extract_ws_env(&params)?;
        let format = params["format"].as_str().unwrap_or("dot");
        let dag_path = resolve_dag_path(ctx, ws_name, env_name).await?;
        let dag = self.cache.get(&dag_path).await?;

        match format {
            "json" => Ok((*dag).clone()),
            "dot" => {
                let dot = dag_to_dot(&dag, ws_name, env_name)?;
                Ok(json!({ "format": "dot", "content": dot }))
            }
            f => Err(ToolError::invalid_param("format", format!("unknown format '{f}'"))),
        }
    }
}

// ── Internal helpers ──────────────────────────────────────────────────────────

fn extract_ws_env(params: &Value) -> Result<(&str, &str), ToolError> {
    let ws = params["workspace"]
        .as_str()
        .ok_or_else(|| ToolError::invalid_param("workspace", "required string"))?;
    let env = params["env"]
        .as_str()
        .ok_or_else(|| ToolError::invalid_param("env", "required string"))?;
    Ok((ws, env))
}

async fn resolve_dag_path(
    ctx: &Context,
    workspace: &str,
    env: &str,
) -> Result<std::path::PathBuf, ToolError> {
    let ws = get_workspace(&ctx.env.workspaces_root, workspace).await?;
    let dag_path = ws.path.join("infra").join(env).join("dag.ncl");
    if !dag_path.exists() {
        return Err(ToolError::not_found(format!(
            "dag.ncl not found for workspace '{workspace}' env '{env}'"
        )));
    }
    Ok(dag_path)
}

/// Convert DAG JSON to a DOT digraph string.
///
/// Expected JSON shape (from NCL export):
/// ```json
/// { "composition": { "formulas": [ { "formula_id": "x", "depends_on": [{"formula_id": "y", "condition": "Healthy"}] } ] } }
/// ```
fn dag_to_dot(dag: &Value, workspace: &str, env: &str) -> Result<String, ToolError> {
    let formulas = dag["composition"]["formulas"]
        .as_array()
        .ok_or_else(|| ToolError::invocation("dag.ncl missing composition.formulas array"))?;

    let mut lines = Vec::new();
    lines.push(format!(
        r#"digraph "{workspace}_{env}" {{"#
    ));
    lines.push(r#"  rankdir=LR;"#.to_owned());
    lines.push(r#"  node [shape=box, style=filled, fillcolor=lightyellow];"#.to_owned());

    for formula in formulas {
        let id = formula["formula_id"].as_str().unwrap_or("?");
        // Sanitise id for DOT node name (replace - with _)
        let node = sanitise_dot_id(id);
        lines.push(format!(r#"  {node} [label="{id}"];"#));

        if let Some(deps) = formula["depends_on"].as_array() {
            for dep in deps {
                let dep_id = dep["formula_id"].as_str().unwrap_or("?");
                let dep_node = sanitise_dot_id(dep_id);
                let condition = dep["condition"].as_str().unwrap_or("Done");
                lines.push(format!(r#"  {dep_node} -> {node} [label="{condition}"];"#));
            }
        }
    }

    lines.push("}".to_owned());
    Ok(lines.join("\n"))
}

fn sanitise_dot_id(s: &str) -> String {
    s.chars()
        .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
        .collect()
}

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

    #[test]
    fn dag_to_dot_single_node() {
        let dag = json!({
            "composition": {
                "formulas": [
                    { "formula_id": "node-a", "depends_on": [] }
                ]
            }
        });
        let dot = dag_to_dot(&dag, "ws", "prod").unwrap();
        assert!(dot.contains("digraph"));
        assert!(dot.contains("node_a"));
    }

    #[test]
    fn dag_to_dot_dependency_edge() {
        let dag = json!({
            "composition": {
                "formulas": [
                    { "formula_id": "a", "depends_on": [] },
                    {
                        "formula_id": "b",
                        "depends_on": [{ "formula_id": "a", "condition": "Healthy" }]
                    }
                ]
            }
        });
        let dot = dag_to_dot(&dag, "ws", "prod").unwrap();
        assert!(dot.contains("a -> b") || dot.contains("a -> b"));
        assert!(dot.contains("Healthy"));
    }

    #[test]
    fn dag_to_dot_missing_formulas_returns_error() {
        let dag = json!({ "composition": {} });
        let err = dag_to_dot(&dag, "ws", "prod").unwrap_err();
        assert!(matches!(err, ToolError::Invocation(_)));
    }

    #[test]
    fn sanitise_replaces_hyphens() {
        assert_eq!(sanitise_dot_id("my-formula-1"), "my_formula_1");
    }
}
