#![expect(
    clippy::disallowed_methods,
    reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]

use std::fs;
use std::time::Duration;

use fabro_client::ServerTarget;
use fabro_test::{fabro_snapshot, test_context};
use insta::assert_snapshot;

use super::support::{
    local_dev_token, run_state, server_target, setup_completed_dry_run,
    setup_seeded_completed_dry_run, setup_seeded_created_dry_run,
};
use crate::support::{LightweightCli, seed_dev_token_auth};

#[test]
fn help() {
    let context = test_context!();
    let mut cmd = context.command();
    cmd.args(["dump", "--help"]);
    fabro_snapshot!(context.filters(), cmd, @"
    success: true
    exit_code: 0
    ----- stdout -----
    Export a run's durable state to a directory

    Usage: fabro dump [OPTIONS] --output <OUTPUT> <RUN>

    Arguments:
      <RUN>  Run ID prefix or workflow name

    Options:
          --json              Output as JSON [env: FABRO_JSON=]
          --server <SERVER>   Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
          --debug             Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
      -o, --output <OUTPUT>   Output directory (must not exist or be empty)
          --no-upgrade-check  Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
          --quiet             Suppress non-essential output [env: FABRO_QUIET=]
          --verbose           Enable verbose output [env: FABRO_VERBOSE=]
      -h, --help              Print help
    ----- stderr -----
    ");
}

#[test]
fn dump_accepts_server_target_from_separate_home() {
    let context = test_context!();
    let run = setup_seeded_completed_dry_run(&context);
    let cli = LightweightCli::new();
    let output_dir = context.temp_dir.join("remote-export");
    let server = server_target(&context.storage_dir);
    if let Some(dev_token) = local_dev_token(&context.storage_dir) {
        let target = server
            .parse::<ServerTarget>()
            .expect("server target should parse");
        seed_dev_token_auth(cli.home(), &target, &dev_token);
    }

    let mut cmd = cli.command();
    cmd.args([
        "dump",
        "--server",
        &server,
        "--output",
        output_dir.to_str().unwrap(),
        &run.run_id,
    ]);

    let output = cmd.output().expect("dump should execute");
    assert!(
        output.status.success(),
        "dump via remote server target failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output_dir.join("run.json").is_file());
}

#[test]
fn dump_exports_large_command_output_backed_by_blob_refs() {
    let context = test_context!();
    let workflow = context.temp_dir.join("large-output.fabro");
    fs::write(
        &workflow,
        r#"digraph LargeOutput {
    graph [goal="Generate oversized command output"]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]
    big   [shape=parallelogram, label="Big", script="printf '%*s' 120000 '' | tr ' ' x"]

    start -> big -> exit
}
"#,
    )
    .unwrap();

    let mut run_cmd = context.run_cmd();
    run_cmd.current_dir(&context.temp_dir);
    run_cmd.timeout(Duration::from_secs(30));
    run_cmd.args(["--environment", "local"]);
    run_cmd.arg(&workflow);
    let run_output = run_cmd.output().expect("command should execute");
    assert!(
        run_output.status.success(),
        "workflow run failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&run_output.stdout),
        String::from_utf8_lossy(&run_output.stderr)
    );
    let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string();

    let mut inspect_cmd = context.command();
    inspect_cmd.args(["inspect", "--json", &run_id]);
    let inspect_output = inspect_cmd.output().expect("inspect should execute");
    assert!(
        inspect_output.status.success(),
        "inspect failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&inspect_output.stdout),
        String::from_utf8_lossy(&inspect_output.stderr)
    );
    let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
    assert!(
        inspect_json.contains("blob://sha256/"),
        "inspect output should contain blob refs to exercise hydration\n{inspect_json}"
    );

    let output_dir = context.temp_dir.join("export");
    let mut dump_cmd = context.command();
    dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
    let dump_output = dump_cmd.output().expect("dump should execute");
    assert!(
        dump_output.status.success(),
        "dump failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&dump_output.stdout),
        String::from_utf8_lossy(&dump_output.stderr)
    );

    let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
    assert!(
        !run_json.contains("blob://sha256/"),
        "run export should hydrate blob refs\n{run_json}"
    );
}

#[test]
fn dump_exports_blob_refs_and_artifacts_together() {
    let context = test_context!();
    let workspace_dir = context.temp_dir.join("mixed-export");
    fs::create_dir_all(&workspace_dir).unwrap();

    fs::write(
        workspace_dir.join("mixed-export.fabro"),
        r#"digraph MixedExport {
    graph [goal="Generate oversized command output and artifacts"]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]
    big   [shape=parallelogram, label="Big", script="mkdir -p assets/shared && printf exported > assets/shared/report.txt && printf '%*s' 120000 '' | tr ' ' x"]

    start -> big -> exit
}
"#,
    )
    .unwrap();
    fs::write(
        workspace_dir.join("run.toml"),
        r#"_version = 1

[workflow]
graph = "mixed-export.fabro"

[run]
goal = "Generate oversized command output and artifacts"

[run.environment]
id = "local"

[run.artifacts]
include = ["assets/**"]
"#,
    )
    .unwrap();

    let mut run_cmd = context.run_cmd();
    run_cmd.current_dir(&workspace_dir);
    run_cmd.timeout(Duration::from_secs(30));
    run_cmd.args(["--environment", "local", "run.toml"]);
    let run_output = run_cmd.output().expect("command should execute");
    assert!(
        run_output.status.success(),
        "workflow run failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&run_output.stdout),
        String::from_utf8_lossy(&run_output.stderr)
    );
    let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string();

    let mut inspect_cmd = context.command();
    inspect_cmd.args(["inspect", "--json", &run_id]);
    let inspect_output = inspect_cmd.output().expect("inspect should execute");
    assert!(
        inspect_output.status.success(),
        "inspect failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&inspect_output.stdout),
        String::from_utf8_lossy(&inspect_output.stderr)
    );
    let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
    assert!(
        inspect_json.contains("blob://sha256/"),
        "inspect output should contain blob refs to exercise hydration\n{inspect_json}"
    );

    let output_dir = context.temp_dir.join("export-mixed");
    let mut dump_cmd = context.command();
    dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
    let dump_output = dump_cmd.output().expect("dump should execute");
    assert!(
        dump_output.status.success(),
        "dump failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&dump_output.stdout),
        String::from_utf8_lossy(&dump_output.stderr)
    );

    let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
    assert!(
        !run_json.contains("blob://sha256/"),
        "run export should hydrate blob refs\n{run_json}"
    );
    assert_eq!(
        fs::read_to_string(
            output_dir.join("artifacts/002-big@1/retry-0001/assets/shared/report.txt")
        )
        .unwrap(),
        "exported"
    );
}

#[test]
fn dump_exports_completed_run_snapshot() {
    let context = test_context!();
    let run = setup_completed_dry_run(&context);
    let output_dir = context.temp_dir.join("export");

    let mut cmd = context.command();
    cmd.args([
        "dump",
        "--output",
        output_dir.to_str().unwrap(),
        &run.run_id,
    ]);
    fabro_snapshot!(context.filters(), cmd, @"
    success: true
    exit_code: 0
    ----- stdout -----
    Exported 13 files for run [ULID] to [TEMP_DIR]/export
    ----- stderr -----
    ");

    assert_snapshot!(dump_file_summary(&output_dir), @"
    checkpoints/0014.json
    checkpoints/0018.json
    checkpoints/0022.json
    events.jsonl
    graph.fabro
    run.json
    run.log
    stages/001-start@1/status.json
    stages/002-run_tests@1/response.md
    stages/002-run_tests@1/status.json
    stages/003-report@1/response.md
    stages/003-report@1/status.json
    stages/004-exit@1/status.json
    ");
}

#[test]
fn dump_succeeds_when_run_log_is_missing() {
    let context = test_context!();
    let run = setup_seeded_created_dry_run(&context);
    let output_dir = context.temp_dir.join("export-missing-log");

    let mut cmd = context.command();
    cmd.args([
        "dump",
        "--output",
        output_dir.to_str().unwrap(),
        &run.run_id,
    ]);
    let output = cmd.output().expect("dump should execute");
    assert!(
        output.status.success(),
        "dump failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        !output_dir.join("run.log").exists(),
        "dump should skip run.log when the server has no run log"
    );
}

#[test]
fn dump_rejects_non_empty_output_dir() {
    let context = test_context!();
    let run = setup_seeded_completed_dry_run(&context);
    let output_dir = context.temp_dir.join("nonempty");
    std::fs::create_dir_all(&output_dir).unwrap();
    std::fs::write(output_dir.join("file.txt"), "x").unwrap();

    let mut cmd = context.command();
    cmd.args([
        "dump",
        "--output",
        output_dir.to_str().unwrap(),
        &run.run_id,
    ]);
    fabro_snapshot!(context.filters(), cmd, @"
    success: false
    exit_code: 1
    ----- stdout -----
    ----- stderr -----
      × output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
    ");
}

fn dump_file_summary(output_dir: &std::path::Path) -> String {
    let mut files: Vec<String> = walkdir::WalkDir::new(output_dir)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .map(|entry| {
            entry
                .path()
                .strip_prefix(output_dir)
                .expect("walked file should stay under the output directory")
                .to_string_lossy()
                .replace('\\', "/")
        })
        .collect();
    files.sort();
    files.join("\n") + "\n"
}
