//! `pic scripts ls | deploy | invoke | delete`.

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::path::Path;

use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;

use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};

pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;

    let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);

    if let Some(group_ident) = group {
        // Phase 4: a group's own (non-inherited) scripts. The owner column
        // shows the group, not an app.
        let scripts = client.group_scripts_list(group_ident).await?;
        for s in scripts {
            table.row([
                s.id.to_string(),
                format!("group:{group_ident}"),
                s.name,
                s.version.to_string(),
                s.updated_at.to_rfc3339(),
            ]);
        }
        table.print(mode);
        return Ok(());
    }

    if let Some(ident) = app {
        let app = client.apps_get(ident).await?;
        let scripts = client.scripts_list_by_app(&app.app.slug).await?;
        for s in scripts {
            table.row([
                s.id.to_string(),
                app.app.slug.clone(),
                s.name,
                s.version.to_string(),
                s.updated_at.to_rfc3339(),
            ]);
        }
    } else {
        // No filter → use the single `GET /admin/scripts` call. Server
        // filters by membership for `Member`; for `Admin`/`Owner` it
        // returns every script. Two requests total (apps + scripts) run
        // in parallel; the per-app walk we used to do here aborted on
        // the first 404 when another caller deleted an app mid-listing,
        // and was the entire reason a 5× retry existed in the tests.
        let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
        let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
        for s in scripts {
            // Group-owned scripts (Phase 4) have no app; show a marker rather
            // than a slug. App-owned scripts resolve their slug as before.
            let app_slug = match s.app_id {
                Some(app_id) => slug_by_id
                    .get(&app_id)
                    .cloned()
                    .unwrap_or_else(|| "-".to_string()),
                None => "(group)".to_string(),
            };
            table.row([
                s.id.to_string(),
                app_slug,
                s.name,
                s.version.to_string(),
                s.updated_at.to_rfc3339(),
            ]);
        }
    }
    table.print(mode);
    Ok(())
}

pub async fn deploy(
    file: &Path,
    app_ident: Option<&str>,
    group_ident: Option<&str>,
    name_override: Option<&str>,
    description: Option<&str>,
    cfg: &ScriptConfig,
    mode: OutputMode,
) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;

    let source =
        std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
    let name = match name_override {
        Some(n) => n.to_string(),
        None => file
            .file_stem()
            .and_then(|s| s.to_str())
            .map(str::to_string)
            .ok_or_else(|| {
                anyhow!(
                    "could not derive script name from path {} (use --name)",
                    file.display()
                )
            })?,
    };

    // Deploy is create-or-update by name within the chosen owner. Exactly
    // one owner — clap marks `--group` as conflicting with `--app`, so this
    // only rejects the both-absent case.
    let (script, action) = match (app_ident, group_ident) {
        (Some(app_ident), None) => {
            // Slug-or-id resolution: a single GET gives the canonical app_id.
            let app = client.apps_get(app_ident).await?;
            let existing = client.scripts_list_by_app(app_ident).await?;
            if let Some(s) = existing.into_iter().find(|s| s.name == name) {
                let updated = client
                    .scripts_update_source(&s.id.to_string(), &source, cfg)
                    .await?;
                (updated, "updated")
            } else {
                let body = CreateScriptBody {
                    app_id: app.app.id,
                    name: &name,
                    description,
                    source: &source,
                    timeout_seconds: cfg.timeout_seconds,
                    memory_limit_mb: cfg.memory_limit_mb,
                    kind: cfg.kind,
                    sandbox: cfg.sandbox,
                };
                (client.scripts_create(&body).await?, "created")
            }
        }
        (None, Some(group_ident)) => {
            // Group scripts update through the owner-polymorphic by-id
            // endpoint; create through the group endpoint.
            let existing = client.group_scripts_list(group_ident).await?;
            if let Some(s) = existing.into_iter().find(|s| s.name == name) {
                let updated = client
                    .scripts_update_source(&s.id.to_string(), &source, cfg)
                    .await?;
                (updated, "updated")
            } else {
                let body = CreateGroupScriptBody {
                    name: &name,
                    description,
                    source: &source,
                    timeout_seconds: cfg.timeout_seconds,
                    memory_limit_mb: cfg.memory_limit_mb,
                    kind: cfg.kind,
                    sandbox: cfg.sandbox,
                };
                (
                    client.group_scripts_create(group_ident, &body).await?,
                    "created",
                )
            }
        }
        (Some(_), Some(_)) | (None, None) => {
            // Reuse the shared XOR message (both-or-neither always errors here).
            return Err(crate::cmds::require_one_owner(app_ident, group_ident)
                .expect_err("both-or-neither must error"));
        }
    };
    // Emit the script object so `--output json` callers can capture the
    // id for the follow-up routes/triggers calls.
    let mut block = KvBlock::new();
    block
        .field("id", script.id.to_string())
        .field("name", script.name.clone())
        .field("version", script.version.to_string())
        .field("action", action)
        .field("updated_at", script.updated_at.to_rfc3339());
    block.print(mode);
    Ok(())
}

pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;

    let body = parse_body_arg(body_arg)?;
    let resp = client.execute(id, body, headers).await?;
    // Status to stderr so stdout stays JSON for piping into jq.
    let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
    let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
    println!("{pretty}");
    if (200..400).contains(&resp.status_code) {
        Ok(())
    } else {
        Err(anyhow!("execute returned HTTP {}", resp.status_code))
    }
}

/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
/// server-side, which is stricter than the edit endpoints — Editor
/// can deploy/update but not destroy. Surfaces that as a 403 with the
/// usual role hint.
pub async fn delete(id: &str) -> Result<()> {
    let creds = config::resolve()?;
    let client = Client::from_creds(&creds)?;
    client.scripts_delete(id).await?;
    println!("Deleted script {id}");
    Ok(())
}

fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
    match arg {
        None => Ok(Value::Object(serde_json::Map::new())),
        Some("@-") => {
            let mut buf = String::new();
            io::stdin()
                .read_to_string(&mut buf)
                .context("reading stdin")?;
            parse_or_string(&buf)
        }
        Some(raw) if raw.starts_with('@') => {
            let path = &raw[1..];
            let text = std::fs::read_to_string(path)
                .with_context(|| format!("reading body file {path}"))?;
            parse_or_string(&text)
        }
        Some(raw) => parse_or_string(raw),
    }
}

fn parse_or_string(s: &str) -> Result<Value> {
    let trimmed = s.trim();
    if trimmed.is_empty() {
        return Ok(Value::Object(serde_json::Map::new()));
    }
    serde_json::from_str(trimmed)
        .with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        format!("{}…", &s[..n])
    }
}

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

    #[test]
    fn parse_body_inline_json() {
        let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
        assert_eq!(v["x"], 1);
    }

    #[test]
    fn parse_body_none_is_empty_object() {
        let v = parse_body_arg(None).unwrap();
        assert!(v.is_object());
        assert_eq!(v.as_object().unwrap().len(), 0);
    }

    #[test]
    fn parse_body_invalid_json_reports() {
        let err = parse_body_arg(Some("not-json{")).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("not valid JSON"), "got: {msg}");
    }
}
