//! `pic pull <app> [--dir .]` — export an app's current server state into
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
//! declarative management with `pic plan` / `pic apply`.
//!
//! Read-only: issues `GET`s only and writes local files. Every trigger
//! kind is exported except `email` — the server stores the *sealed secret
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
//! can't be reconstructed (email triggers must be set up by hand).

use std::collections::HashMap;
use std::path::Path;

use anyhow::{Context, Result};
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
use serde::Deserialize;

use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::manifest::{
    CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
    ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, ManifestWorkflow,
    ManifestWorkflowRetry, ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec,
    MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};

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

    // Refuse to clobber an existing project (mirrors `pic init`). `pull`
    // overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
    // stray `pic pull <wrong-app>` in a populated dir would destroy local
    // edits. Fail fast — before any network call or file write — unless the
    // operator opted in with `--force`.
    let manifest_path = dir.join(MANIFEST_FILE);
    if !force && manifest_path.exists() {
        anyhow::bail!(
            "{} already exists; refusing to overwrite. Re-run with --force to \
             replace it (and any colliding scripts/*.rhai).",
            manifest_path.display()
        );
    }

    // One GET per resource kind (routes are per-script, below).
    let app = client.apps_get(app_ident).await?;
    let scripts = client.scripts_list_by_app(app_ident).await?;
    let triggers = client.triggers_list(app_ident).await?.triggers;
    let workflows = client.workflows_list(app_ident).await?;
    // A live workflow always has ≥1 step (a zero-step definition is rejected at
    // apply), so an empty `definition` here can only mean the server predates
    // the definition-in-list field. Fail fast BEFORE writing anything — silently
    // dropping the workflow would leave a manifest that a later `apply --prune`
    // uses to DELETE the server-side workflow (and `pull --force` would clobber
    // a hand-authored block). Mirror the clobber/unsafe-name bails above.
    if let Some(w) = workflows.iter().find(|w| w.definition.steps.is_empty()) {
        anyhow::bail!(
            "workflow `{}` came back without its definition — the server is too old to \
             round-trip workflows via `pull`. Refusing to write a manifest that omits it \
             (a later `apply --prune` would delete the workflow). Upgrade the server and retry.",
            w.name
        );
    }
    let secrets = client
        .secrets_list(VarOwnerArg::App(app_ident), None)
        .await?
        .secrets;
    // The app's OWN declared extension points (§5.5) — inherited ones belong to
    // the ancestor group's manifest, so filter to `declared_here`.
    let extension_points: Vec<String> = client
        .extension_points_list(crate::client::NodeKind::App, app_ident)
        .await?
        .into_iter()
        .filter(|ep| ep.declared_here)
        .map(|ep| ep.name)
        .collect();
    // App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
    // group vars and tombstones are excluded (pull exports own rows only, §4.6);
    // a value TOML can't represent (e.g. JSON null) is skipped with a warning.
    let mut manifest_vars = std::collections::BTreeMap::new();
    for v in client.vars_list(VarOwnerArg::App(app_ident)).await?.vars {
        if v.env != "*" || v.is_tombstone {
            continue;
        }
        match toml::Value::try_from(&v.value) {
            Ok(tv) => {
                manifest_vars.insert(v.key, tv);
            }
            Err(_) => eprintln!(
                "warning: skipping var `{}` — its value can't be represented in TOML",
                v.key
            ),
        }
    }

    let name_by_id: HashMap<ScriptId, String> =
        scripts.iter().map(|s| (s.id, s.name.clone())).collect();

    // Routes: the admin surface lists them per script.
    let mut routes = Vec::new();
    for s in &scripts {
        for r in client.routes_list_for_script(&s.id.to_string()).await? {
            routes.push(ManifestRoute {
                script: s.name.clone(),
                method: r.method,
                host_kind: r.host_kind,
                host: r.host,
                host_param_name: r.host_param_name,
                path_kind: r.path_kind,
                path: r.path,
                dispatch_mode: r.dispatch_mode,
                enabled: r.enabled,
                // `pull` reconciles an app; app-owned routes are never sealed
                // (sealing is a group-template property, §11 tail).
                sealed: false,
            });
        }
    }

    // The server does not constrain script names to a filesystem-safe
    // charset, so a name containing a path separator or `..` would let `pull`
    // write outside the project dir. Validate ALL names up front, before any
    // file is written, so a single bad name can't leave a half-written dir.
    // Reject rather than sanitize: a silent rename would desync the manifest
    // `name` from its `file`.
    for s in &scripts {
        if !is_safe_filename(&s.name) {
            anyhow::bail!(
                "script name {:?} is not filesystem-safe (a path separator, \
                 `..`, a leading dot, a control character, or longer than 200 \
                 bytes); cannot pull",
                s.name
            );
        }
    }

    // Scripts: write each source next to the manifest and record a path ref.
    let scripts_dir = dir.join("scripts");
    std::fs::create_dir_all(&scripts_dir)
        .with_context(|| format!("creating {}", scripts_dir.display()))?;
    let mut manifest_scripts = Vec::with_capacity(scripts.len());
    for s in &scripts {
        let rel = format!("scripts/{}.rhai", s.name);
        std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
        manifest_scripts.push(ManifestScript {
            name: s.name.clone(),
            file: rel,
            kind: s.kind,
            description: s.description.clone(),
            timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
            memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
            sandbox: if s.sandbox.is_empty() {
                None
            } else {
                Some(s.sandbox)
            },
            enabled: s.enabled,
        });
    }

    // Triggers: map the five settled kinds; warn + skip the rest.
    let mut manifest_triggers = ManifestTriggers::default();
    let mut skipped: Vec<String> = Vec::new();
    for t in &triggers {
        let script = name_by_id
            .get(&t.script_id)
            .cloned()
            .unwrap_or_else(|| t.script_id.to_string());
        let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
        let retry_max_attempts = Some(t.retry_max_attempts);
        match t.kind.as_str() {
            "kv" => {
                let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
                manifest_triggers.kv.push(KvTriggerSpec {
                    script,
                    collection_glob: d.collection_glob,
                    ops: d.ops,
                    dispatch_mode,
                    retry_max_attempts,
                    // app-owned triggers are never sealed/shared (group-only).
                    sealed: false,
                    shared: false,
                });
            }
            "docs" => {
                let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
                manifest_triggers.docs.push(DocsTriggerSpec {
                    script,
                    collection_glob: d.collection_glob,
                    ops: d.ops,
                    dispatch_mode,
                    retry_max_attempts,
                    sealed: false,
                    shared: false,
                });
            }
            "files" => {
                let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
                manifest_triggers.files.push(FilesTriggerSpec {
                    script,
                    collection_glob: d.collection_glob,
                    ops: d.ops,
                    dispatch_mode,
                    retry_max_attempts,
                    sealed: false,
                    shared: false,
                });
            }
            "cron" => {
                let d: CronDetails = decode_details(&t.details, &t.kind)?;
                manifest_triggers.cron.push(CronTriggerSpec {
                    script,
                    schedule: d.schedule,
                    timezone: d.timezone,
                    dispatch_mode,
                    retry_max_attempts,
                });
            }
            "pubsub" => {
                let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
                manifest_triggers.pubsub.push(PubsubTriggerSpec {
                    script,
                    topic_pattern: d.topic_pattern,
                    dispatch_mode,
                    retry_max_attempts,
                    // app-owned triggers are never sealed/shared (group-only).
                    sealed: false,
                    shared: false,
                });
            }
            "queue" => {
                let d: QueueDetails = decode_details(&t.details, &t.kind)?;
                manifest_triggers.queue.push(QueueTriggerSpec {
                    script,
                    queue_name: d.queue_name,
                    visibility_timeout_secs: Some(d.visibility_timeout_secs),
                    dispatch_mode,
                    retry_max_attempts,
                    // app-owned triggers are never shared (group-only).
                    shared: false,
                });
            }
            // `email` is skipped: the server stores the sealed secret value,
            // not the secret *name*, so the manifest's `inbound_secret_ref`
            // can't be reconstructed — set it up by hand.
            other => skipped.push(format!("{other} ({})", t.id)),
        }
    }
    for s in &skipped {
        eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
    }

    let manifest = Manifest {
        app: Some(ManifestApp {
            slug: app.app.slug.clone(),
            name: app.app.name.clone(),
            description: app.app.description.clone(),
            extension_points,
        }),
        group: None,
        project: None,
        scripts: manifest_scripts,
        routes,
        triggers: manifest_triggers,
        secrets: ManifestSecrets {
            names: secrets.iter().map(|s| s.name.clone()).collect(),
        },
        vars: manifest_vars,
        suppress: crate::manifest::ManifestSuppress::default(),
        workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
        // `pull` does not round-trip interceptor markers yet (read surface TBD);
        // an interceptor authored in the manifest survives re-apply regardless.
        interceptors: Vec::new(),
    };

    std::fs::write(&manifest_path, manifest.to_toml()?)
        .with_context(|| format!("writing {}", manifest_path.display()))?;

    let mut block = KvBlock::new();
    block
        .field("manifest", manifest_path.display().to_string())
        .field("app", manifest.slug().to_string())
        .field("scripts", manifest.scripts.len().to_string())
        .field("routes", manifest.routes.len().to_string())
        .field("triggers", trigger_count(&manifest.triggers).to_string())
        .field("secrets", manifest.secrets.names.len().to_string())
        .field("workflows", manifest.workflows.len().to_string());
    block.print(mode);
    Ok(())
}

fn trigger_count(t: &ManifestTriggers) -> usize {
    t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
}

/// Rebuild a `[[workflows]]` manifest block from the server's stored definition
/// (the inverse of `plan::workflow_to_wire`). Without this a `pic pull` would
/// drop the app's workflows, and a subsequent `pic apply --prune` would delete
/// them from the server.
///
/// The caller guarantees `definition.steps` is non-empty (an empty definition
/// means an out-of-date server and is rejected up front in `run`, before any
/// file is written).
fn wire_workflow_to_manifest(w: &crate::client::WorkflowDto) -> ManifestWorkflow {
    ManifestWorkflow {
        name: w.name.clone(),
        enabled: w.enabled,
        steps: w
            .definition
            .steps
            .iter()
            .map(wire_step_to_manifest)
            .collect(),
    }
}

fn wire_step_to_manifest(s: &picloud_shared::WorkflowStepDef) -> ManifestWorkflowStep {
    ManifestWorkflowStep {
        name: s.name.clone(),
        function: s.function.clone(),
        workflow: s.workflow.clone(),
        // JSON step input → TOML. A whole-null input is the "absent" encoding;
        // anything else round-trips through serde. A value TOML can't represent
        // (e.g. a nested null) is dropped with a warning rather than aborting
        // the whole pull.
        input: if s.input.is_null() {
            None
        } else {
            match toml::Value::try_from(&s.input) {
                Ok(tv) => Some(tv),
                Err(e) => {
                    eprintln!(
                        "warning: workflow step `{}` input not representable in TOML ({e}); \
                         omitting it from the manifest",
                        s.name
                    );
                    None
                }
            }
        },
        depends_on: s.depends_on.clone(),
        when: s.when.clone(),
        retry: s.retry.as_ref().map(|r| ManifestWorkflowRetry {
            max_attempts: r.max_attempts,
            // Omit whatever equals the server default (so the round-trip is a
            // no-op), keyed off the shared defaults rather than local literals.
            backoff: (r.backoff != picloud_shared::WorkflowBackoff::default()).then_some(r.backoff),
            base_ms: (r.base_ms != picloud_shared::default_base_ms()).then_some(r.base_ms),
        }),
        // Fail is the default → omit; Continue is explicit.
        on_error: match s.on_error {
            picloud_shared::OnError::Fail => None,
            picloud_shared::OnError::Continue => Some(picloud_shared::OnError::Continue),
        },
    }
}

/// True if `name` is safe to use as a single path component in `scripts/`.
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
/// and any deceptive display character — a server-returned name is otherwise
/// written verbatim as a filename and printed to the operator's terminal.
fn is_safe_filename(name: &str) -> bool {
    // Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
    // `.rhai` suffix.
    const MAX_LEN: usize = 200;
    !name.is_empty()
        && name.len() <= MAX_LEN
        && !name.starts_with('.')
        && !name.contains('/')
        && !name.contains('\\')
        && name != ".."
        && !name.chars().any(is_deceptive_char)
}

/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
/// bidirectional-override and zero-width/format characters used to spoof how a
/// name renders in a terminal — both classes are unsafe to print verbatim.
fn is_deceptive_char(c: char) -> bool {
    c.is_control()
        || matches!(c,
            '\u{200B}'..='\u{200F}'   // zero-width space … LTR/RTL marks
            | '\u{2028}'..='\u{2029}' // line / paragraph separators
            | '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
            | '\u{2060}'              // word joiner
            | '\u{2066}'..='\u{2069}' // bidi isolates
            | '\u{FEFF}'              // BOM / zero-width no-break space
        )
}

/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
/// The server tags details with a `kind` field which these structs ignore.
fn decode_details<T: for<'de> Deserialize<'de>>(
    details: &serde_json::Value,
    kind: &str,
) -> Result<T> {
    serde_json::from_value(details.clone())
        .with_context(|| format!("decoding {kind} trigger details"))
}

#[derive(Deserialize)]
struct CollectionDetails<Op> {
    collection_glob: String,
    #[serde(default = "Vec::new")]
    ops: Vec<Op>,
}

#[derive(Deserialize)]
struct CronDetails {
    schedule: String,
    #[serde(default = "default_timezone")]
    timezone: String,
}

#[derive(Deserialize)]
struct PubsubDetails {
    topic_pattern: String,
}

#[derive(Deserialize)]
struct QueueDetails {
    queue_name: String,
    visibility_timeout_secs: u32,
}

fn default_timezone() -> String {
    "UTC".to_string()
}

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

    #[test]
    fn rejects_traversal_and_separators() {
        for bad in [
            "",
            ".",
            "..",
            "../etc/passwd",
            "a/b",
            "a\\b",
            ".hidden",
            "with\0nul",
        ] {
            assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
        }
    }

    #[test]
    fn rejects_control_chars_and_overlong() {
        for bad in [
            "a\nb",
            "a\tb",
            "line\rdrop",
            "esc\x1b[2Jseq",
            "rtl\u{202E}gpj.exe", // bidi override (filename spoof)
            "zero\u{200B}width",  // zero-width space
            "bom\u{FEFF}name",    // BOM
        ] {
            assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
        }
        assert!(
            !is_safe_filename(&"a".repeat(201)),
            "expected an over-long name to be rejected"
        );
        assert!(
            is_safe_filename(&"a".repeat(200)),
            "a 200-char name is at the limit and allowed"
        );
    }

    #[test]
    fn accepts_normal_names() {
        for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
            assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
        }
    }
}
