use serde_json::json;
use smol_workflow_engine::agent_providers::{
    AgentProvider, AgentProviderRunInput, ClaudeCodeAgentProvider, ClaudeCodeAgentProviderOptions,
    CodexAgentProvider, CodexAgentProviderOptions, OpenCodeAgentProvider,
    OpenCodeAgentProviderOptions, PiAgentProvider, PiAgentProviderOptions,
};
use smol_workflow_engine::environment::{AgentExecutionEnvironment, LocalExecutionEnvironment};
use std::sync::Arc;
fn fixture(name: &str) -> String {
    format!("tests/fixtures/{name}")
}

fn node() -> String {
    std::env::var("NODE").unwrap_or_else(|_| "node".to_string())
}

fn local_environment() -> Arc<dyn AgentExecutionEnvironment> {
    Arc::new(LocalExecutionEnvironment::new(None).unwrap())
}

fn input(prompt: &str) -> AgentProviderRunInput {
    AgentProviderRunInput {
        prompt: prompt.to_string(),
        options: None,
        context: Default::default(),
        environment: local_environment(),
    }
}

fn schema_input(prompt: &str) -> AgentProviderRunInput {
    AgentProviderRunInput {
        prompt: prompt.to_string(),
        options: Some(json!({
            "schema": {
                "type": "object",
                "properties": {
                    "summary": { "type": "string" },
                    "count": { "type": "number" }
                },
                "required": ["summary", "count"]
            }
        })),
        context: Default::default(),
        environment: local_environment(),
    }
}

#[tokio::test(flavor = "current_thread")]
async fn claude_code_provider_invokes_print_mode_and_extracts_usage() {
    let provider = ClaudeCodeAgentProvider::new(ClaudeCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-claude-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(input("hello claude"))
        .await
        .expect("provider should run");

    assert_eq!(provider.name(), "claude-code");
    assert_eq!(result.output, json!("fake claude: hello claude"));
    assert_eq!(result.session_id.as_deref(), Some("claude-session-1"));
    let usage = result.usage.expect("usage");
    assert_eq!(usage.input_tokens, Some(11));
    assert_eq!(usage.output_tokens, Some(6));
    assert_eq!(usage.cache_read_tokens, Some(3));
    assert_eq!(usage.cache_write_tokens, Some(4));
    assert_eq!(usage.total_tokens, Some(24));
    assert_eq!(usage.cost.unwrap().total, Some(0.123));
    assert_eq!(result.raw.as_ref().unwrap()["events"][0]["type"], "result");
    assert_eq!(
        result.raw.as_ref().unwrap()["events"][0]["session_id"],
        "claude-session-1"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn claude_code_provider_parses_structured_output_and_stdin() {
    let provider = ClaudeCodeAgentProvider::new(ClaudeCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-claude-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(schema_input("structured snapshot"))
        .await
        .expect("provider should run");

    assert_eq!(result.output["summary"], "structured claude summary");
    assert_eq!(result.output["prompt"], "structured snapshot");
    assert_eq!(
        result.raw.as_ref().unwrap()["events"][0]["argv"],
        json!([
            "--output-format",
            "stream-json",
            "--verbose",
            "--input-format",
            "text",
            "--json-schema",
            serde_json::to_string(&schema_input("structured snapshot").options.unwrap()["schema"])
                .unwrap(),
            "--print"
        ])
    );
}

#[tokio::test(flavor = "current_thread")]
async fn claude_code_provider_maps_agent_type_to_agent_flag() {
    let provider = ClaudeCodeAgentProvider::new(ClaudeCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-claude-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(AgentProviderRunInput {
            prompt: "specialized claude".to_string(),
            options: Some(json!({ "agentType": "reviewer" })),
            context: Default::default(),
            environment: local_environment(),
        })
        .await
        .expect("provider should run");

    assert_eq!(
        result.raw.as_ref().unwrap()["events"][0]["argv"],
        json!([
            "--agent",
            "reviewer",
            "--output-format",
            "stream-json",
            "--verbose",
            "--input-format",
            "text",
            "--print"
        ])
    );
}

#[tokio::test(flavor = "current_thread")]
async fn claude_code_provider_derives_usage_without_double_counting_cache_reads() {
    let provider = ClaudeCodeAgentProvider::new(ClaudeCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-claude-provider.mjs")],
        ..Default::default()
    });

    let usage = provider
        .run(input("usage-no-total"))
        .await
        .expect("provider should run")
        .usage
        .expect("usage");
    assert_eq!(usage.total_tokens, Some(21));
}

#[tokio::test(flavor = "current_thread")]
async fn codex_provider_reads_output_schema_and_usage() {
    let provider = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-codex-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(input("hello codex"))
        .await
        .expect("provider should run");
    assert_eq!(provider.name(), "codex");
    assert_eq!(result.output, json!("fake codex: hello codex"));
    assert_eq!(result.session_id.as_deref(), Some("codex-session-1"));
    assert_eq!(result.usage.unwrap().total_tokens, Some(15));

    let structured = provider
        .run(schema_input("structured prompt"))
        .await
        .expect("provider should run");
    assert_eq!(structured.output["summary"], "structured debug summary");
    assert_eq!(structured.output["additionalProperties"], false);
}

#[tokio::test(flavor = "current_thread")]
async fn codex_provider_handles_fallback_and_escaped_structured_output() {
    let provider = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-codex-provider.mjs")],
        ..Default::default()
    });

    let fallback = provider
        .run(input("stdout-fallback"))
        .await
        .expect("provider should run");
    assert_eq!(fallback.output, json!("fake codex: stdout-fallback"));

    for prompt in [
        "escaped-structured",
        "quoted-structured",
        "structured-fallback",
    ] {
        let result = provider
            .run(schema_input(prompt))
            .await
            .expect("provider should parse structured output");
        assert_eq!(result.output["prompt"], prompt);
    }
}

#[tokio::test(flavor = "current_thread")]
async fn codex_provider_preserves_required_subset_and_cache_aliases() {
    let provider = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-codex-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(AgentProviderRunInput {
            prompt: "partial-required".into(),
            options: Some(json!({
                "schema": {
                    "type": "object",
                    "properties": { "name": { "type": "string" }, "nickname": { "type": "string" } },
                    "required": ["name"]
                }
            })),
            context: Default::default(),
            environment: local_environment(),
        })
        .await
        .expect("provider should run");
    assert_eq!(result.output["required"], json!(["name"]));

    let no_required = provider
        .run(AgentProviderRunInput {
            prompt: "no-required".into(),
            options: Some(json!({
                "schema": {
                    "type": "object",
                    "properties": { "value": { "type": "string" } }
                }
            })),
            context: Default::default(),
            environment: local_environment(),
        })
        .await
        .expect("provider should run");
    assert_eq!(no_required.output["required"], json!([]));
    assert_eq!(no_required.output["additionalProperties"], false);

    let usage = provider
        .run(input("cache-alias"))
        .await
        .unwrap()
        .usage
        .unwrap();
    assert_eq!(usage.input_tokens, Some(5));
    assert_eq!(usage.output_tokens, Some(3));
    assert_eq!(usage.cache_read_tokens, Some(4));
    assert_eq!(usage.cache_write_tokens, Some(2));
    assert_eq!(usage.total_tokens, Some(10));
}

#[tokio::test(flavor = "current_thread")]
async fn codex_provider_preserves_explicit_skip_git_repo_check_arg() {
    let provider = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![
            fixture("fake-codex-provider.mjs"),
            "--skip-git-repo-check".into(),
        ],
        ..Default::default()
    });

    let result = provider
        .run(input("hello codex"))
        .await
        .expect("provider should run");
    let argv = result.raw.as_ref().unwrap()["events"]
        .as_array()
        .unwrap()
        .iter()
        .find(|event| event["type"] == "argv")
        .and_then(|event| event["argv"].as_array())
        .expect("fake provider should emit argv");
    assert!(argv.iter().any(|arg| arg == "--skip-git-repo-check"));
}

#[tokio::test(flavor = "current_thread")]
async fn codex_provider_propagates_output_file_read_errors() {
    let provider = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-codex-io-error.mjs")],
        ..Default::default()
    });

    let error = provider
        .run(input("io-error"))
        .await
        .unwrap_err()
        .to_string();
    assert!(error.contains("Failed to read codex output file:"));
}

#[tokio::test(flavor = "current_thread")]
async fn opencode_provider_supports_json_run_and_structured_server_mode() {
    let provider = OpenCodeAgentProvider::new(OpenCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-opencode-provider.mjs")],
        server_subcommand: vec![fixture("fake-opencode-provider.mjs"), "serve".into()],
        ..Default::default()
    });

    let result = provider
        .run(input("hello opencode"))
        .await
        .expect("provider should run");
    assert_eq!(provider.name(), "opencode");
    assert_eq!(result.output, json!("fake opencode: hello opencode"));
    assert_eq!(result.session_id.as_deref(), Some("opencode-session-1"));
    assert_eq!(result.usage.unwrap().total_tokens, Some(19));
    assert_eq!(result.raw.as_ref().unwrap()["events"][0]["type"], "session");
    assert_eq!(
        result.raw.as_ref().unwrap()["events"][0]["sessionID"],
        "opencode-session-1"
    );

    let long_prompt = format!("long opencode {}", "x".repeat(40_000));
    let long = provider
        .run(input(&long_prompt))
        .await
        .expect("long prompt should use server transport");
    assert_eq!(long.output, json!(format!("fake opencode: {long_prompt}")));
    assert_eq!(
        long.raw.as_ref().unwrap()["events"]
            .as_array()
            .unwrap()
            .len(),
        2
    );
    assert_eq!(
        long.raw.as_ref().unwrap()["events"][1]["request"]["parts"][0]["text"],
        long_prompt
    );

    let structured = provider
        .run(schema_input("structured prompt"))
        .await
        .expect("provider should run structured mode");
    assert_eq!(structured.output["summary"], "structured opencode summary");
    assert_eq!(
        structured.session_id.as_deref(),
        Some("opencode-session-structured")
    );
    assert_eq!(
        structured.raw.as_ref().unwrap()["events"][1]["request"]["format"]["type"],
        "json_schema"
    );
    assert_eq!(
        structured.raw.as_ref().unwrap()["events"][1]["request"]["format"]["retryCount"],
        2
    );

    let tool_state = provider
        .run(schema_input("tool-state-structured"))
        .await
        .expect("provider should extract tool state");
    assert_eq!(
        tool_state.output,
        json!({ "summary": "structured opencode summary" })
    );
}

#[tokio::test(flavor = "current_thread")]
async fn opencode_provider_handles_nested_events_and_cache_aliases() {
    let provider = OpenCodeAgentProvider::new(OpenCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-opencode-provider.mjs")],
        ..Default::default()
    });

    let nested = provider.run(input("usage-nested")).await.unwrap();
    assert_eq!(nested.usage.unwrap().total_tokens, Some(8));

    let event_properties = provider.run(input("event-properties")).await.unwrap();
    assert_eq!(event_properties.output, json!("event properties result"));
    assert_eq!(
        event_properties.session_id.as_deref(),
        Some("opencode-session-2")
    );

    let tool_text = provider
        .run(input("tool-use-alongside-text"))
        .await
        .unwrap();
    assert_eq!(tool_text.output, json!("tool use result text"));

    let cache = provider
        .run(input("cache-alias"))
        .await
        .unwrap()
        .usage
        .unwrap();
    assert_eq!(cache.cache_read_tokens, Some(2));
    assert_eq!(cache.cache_write_tokens, Some(3));
}

#[tokio::test(flavor = "current_thread")]
async fn opencode_provider_http_failures_include_response_body() {
    let temp = tempfile::Builder::new()
        .prefix("opencode-session-error")
        .tempdir()
        .unwrap();
    let fixture = std::fs::canonicalize(fixture("fake-opencode-provider.mjs"))
        .unwrap()
        .to_string_lossy()
        .to_string();
    let provider = OpenCodeAgentProvider::new(OpenCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture.clone()],
        server_subcommand: vec![fixture, "serve".into()],
        cwd: Some(temp.path().to_path_buf()),
        ..Default::default()
    });

    let error = provider
        .run(schema_input("structured prompt"))
        .await
        .unwrap_err()
        .to_string();
    assert!(
        error.contains("OpenCode POST /session failed with HTTP 400 Bad Request"),
        "unexpected error: {error}"
    );
    assert!(
        error.contains("directory is not an opencode project"),
        "unexpected error: {error}"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn pi_provider_supports_json_mode_prompt_files_and_structured_tool_output() {
    let provider = PiAgentProvider::new(PiAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-pi-provider.mjs")],
        ..Default::default()
    });

    let result = provider
        .run(input("hello pi"))
        .await
        .expect("provider should run");
    assert_eq!(provider.name(), "pi");
    assert_eq!(result.output, json!("fake pi: hello pi"));
    assert_eq!(result.session_id.as_deref(), Some("pi-session-1"));
    assert_eq!(result.usage.unwrap().total_tokens, Some(26));
    assert_eq!(result.raw.as_ref().unwrap()["events"][0]["type"], "session");
    assert_eq!(
        result.raw.as_ref().unwrap()["events"][0]["id"],
        "pi-session-1"
    );

    let long_prompt = format!("long prompt {}", "x".repeat(40_000));
    let long = provider
        .run(input(&long_prompt))
        .await
        .expect("provider should run");
    assert_eq!(long.output, json!(format!("fake pi: {long_prompt}")));

    let structured = provider
        .run(AgentProviderRunInput {
            prompt: "structured prompt".into(),
            options: Some(json!({
                "schema": {
                    "type": "object",
                    "properties": { "summary": { "type": "string" } },
                    "required": ["summary"]
                }
            })),
            context: Default::default(),
            environment: local_environment(),
        })
        .await
        .expect("provider should run");
    assert_eq!(structured.output["summary"], "structured pi summary");
    assert_eq!(structured.output["extensionRegisteredTool"], true);

    let recovered = provider
        .run(AgentProviderRunInput {
            prompt: "structured-tool-error-with-args".into(),
            options: Some(json!({
                "schema": {
                    "type": "object",
                    "properties": { "summary": { "type": "string" } },
                    "required": ["summary"]
                }
            })),
            context: Default::default(),
            environment: local_environment(),
        })
        .await
        .expect("provider should recover attempted structured output");
    assert_eq!(recovered.output["summary"], "structured pi summary");
    assert_eq!(recovered.output["extensionRegisteredTool"], true);

    let message_update = provider
        .run(input("message-update-only"))
        .await
        .expect("provider should parse message_update output");
    assert_eq!(message_update.output, json!("fake pi: message-update-only"));

    let agent_end_without_role = provider
        .run(input("agent-end-message-without-role"))
        .await
        .expect("provider should parse agent_end assistant message without role");
    assert_eq!(
        agent_end_without_role.output,
        json!("fake pi: agent-end-message-without-role")
    );

    let tool_result_only = provider
        .run(input("tool-result-only"))
        .await
        .expect("provider should fall back to tool result text");
    assert_eq!(tool_result_only.output, json!("fake pi: tool-result-only"));

    let cache = provider
        .run(input("cache-alias"))
        .await
        .unwrap()
        .usage
        .unwrap();
    assert_eq!(cache.cache_read_tokens, Some(4));
    assert_eq!(cache.cache_write_tokens, Some(2));
    assert_eq!(cache.total_tokens, Some(10));
}

#[tokio::test(flavor = "current_thread")]
async fn pi_provider_treats_json_error_events_as_failures() {
    let provider = PiAgentProvider::new(PiAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-pi-provider.mjs")],
        ..Default::default()
    });

    let error = provider
        .run(input("model-error"))
        .await
        .unwrap_err()
        .to_string();
    assert!(error.contains("fake provider model error"));
}

#[tokio::test(flavor = "current_thread")]
async fn cli_provider_failures_include_stderr() {
    let claude = ClaudeCodeAgentProvider::new(ClaudeCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-claude-provider.mjs")],
        ..Default::default()
    });
    let error = claude.run(input("fail")).await.unwrap_err().to_string();
    assert!(error.contains("Claude Code provider exited with code 7: nope"));

    let codex = CodexAgentProvider::new(CodexAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-codex-provider.mjs")],
        ..Default::default()
    });
    let error = codex.run(input("fail")).await.unwrap_err().to_string();
    assert!(error.contains("Codex provider exited with code 7: nope"));

    let opencode = OpenCodeAgentProvider::new(OpenCodeAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-opencode-provider.mjs")],
        ..Default::default()
    });
    let error = opencode.run(input("fail")).await.unwrap_err().to_string();
    assert!(error.contains("OpenCode provider exited with code 7: nope"));

    let pi = PiAgentProvider::new(PiAgentProviderOptions {
        command: Some(node()),
        subcommand: vec![fixture("fake-pi-provider.mjs")],
        ..Default::default()
    });
    let error = pi.run(input("fail")).await.unwrap_err().to_string();
    assert!(error.contains("Pi provider exited with code 7: nope"));
}
