//! Output-shape invariants — the contracts downstream `jq`/`awk`
//! pipelines depend on: column headers, stdout-vs-stderr separation,
//! and RFC3339 timestamps.

use serde_json::Value;

use crate::common;
use crate::common::cleanup::AppGuard;

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_header_columns() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);

    let out = common::pic_as(&env)
        .args(["apps", "ls"])
        .output()
        .expect("apps ls");
    assert!(out.status.success());
    let stdout = String::from_utf8(out.stdout).unwrap();
    let header = stdout.lines().next().expect("header row");
    assert_eq!(
        common::cells(header),
        vec!["slug", "name", "my_role", "created_at"]
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_header_columns() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let slug = common::unique_slug("out-ls");
    common::pic_as(&env)
        .args(["apps", "create", &slug])
        .assert()
        .success();
    let _guard = AppGuard::new(&env.url, &env.token, &slug);

    let out = common::pic_as(&env)
        .args(["scripts", "ls", "--app", &slug])
        .output()
        .expect("scripts ls");
    assert!(out.status.success());
    let stdout = String::from_utf8(out.stdout).unwrap();
    let header = stdout.lines().next().expect("header row");
    assert_eq!(
        common::cells(header),
        vec!["id", "app_slug", "name", "version", "updated_at"]
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_separates_stdout_and_stderr() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai");

    let out = common::pic_as(&env)
        .args(["scripts", "invoke", &id])
        .output()
        .expect("invoke");
    assert!(out.status.success());

    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(
        stderr.starts_with("<- HTTP 200"),
        "stderr should announce HTTP status: {stderr:?}"
    );

    let parsed: Value = serde_json::from_slice(&out.stdout)
        .expect("stdout should be JSON only, with no status prefix");
    assert_eq!(parsed["ok"], true, "body: {parsed}");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn error_goes_to_stderr_not_stdout() {
    let Some(_fx) = common::fixture_or_skip() else {
        return;
    };
    // Use a pristine env (no credentials file) so `whoami` is guaranteed
    // to fail at the `config::load` step — `admin_env` would pre-seed
    // creds and the command would succeed.
    let env = common::TestEnv {
        url: String::new(),
        token: String::new(),
        config_dir: tempfile::TempDir::new().unwrap(),
        home: tempfile::TempDir::new().unwrap(),
    };

    let out = common::pic_no_env(&env)
        .args(["whoami"])
        .output()
        .expect("whoami");
    assert!(!out.status.success(), "expected failure, got: {out:?}");
    assert!(
        out.stdout.is_empty(),
        "stdout should be empty on error, got: {:?}",
        String::from_utf8_lossy(&out.stdout),
    );
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(
        stderr.contains("error:"),
        "stderr should be prefixed with `error:`: {stderr}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_created_at_is_rfc3339() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let slug = common::unique_slug("out-date");
    common::pic_as(&env)
        .args(["apps", "create", &slug])
        .assert()
        .success();
    let _guard = AppGuard::new(&env.url, &env.token, &slug);

    let out = common::pic_as(&env)
        .args(["apps", "ls"])
        .output()
        .expect("apps ls");
    let stdout = String::from_utf8(out.stdout).unwrap();
    let row = stdout
        .lines()
        .map(common::cells)
        .find(|c| c.first().copied() == Some(slug.as_str()))
        .unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}"));
    let created_at = row.get(3).expect("created_at cell");

    // Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS`
    // with optional fraction + timezone is enough of a contract for the test.
    assert!(
        created_at.len() >= 20
            && created_at.as_bytes()[4] == b'-'
            && created_at.as_bytes()[7] == b'-'
            && created_at.as_bytes()[10] == b'T'
            && created_at.as_bytes()[13] == b':'
            && created_at.as_bytes()[16] == b':',
        "created_at not RFC3339-shaped: {created_at}"
    );
}

/// `--output json` is the global pipeline-friendly format. Validates
/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_json_output_is_valid_array() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let slug = common::unique_slug("out-json-apps");
    common::pic_as(&env)
        .args(["apps", "create", &slug])
        .assert()
        .success();
    let _guard = AppGuard::new(&env.url, &env.token, &slug);

    let out = common::pic_as(&env)
        .args(["--output", "json", "apps", "ls"])
        .output()
        .expect("apps ls --output json");
    assert!(out.status.success(), "apps ls failed: {out:?}");
    let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON");
    let arr = v.as_array().expect("apps ls JSON should be an array");
    assert!(
        arr.iter()
            .any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())),
        "json should include created slug: {v}"
    );
    // The header row must NOT bleed into JSON output — the rendered
    // objects use header *keys*, not data cells.
    assert!(
        arr.iter().all(|row| row.get("slug").is_some()),
        "every row should have a `slug` key: {v}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_json_output_has_app_slug() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let slug = common::unique_slug("out-json-scr");
    common::pic_as(&env)
        .args(["apps", "create", &slug])
        .assert()
        .success();
    let _guard = AppGuard::new(&env.url, &env.token, &slug);
    let fixture = common::fixture_path("hello.rhai");
    common::pic_as(&env)
        .args([
            "scripts",
            "deploy",
            fixture.to_str().unwrap(),
            "--app",
            &slug,
        ])
        .assert()
        .success();

    let out = common::pic_as(&env)
        .args(["--output", "json", "scripts", "ls", "--app", &slug])
        .output()
        .expect("scripts ls --output json");
    assert!(out.status.success());
    let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
    let arr = v.as_array().expect("array");
    let row = arr
        .iter()
        .find(|r| r.get("name").and_then(Value::as_str) == Some("hello"))
        .expect("hello row");
    assert_eq!(row["app_slug"].as_str(), Some(slug.as_str()));
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_json_output_is_array_of_objects() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai");
    common::pic_as(&env)
        .args(["scripts", "invoke", &id])
        .assert()
        .success();

    let out = common::pic_as(&env)
        .args(["--output", "json", "logs", &id])
        .output()
        .expect("logs --output json");
    assert!(out.status.success());
    let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
    let arr = v.as_array().expect("array");
    assert!(!arr.is_empty(), "expected at least one log");
    // Schema: each row carries the raw `ExecutionLog`, not the
    // truncated summary the TSV form uses.
    assert!(
        arr[0].get("status").is_some(),
        "log row missing status: {arr:?}"
    );
}

/// TSV `whoami` used to be a single tab-separated line with no labels;
/// downstream tools couldn't tell which column was the role. Now it's
/// a key/value block with stable labels.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_tsv_has_labeled_rows() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);

    let out = common::pic_as(&env)
        .args(["whoami"])
        .output()
        .expect("whoami");
    assert!(out.status.success());
    let stdout = String::from_utf8(out.stdout).unwrap();
    let labels: Vec<&str> = stdout
        .lines()
        .filter_map(|l| l.split('\t').next())
        .map(str::trim)
        .collect();
    assert!(
        labels.contains(&"username"),
        "missing username row: {stdout}"
    );
    assert!(labels.contains(&"role"), "missing role row: {stdout}");
    assert!(labels.contains(&"email"), "missing email row: {stdout}");
    assert!(labels.contains(&"url"), "missing url row: {stdout}");
}
