//! `pic apply [--file picloud.toml]` — reconcile the live app to the
//! manifest's desired state in one server-side transaction. Creates and
//! updates are applied; `--prune` additionally deletes live scripts/routes/
//! triggers absent from the manifest (secrets are never pruned).

use std::io::{IsTerminal, Write};
use std::path::Path;

use anyhow::{Context, Result};

use crate::client::{Client, NodeKind};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::Manifest;
use crate::output::{KvBlock, OutputMode};

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

    let manifest = Manifest::load_with_env(manifest_path, env)?;

    // Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
    // admin-gated, audited per-env approval is enforced server-side only on the
    // tree apply path. Single-node `apply --file` cannot provide that guarantee,
    // so rather than silently bypass a confirm-required env, refuse and direct
    // the user to `--dir`.
    if let (Some(env), Some(project)) = (env, &manifest.project) {
        if project
            .environments
            .iter()
            .any(|e| e.name == env && e.confirm)
        {
            anyhow::bail!(
                "environment `{env}` is confirm-required by this project's [project] policy; \
                 single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
                 Use `pic apply --dir <root> --env {env} --approve {env}` instead."
            );
        }
    }

    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
    };
    let slug = manifest.slug().to_string();

    if prune && !yes {
        confirm_prune(&slug)?;
    }

    // Bound-plan check: replay the token from the last `pic plan` (for this
    // node) so the server refuses if it changed since it was reviewed.
    // `--force` skips it; no recorded plan means no check (apply still works
    // standalone). The token is single-use — cleared after a successful apply.
    let expected_token = if force {
        None
    } else {
        crate::linkstate::read_plan(base_dir)
            .filter(|l| l.app == slug)
            .map(|l| l.state_token)
    };

    let report = client
        .apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
        .await?;
    crate::linkstate::clear_plan(base_dir, &slug);

    let label = if manifest.is_group() { "group" } else { "app" };
    let mut block = KvBlock::new();
    block
        .field(label, slug.clone())
        .field(
            "scripts",
            format!(
                "+{} ~{} -{}",
                report.scripts_created, report.scripts_updated, report.scripts_deleted
            ),
        )
        .field(
            "routes",
            format!(
                "+{} ~{} -{}",
                report.routes_created, report.routes_updated, report.routes_deleted
            ),
        )
        .field(
            "triggers",
            format!("+{} -{}", report.triggers_created, report.triggers_deleted),
        )
        .field(
            "vars",
            format!(
                "+{} ~{} -{}",
                report.vars_created, report.vars_updated, report.vars_deleted
            ),
        )
        .field(
            "extension_points",
            format!(
                "+{} -{}",
                report.extension_points_created, report.extension_points_deleted
            ),
        )
        .field(
            "collections",
            format!(
                "+{} -{}",
                report.collections_created, report.collections_deleted
            ),
        )
        .field(
            "suppressions",
            format!(
                "+{} -{}",
                report.suppressions_created, report.suppressions_deleted
            ),
        );
    for w in &report.warnings {
        block.field("warning", w.clone());
    }
    block.print(mode);
    Ok(())
}

/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
/// `picloud.toml` under `root` — in ONE atomic server transaction.
#[allow(clippy::too_many_arguments)]
pub async fn run_tree(
    dir: &Path,
    prune: bool,
    yes: bool,
    force: bool,
    env: Option<&str>,
    approve: &[String],
    mode: OutputMode,
) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;
    let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;

    if prune && !yes {
        confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
    }

    // Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
    // it needs an explicit `--approve <env>` (a TTY may confirm interactively;
    // `--yes` does NOT cover it). The server re-checks this authoritatively — the
    // local check just fails fast with a clear message.
    let approved = resolve_approvals(env, &envs, approve)?;

    let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
    let expected_token = if force {
        None
    } else {
        crate::linkstate::read_plan(dir)
            .filter(|l| l.app == token_key)
            .map(|l| l.state_token)
    };

    let report = client
        .apply_tree(&bundle, prune, expected_token.as_deref(), env, &approved)
        .await?;
    crate::linkstate::clear_plan(dir, token_key);

    let mut block = KvBlock::new();
    block
        .field("nodes", node_count.to_string())
        .field(
            "scripts",
            format!(
                "+{} ~{} -{}",
                report.scripts_created, report.scripts_updated, report.scripts_deleted
            ),
        )
        .field(
            "routes",
            format!(
                "+{} ~{} -{}",
                report.routes_created, report.routes_updated, report.routes_deleted
            ),
        )
        .field(
            "triggers",
            format!("+{} -{}", report.triggers_created, report.triggers_deleted),
        )
        .field(
            "vars",
            format!(
                "+{} ~{} -{}",
                report.vars_created, report.vars_updated, report.vars_deleted
            ),
        )
        .field(
            "extension_points",
            format!(
                "+{} -{}",
                report.extension_points_created, report.extension_points_deleted
            ),
        )
        .field(
            "collections",
            format!(
                "+{} -{}",
                report.collections_created, report.collections_deleted
            ),
        )
        .field(
            "suppressions",
            format!(
                "+{} -{}",
                report.suppressions_created, report.suppressions_deleted
            ),
        );
    for w in &report.warnings {
        block.field("warning", w.clone());
    }
    block.print(mode);
    Ok(())
}

/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
/// If the selected `env` is confirm-required (per the root manifest's
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
/// interactive TTY confirmation that requires re-typing the env name. A blanket
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
/// wire (the server re-checks authoritatively). A non-gated env passes through.
fn resolve_approvals(
    env: Option<&str>,
    policy: &[crate::manifest::ManifestEnvironment],
    approve: &[String],
) -> Result<Vec<String>> {
    let mut approved: Vec<String> = approve.to_vec();
    let Some(env) = env else {
        return Ok(approved); // no env selected → nothing env-specific to gate
    };
    let gated = policy.iter().any(|e| e.name == env && e.confirm);
    if !gated || approved.iter().any(|e| e == env) {
        return Ok(approved);
    }
    // Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
    // --approve explicitly; --yes is deliberately insufficient).
    if std::io::stdin().is_terminal() {
        eprint!(
            "environment `{env}` requires explicit approval to apply. \
             Type the environment name to approve, or anything else to abort: "
        );
        std::io::stderr().flush().ok();
        let mut answer = String::new();
        std::io::stdin()
            .read_line(&mut answer)
            .context("read approval")?;
        if answer.trim() == env {
            approved.push(env.to_string());
            return Ok(approved);
        }
        anyhow::bail!("aborted: environment `{env}` not approved");
    }
    anyhow::bail!(
        "environment `{env}` requires explicit approval: re-run with `--approve {env}` \
         (a blanket `--yes` does not cover a confirm-required environment)"
    );
}

fn confirm_prune(slug: &str) -> Result<()> {
    if !std::io::stdin().is_terminal() {
        anyhow::bail!(
            "refusing to `apply --prune` non-interactively without `--yes`: prune \
             deletes resources absent from the manifest and cannot be undone. \
             Review with `pic plan`, then re-run with `--yes`."
        );
    }
    eprint!(
        "apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
         absent from the manifest. This cannot be undone. Continue? [y/N] "
    );
    std::io::stderr().flush().ok();
    let mut answer = String::new();
    std::io::stdin()
        .read_line(&mut answer)
        .context("read confirmation")?;
    if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
        anyhow::bail!("aborted");
    }
    Ok(())
}
