use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use uuid::Uuid;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{body_json, body_partial_json, method, path, query_param},
};

const JOB_ID: &str = "11111111-1111-4111-8111-111111111111";
const USER_ID: &str = "22222222-2222-4222-8222-222222222222";
const PAT_ID: &str = "33333333-3333-4333-8333-333333333333";
const CHARACTER_ID: &str = "44444444-4444-4444-8444-444444444444";
const PROJECT_ID: &str = "55555555-5555-4555-8555-555555555555";
const ASSET_ID: &str = "66666666-6666-4666-8666-666666666666";

#[test]
fn help_lists_full_command_surface() {
    cmd()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("auth"))
        .stdout(predicate::str::contains("gen"))
        .stdout(predicate::str::contains("status"))
        .stdout(predicate::str::contains("wait"))
        .stdout(predicate::str::contains("assets"))
        .stdout(predicate::str::contains("characters"))
        .stdout(predicate::str::contains("projects"))
        .stdout(predicate::str::contains("account"))
        .stdout(predicate::str::contains("billing"))
        .stdout(predicate::str::contains("pat"));
}

#[test]
fn gen_help_lists_modalities() {
    cmd()
        .args(["gen", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("image"))
        .stdout(predicate::str::contains("video"))
        .stdout(predicate::str::contains("audio"));
}

#[tokio::test]
async fn gen_image_writes_output_file() {
    let api = MockServer::start().await;
    let files = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/video.mp4"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3]))
        .mount(&files)
        .await;
    Mock::given(method("POST"))
        .and(path("/v1/generate/image"))
        .respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
        .mount(&api)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/jobs/{JOB_ID}/wait")))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some(&files.uri()))),
        )
        .mount(&api)
        .await;
    let out = tempfile::tempdir().unwrap().path().join("x.png");
    run_ok(
        &api,
        &[
            "gen",
            "image",
            "--prompt",
            "x",
            "--out",
            out.to_str().unwrap(),
        ],
    );
    assert_eq!(std::fs::read(out).unwrap(), vec![1, 2, 3]);
}

#[tokio::test]
async fn json_gen_image_no_wait_returns_job_id() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/generate/image"))
        .respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
        .mount(&api)
        .await;
    run_ok(
        &api,
        &["--json", "gen", "image", "--prompt", "x", "--no-wait"],
    )
    .stdout(predicate::str::contains("job_id"));
}

#[tokio::test]
async fn gen_video_no_wait_returns_job_id() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/generate/video"))
        .respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
        .mount(&api)
        .await;
    run_ok(&api, &["gen", "video", "--prompt", "x", "--no-wait"])
        .stdout(predicate::str::contains(JOB_ID));
}

#[tokio::test]
async fn gen_video_wait_downloads_asset() {
    let api = MockServer::start().await;
    let files = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/video.mp4"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![9]))
        .mount(&files)
        .await;
    Mock::given(method("POST"))
        .and(path("/v1/generate/video"))
        .respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
        .mount(&api)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/jobs/{JOB_ID}/wait")))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some(&files.uri()))),
        )
        .mount(&api)
        .await;
    let out = tempfile::tempdir().unwrap().path().join("x.mp4");
    run_ok(
        &api,
        &[
            "gen",
            "video",
            "--prompt",
            "x",
            "--out",
            out.to_str().unwrap(),
        ],
    );
    assert_eq!(std::fs::read(out).unwrap(), vec![9]);
}

#[tokio::test]
async fn gen_audio_prints_asset_url() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/generate/audio"))
        .respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
        .mount(&api)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/jobs/{JOB_ID}/wait")))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some("https://files"))),
        )
        .mount(&api)
        .await;
    run_ok(&api, &["gen", "audio", "--prompt", "x"]).stdout(predicate::str::contains("video.mp4"));
}

#[tokio::test]
async fn status_fetches_job() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/jobs/{JOB_ID}")))
        .respond_with(ResponseTemplate::new(200).set_body_json(job_json("running", None)))
        .mount(&api)
        .await;
    run_ok(&api, &["status", JOB_ID]).stdout(predicate::str::contains("running"));
}

#[tokio::test]
async fn wait_fetches_terminal_job() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/jobs/{JOB_ID}/wait")))
        .respond_with(ResponseTemplate::new(200).set_body_json(job_json("succeeded", None)))
        .mount(&api)
        .await;
    run_ok(&api, &["wait", JOB_ID, "--timeout", "1"]).stdout(predicate::str::contains("succeeded"));
}

#[tokio::test]
async fn assets_list_outputs_asset() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/assets"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({"items": [asset_json("https://files/a.png")]})),
        )
        .mount(&api)
        .await;
    run_ok(&api, &["assets", "list"]).stdout(predicate::str::contains("a.png"));
}

#[tokio::test]
async fn assets_get_downloads_asset() {
    let api = MockServer::start().await;
    let files = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/asset.png"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![7, 7]))
        .mount(&files)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/assets/{JOB_ID}")))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(asset_json(&format!("{}/asset.png", files.uri()))),
        )
        .mount(&api)
        .await;
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("asset.bin");
    run_ok(
        &api,
        &["assets", "get", JOB_ID, "--out", out.to_str().unwrap()],
    );
    assert_eq!(std::fs::read(&out).unwrap(), vec![7, 7]);
}

#[tokio::test]
async fn assets_get_prints_metadata_without_out() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(format!("/v1/assets/{JOB_ID}")))
        .respond_with(ResponseTemplate::new(200).set_body_json(asset_json("https://files/a.png")))
        .mount(&api)
        .await;
    run_ok(&api, &["assets", "get", JOB_ID]).stdout(predicate::str::contains("a.png"));
}

#[tokio::test]
async fn assets_delete_removes_asset() {
    let api = MockServer::start().await;
    Mock::given(method("DELETE"))
        .and(path(format!("/v1/assets/{JOB_ID}")))
        .respond_with(ResponseTemplate::new(204))
        .mount(&api)
        .await;
    run_ok(&api, &["assets", "delete", JOB_ID])
        .stdout(predicate::str::contains(format!("deleted {JOB_ID}")));
}

#[tokio::test]
async fn assets_list_sends_tag_filter() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/assets"))
        .and(query_param("tag", "hero"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({"items": [asset_json("https://files/a.png")]})),
        )
        .expect(1)
        .mount(&api)
        .await;
    run_ok(&api, &["assets", "list", "--tag", "hero"]).stdout(predicate::str::contains("a.png"));
}

#[tokio::test]
async fn assets_tag_sends_patch_body_and_prints_tags() {
    let api = MockServer::start().await;
    let mut asset = asset_json("https://files/a.png");
    asset["tags"] = json!(["hero", "draft"]);
    Mock::given(method("PATCH"))
        .and(path(format!("/v1/assets/{ASSET_ID}")))
        .and(body_json(json!({"tags": ["hero", "draft"]})))
        .respond_with(ResponseTemplate::new(200).set_body_json(asset))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(
        &api,
        &["assets", "tag", ASSET_ID, "--tag", "hero", "--tag", "draft"],
    )
    .stdout(predicate::str::contains("tags: [hero, draft]"));
}

#[tokio::test]
async fn assets_tag_clear_sends_empty_tag_set() {
    let api = MockServer::start().await;
    Mock::given(method("PATCH"))
        .and(path(format!("/v1/assets/{ASSET_ID}")))
        .and(body_json(json!({"tags": []})))
        .respond_with(ResponseTemplate::new(200).set_body_json(asset_json("https://files/a.png")))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(&api, &["assets", "tag", ASSET_ID, "--clear"])
        .stdout(predicate::str::contains("tags: []"));
}

#[test]
fn assets_tag_requires_tag_or_clear() {
    cmd()
        .args(["assets", "tag", ASSET_ID, "--api-url", "http://127.0.0.1:9"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--tag"));
}

#[tokio::test]
async fn characters_list_outputs_characters() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/characters"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(json!({"characters": [character_json()]})),
        )
        .mount(&api)
        .await;
    run_ok(&api, &["characters", "list"])
        .stdout(predicate::str::contains(CHARACTER_ID))
        .stdout(predicate::str::contains("Captain Nova"))
        .stdout(predicate::str::contains("1 reference"));
}

#[tokio::test]
async fn characters_create_sends_body() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/characters"))
        .and(body_json(json!({
            "name": "Captain Nova",
            "description": "Silver-haired astronaut",
            "reference_asset_ids": [ASSET_ID]
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(character_json()))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(
        &api,
        &[
            "characters",
            "create",
            "--name",
            "Captain Nova",
            "--description",
            "Silver-haired astronaut",
            "--reference-asset-id",
            ASSET_ID,
        ],
    )
    .stdout(predicate::str::contains(CHARACTER_ID));
}

#[tokio::test]
async fn characters_update_sends_only_provided_fields() {
    let api = MockServer::start().await;
    Mock::given(method("PATCH"))
        .and(path(format!("/v1/characters/{CHARACTER_ID}")))
        .and(body_json(json!({"name": "Nova Prime"})))
        .respond_with(ResponseTemplate::new(200).set_body_json(character_json()))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(
        &api,
        &["characters", "update", CHARACTER_ID, "--name", "Nova Prime"],
    )
    .stdout(predicate::str::contains(CHARACTER_ID));
}

#[tokio::test]
async fn characters_delete_removes_character() {
    let api = MockServer::start().await;
    Mock::given(method("DELETE"))
        .and(path(format!("/v1/characters/{CHARACTER_ID}")))
        .respond_with(ResponseTemplate::new(204))
        .mount(&api)
        .await;
    run_ok(&api, &["characters", "delete", CHARACTER_ID])
        .stdout(predicate::str::contains(format!("deleted {CHARACTER_ID}")));
}

#[test]
fn characters_create_rejects_more_than_four_references() {
    let a = "77777777-7777-4777-8777-777777777777";
    cmd()
        .args([
            "characters",
            "create",
            "--name",
            "x",
            "--reference-asset-id",
            a,
            "--reference-asset-id",
            a,
            "--reference-asset-id",
            a,
            "--reference-asset-id",
            a,
            "--reference-asset-id",
            a,
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("at most 4"));
}

#[tokio::test]
async fn projects_list_outputs_projects() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/projects"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(json!({"projects": [project_json()]})),
        )
        .mount(&api)
        .await;
    run_ok(&api, &["projects", "list"])
        .stdout(predicate::str::contains(PROJECT_ID))
        .stdout(predicate::str::contains("Launch teaser"))
        .stdout(predicate::str::contains("3 assets"));
}

#[tokio::test]
async fn projects_create_sends_body() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/projects"))
        .and(body_json(json!({
            "name": "Launch teaser",
            "description": "Spring launch assets"
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(project_json()))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(
        &api,
        &[
            "projects",
            "create",
            "--name",
            "Launch teaser",
            "--description",
            "Spring launch assets",
        ],
    )
    .stdout(predicate::str::contains(PROJECT_ID));
}

#[tokio::test]
async fn projects_add_assets_sends_body() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path(format!("/v1/projects/{PROJECT_ID}/assets")))
        .and(body_json(json!({"asset_ids": [ASSET_ID]})))
        .respond_with(ResponseTemplate::new(204))
        .expect(1)
        .mount(&api)
        .await;
    run_ok(
        &api,
        &["projects", "add-assets", PROJECT_ID, "--asset-id", ASSET_ID],
    )
    .stdout(predicate::str::contains("added 1 asset"));
}

#[tokio::test]
async fn projects_remove_asset_deletes_membership() {
    let api = MockServer::start().await;
    Mock::given(method("DELETE"))
        .and(path(format!("/v1/projects/{PROJECT_ID}/assets/{ASSET_ID}")))
        .respond_with(ResponseTemplate::new(204))
        .mount(&api)
        .await;
    run_ok(&api, &["projects", "remove-asset", PROJECT_ID, ASSET_ID]).stdout(
        predicate::str::contains(format!("removed {ASSET_ID} from {PROJECT_ID}")),
    );
}

#[test]
fn projects_add_assets_requires_asset_id() {
    cmd()
        .args([
            "projects",
            "add-assets",
            PROJECT_ID,
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--asset-id"));
}

#[tokio::test]
async fn account_me_outputs_email() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/me"))
        .respond_with(ResponseTemplate::new(200).set_body_json(user_json()))
        .mount(&api)
        .await;
    run_ok(&api, &["account", "me"]).stdout(predicate::str::contains("ada@nolgia.ai"));
}

#[tokio::test]
async fn account_usage_combines_jobs_and_assets() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/jobs"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({"items": [job_json("queued", None)], "total": 1})),
        )
        .mount(&api)
        .await;
    Mock::given(method("GET"))
        .and(path("/v1/assets"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": []})))
        .mount(&api)
        .await;
    run_ok(&api, &["account", "usage"]).stdout(predicate::str::contains("jobs: 1"));
}

#[tokio::test]
async fn billing_subscription_outputs_status() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/billing/subscription"))
        .respond_with(ResponseTemplate::new(200).set_body_json(
            json!({"tier":"pro","status":"active","current_period_end":"2026-06-13T00:00:00Z"}),
        ))
        .mount(&api)
        .await;
    run_ok(&api, &["billing", "subscription"]).stdout(predicate::str::contains("active"));
}

#[tokio::test]
async fn billing_portal_outputs_url() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/billing/portal-link"))
        .respond_with(ResponseTemplate::new(200).set_body_json(
            json!({"url":"https://billing.example","expires_at":"2026-06-13T00:00:00Z"}),
        ))
        .mount(&api)
        .await;
    run_ok(&api, &["billing", "portal"]).stdout(predicate::str::contains("billing.example"));
}

#[tokio::test]
async fn billing_credits_shows_both_pools() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/billing/credits"))
        .respond_with(ResponseTemplate::new(200).set_body_json(credit_balance_json()))
        .mount(&api)
        .await;
    run_ok(&api, &["billing", "credits"])
        .stdout(predicate::str::contains(
            "subscription: 546631 (resets with plan)  api top-ups: 250",
        ))
        .stdout(predicate::str::contains("total: 546881"));
}

#[tokio::test]
async fn json_billing_credits_emits_raw_balance() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/billing/credits"))
        .respond_with(ResponseTemplate::new(200).set_body_json(credit_balance_json()))
        .mount(&api)
        .await;
    run_ok(&api, &["--json", "billing", "credits"])
        .stdout(predicate::str::contains("app_subscription"))
        .stdout(predicate::str::contains("shared_topup"))
        .stdout(predicate::str::contains("buckets"));
}

#[tokio::test]
async fn pat_create_prints_token_once_with_warning() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/pat"))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "pat": pat_json(),
            "token": "nol_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
        })))
        .mount(&api)
        .await;
    run_ok(&api, &["pat", "create", "--name", "ci-bot"])
        .stdout(predicate::str::contains(
            "token: nol_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
        ))
        .stdout(predicate::str::contains("will not be shown again"));
}

#[tokio::test]
async fn pat_list_outputs_tokens() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/pat"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": [pat_json()]})))
        .mount(&api)
        .await;
    run_ok(&api, &["pat", "list"])
        .stdout(predicate::str::contains(PAT_ID))
        .stdout(predicate::str::contains("ci-bot"))
        .stdout(predicate::str::contains("nol_a1b2"))
        .stdout(predicate::str::contains("never"));
}

#[tokio::test]
async fn pat_revoke_deletes_token() {
    let api = MockServer::start().await;
    Mock::given(method("DELETE"))
        .and(path(format!("/v1/pat/{PAT_ID}")))
        .respond_with(ResponseTemplate::new(204))
        .mount(&api)
        .await;
    run_ok(&api, &["pat", "revoke", PAT_ID])
        .stdout(predicate::str::contains(format!("revoked {PAT_ID}")));
}

#[test]
fn auth_help_lists_device_flow_commands() {
    cmd()
        .args(["auth", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("login"))
        .stdout(predicate::str::contains("logout"))
        .stdout(predicate::str::contains("status"))
        .stdout(predicate::str::contains("whoami"));
}

#[test]
fn invalid_timeout_is_rejected() {
    cmd()
        .args(["wait", JOB_ID, "--timeout", "0"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("timeout"));
}

#[tokio::test]
async fn json_global_flag_is_accepted_before_command() {
    let api = MockServer::start().await;
    Mock::given(method("DELETE"))
        .and(path(format!("/v1/assets/{JOB_ID}")))
        .respond_with(ResponseTemplate::new(204))
        .mount(&api)
        .await;
    run_ok(&api, &["--json", "assets", "delete", JOB_ID])
        .stdout(predicate::str::contains("deleted"));
}

#[test]
fn image_requires_prompt() {
    cmd()
        .args(["gen", "image"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("prompt"));
}

#[test]
fn video_accepts_input_flag() {
    cmd()
        .args([
            "gen",
            "video",
            "--prompt",
            "x",
            "--input",
            "seed.png",
            "--no-wait",
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure();
}

#[test]
fn audio_accepts_format_flag() {
    cmd()
        .args([
            "gen",
            "audio",
            "--prompt",
            "x",
            "--format",
            "wav",
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure();
}

#[test]
fn status_requires_uuid() {
    cmd()
        .args(["status", "not-a-uuid"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("invalid value"));
}

#[test]
fn assets_list_accepts_filters() {
    cmd()
        .args([
            "assets",
            "list",
            "--limit",
            "1",
            "--modality",
            "image",
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure();
}

#[test]
fn billing_portal_accepts_return_url() {
    cmd()
        .args([
            "billing",
            "portal",
            "--return-url",
            "https://nolgia.ai",
            "--api-url",
            "http://127.0.0.1:9",
        ])
        .assert()
        .failure();
}

#[test]
fn account_help_lists_subcommands() {
    cmd()
        .args(["account", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("me"))
        .stdout(predicate::str::contains("usage"));
}

#[tokio::test]
async fn skill_list_shows_marketplace_catalog() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/skills"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([skill_json("public", true)])))
        .mount(&api)
        .await;
    run_ok(&api, &["skill", "list"])
        .stdout(predicate::str::contains("nolgia-cli-basics"))
        .stdout(predicate::str::contains("v1.0.0"));
}

#[tokio::test]
async fn skill_list_marks_private_skills() {
    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/skills"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(json!([skill_json("private", true)])),
        )
        .mount(&api)
        .await;
    run_ok(&api, &["skill", "list"]).stdout(predicate::str::contains("[private]"));
}

#[tokio::test]
async fn skill_install_reports_pod_delivery() {
    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/agent/skills/nolgia-cli-basics"))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "description": "d",
            "latest_version": "1.0.0", "installed_at": "2026-06-13T00:00:00Z"
        })))
        .mount(&api)
        .await;
    run_ok(&api, &["skill", "install", "nolgia-cli-basics"]).stdout(predicate::str::contains(
        "installed nolgia-cli-basics v1.0.0",
    ));
}

#[tokio::test]
async fn skill_sync_materializes_installed_skills() {
    use base64::Engine as _;
    // Build a tiny skill tarball to serve as content.
    let targz = {
        let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        let mut builder = tar::Builder::new(encoder);
        let body = b"---\nname: nolgia-cli-basics\n---\n";
        let mut header = tar::Header::new_gnu();
        header.set_size(body.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        builder
            .append_data(&mut header, "SKILL.md", &body[..])
            .unwrap();
        builder.into_inner().unwrap().finish().unwrap()
    };

    let api = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/agent/skills"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([{
            "slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "description": "d",
            "latest_version": "1.0.0", "installed_at": "2026-06-13T00:00:00Z"
        }])))
        .mount(&api)
        .await;
    Mock::given(method("GET"))
        .and(path("/v1/skills/nolgia-cli-basics/content"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "slug": "nolgia-cli-basics", "version": "1.0.0", "manifest": {},
            "content_base64": base64::engine::general_purpose::STANDARD.encode(&targz)
        })))
        .mount(&api)
        .await;

    let dir = tempfile::tempdir().unwrap();
    run_ok(
        &api,
        &["skill", "sync", "--dir", dir.path().to_str().unwrap()],
    )
    .stdout(predicate::str::contains(
        "synced   nolgia-cli-basics v1.0.0",
    ));
    assert!(dir.path().join("nolgia-cli-basics/SKILL.md").is_file());
    assert!(
        dir.path()
            .join("nolgia-cli-basics/.nolgia-skill.json")
            .is_file()
    );

    // Second sync is a no-op ("current"), driven by the version marker.
    run_ok(
        &api,
        &["skill", "sync", "--dir", dir.path().to_str().unwrap()],
    )
    .stdout(predicate::str::contains(
        "current  nolgia-cli-basics v1.0.0",
    ));
}

#[tokio::test]
async fn skill_publish_sends_manifest_and_content() {
    let pkg = tempfile::tempdir().unwrap();
    std::fs::write(
        pkg.path().join("skill.json"),
        json!({
            "slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "version": "1.0.0",
            "description": "CLI basics", "required_env": ["NOLGIA_TOKEN"],
            "min_tier": "", "visibility": "public", "credit_cost_hint": "free"
        })
        .to_string(),
    )
    .unwrap();
    std::fs::write(
        pkg.path().join("SKILL.md"),
        "---\nname: nolgia-cli-basics\n---\n",
    )
    .unwrap();

    let api = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/skills"))
        .respond_with(ResponseTemplate::new(201).set_body_json(skill_json("public", true)))
        .mount(&api)
        .await;
    run_ok(&api, &["skill", "publish", pkg.path().to_str().unwrap()]).stdout(
        predicate::str::contains("published nolgia-cli-basics v1.0.0 (public, min_tier: free)"),
    );
}

#[test]
fn skill_help_lists_authoring_verbs() {
    cmd()
        .args(["skill", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("init"))
        .stdout(predicate::str::contains("pack"))
        .stdout(predicate::str::contains("publish"));
}

#[tokio::test]
async fn skill_init_pack_publish_roundtrip() {
    let base = tempfile::tempdir().unwrap();
    let authoring = base.path().join("my-skill");
    let api = MockServer::start().await;

    run_ok(
        &api,
        &[
            "skill",
            "init",
            "my-skill",
            "--dir",
            authoring.to_str().unwrap(),
        ],
    )
    .stdout(predicate::str::contains("nolgia skill pack"));

    // Author the skill: drop code into payload/ and declare a pip dep.
    std::fs::write(authoring.join("payload/tool.py"), "print('hi')\n").unwrap();
    let manifest = std::fs::read_to_string(authoring.join("skill.json")).unwrap();
    assert!(manifest.contains("\"python_requirements\": []"));
    std::fs::write(
        authoring.join("skill.json"),
        manifest.replace(
            "\"python_requirements\": []",
            "\"python_requirements\": [\"requests>=2.31\"]",
        ),
    )
    .unwrap();

    let out = base.path().join("dist/my-skill");
    run_ok(
        &api,
        &[
            "skill",
            "pack",
            authoring.to_str().unwrap(),
            "--out",
            out.to_str().unwrap(),
        ],
    )
    .stdout(predicate::str::contains("packed my-skill v0.1.0"))
    .stdout(predicate::str::contains("tool.py"));
    // Payload contents land at the package root, next to SKILL.md.
    assert!(out.join("tool.py").is_file());
    assert!(!out.join("payload").exists());

    // The packed dir publishes as-is; python_requirements travels verbatim
    // inside the manifest.
    Mock::given(method("POST"))
        .and(path("/v1/skills"))
        .and(body_partial_json(json!({
            "slug": "my-skill", "version": "0.1.0", "visibility": "private",
            "manifest": { "python_requirements": ["requests>=2.31"] }
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(skill_json("private", true)))
        .mount(&api)
        .await;
    run_ok(&api, &["skill", "publish", out.to_str().unwrap()])
        .stdout(predicate::str::contains("published"));
}

#[test]
fn skill_pack_rejects_bad_version() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("skill.json"),
        json!({
            "slug": "my-skill", "name": "My Skill", "version": "1.0",
            "description": "d", "visibility": "private"
        })
        .to_string(),
    )
    .unwrap();
    std::fs::write(dir.path().join("SKILL.md"), "---\nname: my-skill\n---\n").unwrap();
    cmd()
        .args(["skill", "pack", dir.path().to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("version"));
}

fn skill_json(visibility: &str, entitled: bool) -> serde_json::Value {
    json!({
        "slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics",
        "description": "Drive the platform with the nolgia CLI", "required_env": ["NOLGIA_TOKEN"],
        "credit_cost_hint": "free", "min_tier": "", "visibility": visibility, "entitled": entitled,
        "latest_version": "1.0.0",
        "created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
    })
}

fn cmd() -> Command {
    let mut command = Command::cargo_bin("nolgia").unwrap();
    command.env_remove("NOLGIA_TOKEN");
    command
}

fn run_ok(api: &MockServer, args: &[&str]) -> assert_cmd::assert::Assert {
    cmd()
        .arg("--api-url")
        .arg(api.uri())
        .args(args)
        .assert()
        .success()
}

fn asset_json(url: &str) -> serde_json::Value {
    json!({
        "id": Uuid::new_v4(), "user_id": USER_ID, "modality": "image", "model": "fal-ai/flux-pro/v1.1",
        "signed_url": url, "expires_at": "2026-06-13T00:00:00Z", "created_at": "2026-06-13T00:00:00Z"
    })
}

fn character_json() -> serde_json::Value {
    json!({
        "id": CHARACTER_ID, "user_id": USER_ID, "name": "Captain Nova",
        "description": "Silver-haired astronaut",
        "reference_assets": [asset_json("https://files/ref.png")],
        "created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
    })
}

fn project_json() -> serde_json::Value {
    json!({
        "id": PROJECT_ID, "user_id": USER_ID, "name": "Launch teaser",
        "description": "Spring launch assets", "asset_count": 3,
        "created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
    })
}

fn job_json(status: &str, files_base: Option<&str>) -> serde_json::Value {
    json!({
        "id": JOB_ID, "user_id": USER_ID, "modality": "video", "model": "fal-ai/kling-video/v3/text-to-video",
        "status": status, "asset": files_base.map(|base| asset_json(&format!("{base}/video.mp4"))),
        "created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
    })
}

fn credit_balance_json() -> serde_json::Value {
    json!({
        "user_id": USER_ID, "app_subscription": 546631, "shared_topup": 250, "total": 546881,
        "available_for_app": 546881, "available_for_api": 250,
        "buckets": [
            {"wallet_id": Uuid::new_v4(), "type": "app_subscription", "balance": 546631, "expires_at": "2026-08-01T00:00:00Z"},
            {"wallet_id": Uuid::new_v4(), "type": "shared_topup", "balance": 250, "expires_at": null}
        ]
    })
}

fn pat_json() -> serde_json::Value {
    json!({
        "id": PAT_ID, "name": "ci-bot", "prefix": "nol_a1b2",
        "created_at": "2026-06-13T00:00:00Z", "last_used_at": null, "revoked_at": null
    })
}

fn user_json() -> serde_json::Value {
    json!({"id": USER_ID, "email": "ada@nolgia.ai", "name": "Ada", "image_url": null, "created_at": "2026-06-13T00:00:00Z"})
}
