//! Smoke tests for OpenRouter error handling and retry behavior.
//!
//! Exercises the error handling pipeline via the public API:
//! - Invalid model strings return clear errors
//! - Timeout behavior
//! - Malformed tool arguments are handled gracefully (no infinite loop)
//! - Model validation produces user-friendly errors
//!
//! Most tests use a mock HTTP server; live API tests are gated with
//! `#[ignore]` and require a valid `OPENROUTER_API_KEY`.
//!
//! Run with: cargo test --test smoke_openrouter_errors
//! For live tests: cargo test --test smoke_openrouter_errors -- --ignored

use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;

use tempfile::TempDir;
use workgraph::config::parse_model_spec;
use workgraph::executor::native::client::{ContentBlock, Message, MessagesRequest, Role};
use workgraph::executor::native::openai_client::{OpenAiClient, validate_openrouter_model};
use workgraph::executor::native::provider::Provider;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn wg_binary() -> PathBuf {
    let mut path = std::env::current_exe().expect("could not get current exe path");
    path.pop();
    if path.ends_with("deps") {
        path.pop();
    }
    path.push("wg");
    assert!(
        path.exists(),
        "wg binary not found at {:?}. Run `cargo build` first.",
        path
    );
    path
}

fn wg_cmd(wg_dir: &Path, args: &[&str]) -> std::process::Output {
    let fake_home = wg_dir.parent().unwrap_or(wg_dir).join("fakehome");
    fs::create_dir_all(&fake_home).unwrap_or_default();
    Command::new(wg_binary())
        .arg("--dir")
        .arg(wg_dir)
        .args(args)
        .env("HOME", &fake_home)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .unwrap_or_else(|e| panic!("Failed to run wg {:?}: {}", args, e))
}

/// Build a minimal messages request for testing.
fn make_request(model: &str) -> MessagesRequest {
    MessagesRequest {
        model: model.to_string(),
        system: Some("You are a helpful assistant.".to_string()),
        messages: vec![Message {
            role: Role::User,
            content: vec![ContentBlock::Text {
                text: "Reply with exactly the word 'ping'.".to_string(),
            }],
        }],
        tools: vec![],
        max_tokens: 10,
        stream: false,
    }
}

/// Run an async block in a new runtime and return the result.
fn block_on<T>(fut: impl std::future::Future<Output = T>) -> T {
    tokio::runtime::Runtime::new().unwrap().block_on(fut)
}

// ---------------------------------------------------------------------------
// Test 1: Model string parsing
// ---------------------------------------------------------------------------

/// Verifies that parse_model_spec correctly handles model strings.
#[test]
fn parse_model_spec_handles_strings_gracefully() {
    // Valid format: "provider:model"
    let valid = parse_model_spec("openrouter:minimax/minimax-m2.7");
    assert!(
        valid.provider.is_some(),
        "Valid model spec should parse provider"
    );
    assert_eq!(valid.model_id, "minimax/minimax-m2.7");

    // Bare model should work
    let bare = parse_model_spec("some-model");
    assert!(
        bare.provider.is_none(),
        "Bare model should have no provider"
    );

    // Empty string should not panic
    let empty = parse_model_spec("");
    assert_eq!(
        empty.model_id, "",
        "Empty model spec should return empty model_id"
    );

    // Provider prefix with slash format (e.g., openai/gpt-4o-mini)
    // parse_model_spec checks for a known provider prefix, not bare slash
    let with_slash = parse_model_spec("openai/gpt-4o-mini");
    // It may or may not detect provider depending on implementation
    // Just verify it doesn't panic and returns something
    assert!(!with_slash.model_id.is_empty() || with_slash.provider.is_some());
}

// ---------------------------------------------------------------------------
// Test 2: OpenRouter client configuration
// ---------------------------------------------------------------------------

/// Verifies that the OpenRouter client is configured correctly.
#[test]
fn openrouter_client_configured_correctly() {
    let client = OpenAiClient::new("sk-or-test".into(), "minimax/minimax-m2.7", None)
        .unwrap()
        .with_provider_hint("openrouter");

    // Verify the client has the right model
    assert_eq!(client.model, "minimax/minimax-m2.7");

    // Verify provider hint is set
    assert_eq!(client.name(), "openrouter");
}

// ---------------------------------------------------------------------------
// Test 3: CLI reports errors clearly (no panic traces)
// ---------------------------------------------------------------------------

/// Verifies that the CLI reports errors clearly without leaking panic traces.
#[test]
fn cli_error_reported_clearly_no_panic_trace() {
    let tmp = TempDir::new().unwrap();
    let wg_dir = tmp.path().join(".wg");
    fs::create_dir_all(&wg_dir).unwrap();

    // Write config with invalid key
    let config_path = wg_dir.join("config.toml");
    fs::write(
        &config_path,
        r#"
[native_executor]
api_key = "sk-or-invalid-key-that-will-fail"
"#,
    )
    .unwrap();

    // Run a command that would make an API call
    let output = wg_cmd(&wg_dir, &["model", "list"]);
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    // Should not panic, should report error clearly
    if !output.status.success() {
        assert!(
            !stderr.contains("thread '"),
            "stderr should not contain thread names: {}",
            stderr
        );
        eprintln!("CLI error (expected for invalid key): {}", stderr);
    }
}

// ---------------------------------------------------------------------------
// Test 4: Non-existent endpoint fails quickly
// ---------------------------------------------------------------------------

/// Verifies that using a non-existent endpoint produces a clear error quickly.
#[test]
fn nonexistent_endpoint_fails_quickly() {
    let client = OpenAiClient::new(
        "sk-test".into(),
        "minimax/minimax-m2.7",
        Some("http://localhost:59999"),
    )
    .unwrap()
    .with_provider_hint("openrouter");

    let request = make_request("minimax/minimax-m2.7");
    let start = std::time::Instant::now();
    let result = block_on(client.send(&request));
    let elapsed = start.elapsed();

    assert!(
        result.is_err(),
        "Should get an error for non-existent endpoint"
    );
    let err_msg = result.unwrap_err().to_string();

    // Error should be clean (no panic traces)
    assert!(
        !err_msg.contains("thread '"),
        "Error should not contain thread names: {}",
        err_msg
    );
    assert!(err_msg.len() < 500, "Error message should be concise");

    // Should fail relatively quickly (not hang)
    assert!(
        elapsed < Duration::from_secs(10),
        "Should fail quickly, not hang. Took {:?}",
        elapsed
    );

    eprintln!(
        "Connection error (expected): {} (took {:?})",
        err_msg, elapsed
    );
}

// ---------------------------------------------------------------------------
// Test 5: Model validation produces user-friendly errors
// ---------------------------------------------------------------------------

/// Verifies that model validation errors are user-friendly.
#[test]
fn model_validation_error_is_user_friendly() {
    let tmp = TempDir::new().unwrap();
    let wg_dir = tmp.path().join(".wg");
    fs::create_dir_all(&wg_dir).unwrap();

    // Write a model cache with some models
    let cache_path = wg_dir.join("model_cache.json");
    fs::write(
        &cache_path,
        r#"{"models":[{"id":"some/valid-model"},{"id":"another/model"}]}"#,
    )
    .unwrap();

    let result = validate_openrouter_model("minimax/nonexistent-model", &wg_dir);

    if !result.was_valid {
        assert!(
            result.warning.is_some(),
            "Invalid model should have a warning"
        );
        let warning = result.warning.unwrap();
        assert!(
            !warning.contains("thread '"),
            "Warning should not contain thread names"
        );
        assert!(warning.len() < 500, "Warning should be concise");
        assert!(
            warning.contains("nonexistent-model"),
            "Warning should mention the invalid model name"
        );
        eprintln!("Model validation warning: {}", warning);
    }
}

// ---------------------------------------------------------------------------
// Test 6: Model validation with valid model passes
// ---------------------------------------------------------------------------

/// Verifies that a valid model passes validation.
#[test]
fn valid_model_passes_validation() {
    let tmp = TempDir::new().unwrap();
    let wg_dir = tmp.path().join(".wg");
    fs::create_dir_all(&wg_dir).unwrap();

    // Write a model cache
    let cache_path = wg_dir.join("model_cache.json");
    fs::write(
        &cache_path,
        r#"{"models":[{"id":"minimax/minimax-m2.7"},{"id":"openai/gpt-4o"}]}"#,
    )
    .unwrap();

    let result = validate_openrouter_model("minimax/minimax-m2.7", &wg_dir);
    assert!(result.was_valid, "Valid model should pass validation");
}

// ---------------------------------------------------------------------------
// Test 7: openrouter/auto is always valid
// ---------------------------------------------------------------------------

/// Verifies that openrouter/auto model is always considered valid.
#[test]
fn openrouter_auto_is_always_valid() {
    let tmp = TempDir::new().unwrap();
    let wg_dir = tmp.path().join(".wg");
    fs::create_dir_all(&wg_dir).unwrap();

    // Even without a cache, openrouter/auto should be valid
    let result = validate_openrouter_model("openrouter/auto", &wg_dir);
    assert!(result.was_valid, "openrouter/auto should always be valid");
    assert_eq!(result.model, "openrouter/auto");
}

// ---------------------------------------------------------------------------
// Test 8: Live smoke test — requires OPENROUTER_API_KEY
// ---------------------------------------------------------------------------

/// End-to-end smoke test that exercises error handling through the real OpenRouter API.
///
/// This test is gated with `#[ignore]` and will only run when explicitly
/// invoked with `cargo test --test smoke_openrouter_errors -- --ignored`.
#[test]
#[ignore] // Run with: cargo test --test smoke_openrouter_errors -- --ignored
fn smoke_live_openrouter_error_handling() {
    let api_key = std::env::var("OPENROUTER_API_KEY")
        .expect("OPENROUTER_API_KEY must be set for live smoke test");

    // --- Test 1: Invalid API key (401) ---
    eprintln!("Testing invalid API key...");
    let bad_client = OpenAiClient::new(
        "sk-or-invalid-key-000000".into(),
        "minimax/minimax-m2.7",
        None,
    )
    .unwrap()
    .with_provider_hint("openrouter");
    let request = make_request("minimax/minimax-m2.7");
    let result = block_on(bad_client.send(&request));
    match result {
        Err(e) => {
            let msg = e.to_string();
            // Error should be clean (no panic traces)
            assert!(
                !msg.contains("thread '"),
                "Error should not contain thread names: {}",
                msg
            );
            eprintln!("  Auth error (expected): {}", msg);
        }
        Ok(_) => eprintln!("  Warning: Invalid key was accepted (may be test environment)"),
    }

    // --- Test 2: Invalid model string ---
    eprintln!("Testing invalid model...");
    let client = OpenAiClient::new(api_key.clone(), "minimax/nonexistent-model-xyz", None)
        .unwrap()
        .with_provider_hint("openrouter");
    let result = block_on(client.send(&request));
    match result {
        Err(e) => {
            let msg = e.to_string();
            assert!(
                !msg.contains("thread '"),
                "Error should not contain thread names: {}",
                msg
            );
            eprintln!("  Invalid model error (expected): {}", msg);
        }
        Ok(_) => eprintln!("  Warning: Invalid model was accepted"),
    }

    // --- Test 3: Valid model (should succeed or fail with API error, not crash) ---
    eprintln!("Testing valid model...");
    let client = OpenAiClient::new(api_key.clone(), "minimax/minimax-m2.7", None)
        .unwrap()
        .with_provider_hint("openrouter");
    let result = block_on(client.send(&request));
    match result {
        Ok(resp) => {
            assert!(!resp.content.is_empty(), "Response should have content");
            eprintln!("  Valid request succeeded");
        }
        Err(e) => {
            let msg = e.to_string();
            assert!(
                !msg.contains("thread '"),
                "Error should not contain thread names: {}",
                msg
            );
            eprintln!("  API error (not a crash): {}", msg);
        }
    }

    eprintln!("Live smoke test completed successfully");
}

// ---------------------------------------------------------------------------
// Test 9: HTTP error codes produce clean error messages
// ---------------------------------------------------------------------------

/// Verifies that HTTP error codes produce clean error messages via mock server.
#[test]
fn http_error_produces_clean_error_message() {
    // Start a mock server that returns 401
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    let handle = std::thread::spawn(move || {
        if let Ok((mut stream, _)) = listener.accept() {
            let mut buf = [0u8; 8192];
            let _ = stream.read(&mut buf);
            let body = r#"{"error":{"message":"Invalid API key"}}"#;
            let resp = format!(
                "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = stream.write_all(resp.as_bytes());
            let _ = stream.flush();
        }
    });

    let url = format!("http://127.0.0.1:{}", port);
    let client = OpenAiClient::new("sk-test".into(), "minimax/minimax-m2.7", Some(&url))
        .unwrap()
        .with_provider_hint("openrouter")
        .with_streaming(false);

    let request = make_request("minimax/minimax-m2.7");
    let result = block_on(client.send(&request));

    handle.join().unwrap();

    assert!(result.is_err(), "401 should produce an error");
    let err_msg = result.unwrap_err().to_string();
    // Error should be clean
    assert!(
        !err_msg.contains("thread '"),
        "Error should not contain thread names: {}",
        err_msg
    );
    assert!(
        err_msg.len() < 500,
        "Error message should be concise: {}",
        err_msg
    );
    assert!(
        err_msg.contains("401") || err_msg.contains("Invalid API key"),
        "Error should mention status or message: {}",
        err_msg
    );
    assert!(
        err_msg.contains("Authentication failed") && err_msg.contains("API key"),
        "Authentication failures should retain credential-focused guidance: {}",
        err_msg
    );
    assert!(
        !err_msg.contains("provider-side"),
        "Local authentication failures should not be classified as provider-side: {}",
        err_msg
    );
    eprintln!("HTTP 401 error (expected): {}", err_msg);
}

// ---------------------------------------------------------------------------
// Test 10: OpenRouter provider-side 402 metadata is actionable
// ---------------------------------------------------------------------------

/// Verifies that OpenRouter's structured provider metadata is rendered instead
/// of collapsing to the ambiguous top-level "Provider returned error" message.
#[test]
fn provider_side_402_metadata_is_actionable() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    let handle = std::thread::spawn(move || {
        if let Ok((mut stream, _)) = listener.accept() {
            let mut buf = [0u8; 8192];
            let _ = stream.read(&mut buf);
            let body = serde_json::json!({
                "error": {
                    "message": "Provider returned error",
                    "code": 402,
                    "metadata": {
                        "provider_name": "Crucible",
                        "raw": r#"{"error":{"type":"insufficient_quota","code":"insufficient_quota","message":"Out of credits. Top up at /dashboard/billing to continue."}}"#
                    }
                }
            })
            .to_string();
            let resp = format!(
                "HTTP/1.1 402 Payment Required\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = stream.write_all(resp.as_bytes());
            let _ = stream.flush();
        }
    });

    let url = format!("http://127.0.0.1:{}", port);
    let client = OpenAiClient::new(
        "sk-or-test-secret-not-for-display".into(),
        "deepseek/deepseek-v4-flash:free",
        Some(&url),
    )
    .unwrap()
    .with_provider_hint("openrouter")
    .with_streaming(false);

    let request = make_request("deepseek/deepseek-v4-flash:free");
    let result = block_on(client.send(&request));

    handle.join().unwrap();

    assert!(result.is_err(), "402 should produce an error");
    let err_msg = result.unwrap_err().to_string();
    assert!(
        err_msg.contains("API error 402: Provider returned error"),
        "{}",
        err_msg
    );
    assert!(
        err_msg.contains("OpenRouter provider Crucible"),
        "{}",
        err_msg
    );
    assert!(err_msg.contains("insufficient_quota"), "{}", err_msg);
    assert!(err_msg.contains("Out of credits"), "{}", err_msg);
    assert!(
        err_msg.contains("openrouter:deepseek/deepseek-v4-flash"),
        "{}",
        err_msg
    );
    assert!(
        err_msg.contains("provider-side, not a local API-key/config failure"),
        "{}",
        err_msg
    );
    assert!(
        !err_msg.contains("Check your API key") && !err_msg.contains("api_key ="),
        "Provider-side failures should not point users at credentials: {}",
        err_msg
    );
    assert!(
        !err_msg.contains("sk-or-test-secret-not-for-display"),
        "Error output must not leak API key values: {}",
        err_msg
    );
    assert!(
        err_msg.len() < 800,
        "Error message should stay concise: {}",
        err_msg
    );
    eprintln!("HTTP 402 provider error (expected): {}", err_msg);
}

// ---------------------------------------------------------------------------
// Test 11: 429 Rate limit produces clean error
// ---------------------------------------------------------------------------

/// Verifies that 429 rate limit produces a clean error via mock server.
#[test]
fn rate_limit_429_produces_clean_error() {
    // Start a mock server that returns 429
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    let handle = std::thread::spawn(move || {
        if let Ok((mut stream, _)) = listener.accept() {
            let mut buf = [0u8; 8192];
            let _ = stream.read(&mut buf);
            let body = r#"{"error":{"message":"Rate limit exceeded"}}"#;
            let resp = format!(
                "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = stream.write_all(resp.as_bytes());
            let _ = stream.flush();
        }
    });

    let url = format!("http://127.0.0.1:{}", port);
    let client = OpenAiClient::new("sk-test".into(), "minimax/minimax-m2.7", Some(&url))
        .unwrap()
        .with_provider_hint("openrouter")
        .with_streaming(false);

    let request = make_request("minimax/minimax-m2.7");
    let result = block_on(client.send(&request));

    handle.join().unwrap();

    assert!(result.is_err(), "429 should produce an error");
    let err_msg = result.unwrap_err().to_string();
    assert!(
        !err_msg.contains("thread '"),
        "Error should not contain thread names: {}",
        err_msg
    );
    assert!(err_msg.len() < 500, "Error message should be concise");
    eprintln!("HTTP 429 error (expected): {}", err_msg);
}
