//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
//! against the live app and print the per-resource changes. Read-only:
//! builds a bundle (manifest + script sources) and POSTs it to the
//! server's plan endpoint, which computes the diff.

use std::path::Path;

use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};

use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};

/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
/// slug, so its token is stored under this reserved marker.
pub const TREE_TOKEN_KEY: &str = "<tree>";

pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;

    let manifest = Manifest::load_with_env(manifest_path, env)?;
    let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
    let bundle = build_bundle(&manifest, base_dir)?;
    let kind = if manifest.is_group() {
        NodeKind::Group
    } else {
        NodeKind::App
    };

    // §3 M3: preview the gate against the GOVERNING project (own block, else the
    // nearest ancestor's), symmetric with `apply::run` — so `pic plan --file` on
    // a leaf still surfaces `approvals_required` for the gate the apply enforces.
    let governing = manifest
        .project
        .clone()
        .or_else(|| crate::cmds::apply::find_governing_project(manifest_path));
    let plan = client
        .plan_node(kind, manifest.slug(), &bundle, governing.as_ref())
        .await?;
    // Record the bound-plan token so a subsequent `pic apply` can detect the
    // node changing underneath the reviewed plan (best-effort — a read-only
    // plan still succeeds if the project dir isn't writable).
    if !plan.state_token.is_empty() {
        if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
            eprintln!("warning: could not record plan state for `pic apply`: {e}");
        }
    }
    render(&plan, mode);
    Ok(())
}

/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
/// `picloud.toml` under `root` — against the live group/app subtree.
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;
    let (bundle, _count, project) = crate::discover::build_tree(dir, env)?;
    let plan = client.plan_tree(&bundle, project.as_ref()).await?;
    if !plan.state_token.is_empty() {
        if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
            eprintln!("warning: could not record plan state for `pic apply`: {e}");
        }
    }
    render_tree(&plan, mode);
    Ok(())
}

fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
    let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
    for n in &plan.nodes {
        let node = format!("{}:{}", n.kind, n.slug);
        if let Some(s) = &n.structure {
            table.row([
                node.clone(),
                "structure".to_string(),
                s.status.clone(),
                format!(
                    "server={}",
                    s.server_parent.clone().unwrap_or_else(|| "<root>".into())
                ),
                format!(
                    "manifest={}",
                    s.manifest_parent.clone().unwrap_or_else(|| "<root>".into())
                ),
            ]);
        }
        if let Some(o) = &n.ownership {
            table.row([
                node.clone(),
                "ownership".to_string(),
                o.action.clone(),
                o.owner.clone().unwrap_or_default(),
                String::new(),
            ]);
            for b in &o.blast_radius {
                table.row([
                    node.clone(),
                    "blast_radius".to_string(),
                    b.project.clone(),
                    format!("{} app(s)", b.apps),
                    String::new(),
                ]);
            }
        }
        let groups: [(&str, &Vec<ChangeDto>); 10] = [
            ("script", &n.scripts),
            ("route", &n.routes),
            ("trigger", &n.triggers),
            ("secret", &n.secrets),
            ("var", &n.vars),
            ("extension_point", &n.extension_points),
            ("collection", &n.collections),
            ("suppression", &n.suppressions),
            ("workflow", &n.workflows),
            ("interceptor", &n.interceptors),
        ];
        for (rk, changes) in groups {
            for c in changes {
                table.row([
                    node.clone(),
                    rk.to_string(),
                    c.op.clone(),
                    c.key.clone(),
                    c.detail.clone().unwrap_or_default(),
                ]);
            }
        }
        for w in &n.warnings {
            table.row([
                node.clone(),
                "warning".to_string(),
                String::new(),
                w.clone(),
                String::new(),
            ]);
        }
    }
    table.print(mode);
    render_approvals(&plan.approvals_required, mode);
}

/// §3 M3: surface the confirm-required environments so CI sees the gate at plan
/// time, before an `apply --approve <env>` is needed.
fn render_approvals(approvals_required: &[String], mode: OutputMode) {
    if mode != OutputMode::Json && !approvals_required.is_empty() {
        eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
        for e in approvals_required {
            eprintln!("  - {e}");
        }
    }
}

/// Surface the desired-state warnings `apply` would emit, so `pic plan` is a
/// faithful preview for CI (disabled binding, unreachable endpoint, dangling
/// suppress). JSON callers read them off the `warnings` field.
fn render_warnings(warnings: &[String], mode: OutputMode) {
    if mode != OutputMode::Json && !warnings.is_empty() {
        eprintln!("\nwarnings:");
        for w in warnings {
            eprintln!("  - {w}");
        }
    }
}

/// Assemble the wire bundle: scripts carry inlined source (read from
/// their `file`), routes pass through, triggers flatten into a tagged
/// array, secrets are names only.
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
    let mut scripts = Vec::with_capacity(manifest.scripts.len());
    for s in &manifest.scripts {
        let source = std::fs::read_to_string(base_dir.join(&s.file))
            .with_context(|| format!("reading script source {}", s.file))?;
        let mut obj = Map::new();
        obj.insert("name".into(), json!(s.name));
        obj.insert("source".into(), json!(source));
        obj.insert("kind".into(), serde_json::to_value(s.kind)?);
        if let Some(d) = &s.description {
            obj.insert("description".into(), json!(d));
        }
        if let Some(t) = s.timeout_seconds {
            obj.insert("timeout_seconds".into(), json!(t));
        }
        if let Some(m) = s.memory_limit_mb {
            obj.insert("memory_limit_mb".into(), json!(m));
        }
        if let Some(sb) = &s.sandbox {
            obj.insert("sandbox".into(), serde_json::to_value(sb)?);
        }
        obj.insert("enabled".into(), json!(s.enabled));
        scripts.push(Value::Object(obj));
    }

    let routes = manifest
        .routes
        .iter()
        .map(serde_json::to_value)
        .collect::<Result<Vec<_>, _>>()?;

    let t = &manifest.triggers;
    let mut triggers = Vec::new();
    for s in &t.kv {
        triggers.push(tagged("kv", s)?);
    }
    for s in &t.docs {
        triggers.push(tagged("docs", s)?);
    }
    for s in &t.files {
        triggers.push(tagged("files", s)?);
    }
    for s in &t.cron {
        triggers.push(tagged("cron", s)?);
    }
    for s in &t.pubsub {
        triggers.push(tagged("pubsub", s)?);
    }
    for s in &t.email {
        triggers.push(tagged("email", s)?);
    }
    for s in &t.queue {
        triggers.push(tagged("queue", s)?);
    }

    // Vars: key → JSON value. TOML values round-trip to JSON via serde so the
    // wire shape matches the server's `serde_json::Value`.
    let mut vars = Map::new();
    for (k, v) in &manifest.vars {
        vars.insert(
            k.clone(),
            serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?,
        );
    }

    Ok(json!({
        "scripts": scripts,
        "routes": routes,
        "triggers": triggers,
        "secrets": manifest.secrets.names,
        "vars": vars,
        "extension_points": manifest.extension_points(),
        "collections": manifest
            .collections()
            .into_iter()
            .map(|(name, kind)| json!({ "name": name, "kind": kind }))
            .collect::<Vec<_>>(),
        "suppress_triggers": manifest.suppress.triggers,
        "suppress_routes": manifest.suppress.routes,
        "workflows": manifest
            .workflows
            .iter()
            .map(workflow_to_wire)
            .collect::<Result<Vec<_>>>()?,
        // §9.4: expand each `[[interceptors]]` entry's `ops` into one wire
        // marker per (service, op).
        "interceptors": manifest
            .interceptors
            .iter()
            .flat_map(|i| {
                i.ops.iter().map(move |op| {
                    json!({ "service": i.service, "op": op, "script": i.script })
                })
            })
            .collect::<Vec<_>>(),
    }))
}

/// Convert a `[[workflows]]` manifest block to the server `BundleWorkflow`
/// shape `{ name, enabled, definition: { steps } }`. The manifest authors steps
/// at the workflow's top level; the wire nests them under `definition`.
fn workflow_to_wire(w: &crate::manifest::ManifestWorkflow) -> Result<Value> {
    let steps = serde_json::to_value(&w.steps)
        .with_context(|| format!("encoding workflow `{}`", w.name))?;
    Ok(json!({
        "name": w.name,
        "enabled": w.enabled,
        "definition": { "steps": steps },
    }))
}

/// Serialize a trigger spec and stamp its `kind` discriminator.
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
    let mut v = serde_json::to_value(spec)?;
    if let Value::Object(map) = &mut v {
        map.insert("kind".into(), Value::String(kind.to_string()));
    }
    Ok(v)
}

fn render(plan: &PlanDto, mode: OutputMode) {
    let mut table = Table::new(["kind", "op", "resource", "detail"]);
    if let Some(o) = &plan.ownership {
        table.row([
            "ownership".to_string(),
            o.action.clone(),
            o.owner.clone().unwrap_or_default(),
            String::new(),
        ]);
        for b in &o.blast_radius {
            table.row([
                "blast_radius".to_string(),
                b.project.clone(),
                format!("{} app(s)", b.apps),
                String::new(),
            ]);
        }
    }
    let groups: [(&str, &Vec<ChangeDto>); 10] = [
        ("script", &plan.scripts),
        ("route", &plan.routes),
        ("trigger", &plan.triggers),
        ("secret", &plan.secrets),
        ("var", &plan.vars),
        ("extension_point", &plan.extension_points),
        ("collection", &plan.collections),
        ("suppression", &plan.suppressions),
        ("workflow", &plan.workflows),
        ("interceptor", &plan.interceptors),
    ];
    for (kind, changes) in groups {
        for c in changes {
            table.row([
                kind.to_string(),
                c.op.clone(),
                c.key.clone(),
                c.detail.clone().unwrap_or_default(),
            ]);
        }
    }
    table.print(mode);
    render_approvals(&plan.approvals_required, mode);
    render_warnings(&plan.warnings, mode);
}
