//! Integration tests for `opencode_rs`.
//!
//! These tests run against a real `OpenCode` server and are gated by environment variables.
//!
//! With `server` feature (recommended - auto-spawns server):
//!   `OPENCODE_INTEGRATION=1 cargo test -p opencode_rs --features server --test integration -- --ignored`
//!
//! For bunx-provisioned testing:
//!   `OPENCODE_BINARY=bunx OPENCODE_BINARY_ARGS="--yes opencode-ai@1.17.4" OPENCODE_INTEGRATION=1 \
//!    cargo test -p opencode_rs --features server --test integration -- --ignored`
//!
//! Without `server` feature (requires pre-running server):
//!   `OPENCODE_BASE_URL=http://127.0.0.1:4096 OPENCODE_INTEGRATION=1 \
//!    cargo test -p opencode_rs --test integration -- --ignored`
//!
//! Environment variables:
//!   `OPENCODE_INTEGRATION` - Must be set to run integration tests
//!   `OPENCODE_BINARY` - Path to opencode binary or launcher (default: "opencode")
//!   `OPENCODE_BINARY_ARGS` - Extra args for launcher (e.g., "--yes opencode-ai@1.17.4")
//!   `OPENCODE_BASE_URL` - Base URL when not using managed server (default: <http://127.0.0.1:4096>)
//!   `OPENCODE_DIRECTORY` - Directory context for requests (default: current directory)

// Integration tests are allowed to use unwrap/expect for test assertions
#![allow(clippy::unwrap_used, clippy::expect_used)]

// Include submodule tests from integration/ directory
#[path = "integration/mod.rs"]
mod typed_tests;

#[cfg(feature = "server")]
use opencode_rs::server::ManagedServer;
#[cfg(feature = "server")]
use opencode_rs::server::ServerOptions;
#[cfg(feature = "server")]
use opencode_rs::version::OPENCODE_BINARY_ARGS_ENV;
#[cfg(feature = "server")]
use opencode_rs::version::OPENCODE_BINARY_ENV;
#[cfg(feature = "server")]
use std::sync::Arc;
#[cfg(feature = "server")]
use tokio::sync::OnceCell;

use opencode_rs::Client;
use opencode_rs::ClientBuilder;
use opencode_rs::types::event::Event;
use opencode_rs::types::message::CommandRequest;
use opencode_rs::types::message::PromptPart;
use opencode_rs::types::message::PromptRequest;
use opencode_rs::types::session::CreateSessionRequest;
use std::time::Duration;

/// Check if integration tests should run.
fn should_run() -> bool {
    std::env::var("OPENCODE_INTEGRATION").is_ok()
}

/// Get the directory context.
fn directory() -> String {
    std::env::var("OPENCODE_DIRECTORY").unwrap_or_else(|_| {
        std::env::current_dir()
            .map_or_else(|_| "/tmp".to_string(), |p| p.to_string_lossy().to_string())
    })
}

// ============================================================================
// Server feature enabled: Auto-spawn ManagedServer
// ============================================================================

#[cfg(feature = "server")]
static SERVER: OnceCell<Arc<ManagedServer>> = OnceCell::const_new();

#[cfg(feature = "server")]
fn resolve_launcher_config() -> (String, Vec<String>) {
    let binary = std::env::var(OPENCODE_BINARY_ENV).unwrap_or_else(|_| "opencode".to_string());
    let args = std::env::var(OPENCODE_BINARY_ARGS_ENV)
        .map(|s| s.split_whitespace().map(String::from).collect())
        .unwrap_or_default();
    (binary, args)
}

#[cfg(feature = "server")]
async fn start_server() -> Arc<ManagedServer> {
    Arc::clone(
        SERVER
            .get_or_init(|| async {
                let (binary, launcher_args) = resolve_launcher_config();
                let dir = directory();

                eprintln!(
                    "[integration] Starting managed server: binary={binary}, args={launcher_args:?}, dir={dir}"
                );

                let opts = ServerOptions::default()
                    .binary(&binary)
                    .launcher_args(launcher_args)
                    .directory(&dir)
                    .startup_timeout_ms(30_000); // 30 second timeout for bunx

                let server = ManagedServer::start(opts)
                    .await
                    .expect("Failed to start managed opencode server");

                let url = server.url().to_string();
                eprintln!("[integration] Server started at {url}");

                // Set OPENCODE_BASE_URL so typed_tests submodule can find the managed server.
                // The typed_tests module can't access the SERVER singleton (Rust module scoping),
                // so we communicate the URL via environment variable.
                // SAFETY: Tests run single-threaded (--test-threads=1), so this is safe.
                unsafe { std::env::set_var("OPENCODE_BASE_URL", &url) };

                Arc::new(server)
            })
            .await,
    )
}

#[cfg(feature = "server")]
async fn build_client() -> Client {
    let server = start_server().await;
    ClientBuilder::new()
        .base_url(server.url().as_str())
        .directory(directory())
        .timeout_secs(300)
        .build()
        .unwrap()
}

// ============================================================================
// Server feature disabled: Connect to pre-running server
// ============================================================================

#[cfg(not(feature = "server"))]
fn base_url() -> String {
    std::env::var("OPENCODE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:4096".to_string())
}

#[cfg(not(feature = "server"))]
#[expect(
    clippy::unused_async,
    reason = "async for API compatibility with server-enabled version"
)]
async fn build_client() -> Client {
    ClientBuilder::new()
        .base_url(base_url())
        .directory(directory())
        .timeout_secs(300)
        .build()
        .unwrap()
}

/// Test server health endpoint.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_server_health() {
    if !should_run() {
        return;
    }

    let client = build_client().await;
    let health = client.misc().health().await.expect("Failed to get health");
    assert!(health.healthy, "Server should be healthy");
}

/// Test full session lifecycle: create -> list -> get -> delete.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_lifecycle() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create session
    let session = client
        .sessions()
        .create(&CreateSessionRequest {
            title: Some("Integration Test Session".into()),
            ..Default::default()
        })
        .await
        .expect("Failed to create session");
    assert!(!session.id.is_empty());

    // Get session to verify it exists
    let fetched = client
        .sessions()
        .get(&session.id)
        .await
        .expect("Failed to get session");
    assert_eq!(fetched.id, session.id);

    // List sessions (our session should be there, but don't fail if not)
    match client.sessions().list().await {
        Ok(sessions) => {
            println!("Found {} sessions", sessions.len());
        }
        Err(e) => {
            println!("List sessions failed: {e:?}");
        }
    }

    // Cleanup - delete session
    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");
}

/// Test session with prompt and SSE streaming.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_prompt_and_stream() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create session
    let session = client
        .sessions()
        .create(&CreateSessionRequest::default())
        .await
        .expect("Failed to create session");

    // Subscribe to events BEFORE sending prompt
    let mut subscription = client
        .subscribe_session(&session.id)
        .expect("Failed to subscribe");

    // Send a simple prompt
    let prompt_result = client
        .messages()
        .prompt(
            &session.id,
            &PromptRequest {
                parts: vec![PromptPart::Text {
                    text: "Say 'hello' and nothing else.".into(),
                    synthetic: None,
                    ignored: None,
                    metadata: None,
                }],
                message_id: None,
                model: None,
                agent: None,
                no_reply: None,
                system: None,
                variant: None,
            },
        )
        .await;

    if prompt_result.is_err() {
        // Prompt may fail if no provider is configured
        println!("Prompt failed (no provider?): {:?}", prompt_result.err());
        subscription.close();
        let _ = client.sessions().delete(&session.id).await;
        return;
    }

    // Wait for events with timeout (shorter timeout for CI)
    let timeout = Duration::from_secs(30);
    let start = std::time::Instant::now();
    let mut got_any_event = false;

    while start.elapsed() < timeout {
        tokio::select! {
            event = subscription.recv() => {
                match event {
                    Some(Event::SessionIdle { .. } | Event::SessionError { .. }) => {
                        got_any_event = true;
                        break;
                    }
                    Some(_) => {
                        // Any other event (MessagePartUpdated, ServerHeartbeat, etc.)
                        got_any_event = true;
                    }
                    None => break,
                }
            }
            () = tokio::time::sleep(Duration::from_millis(100)) => {}
        }
    }

    // Just verify we connected and received some events
    // The actual content depends on provider configuration
    println!(
        "Streaming test: got_any_event={}, elapsed={:?}",
        got_any_event,
        start.elapsed()
    );

    // Cleanup
    subscription.close();
    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");
}

/// Test session abort.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_abort() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create session
    let session = client
        .sessions()
        .create(&CreateSessionRequest::default())
        .await
        .expect("Failed to create session");

    // Abort should succeed even on idle session
    let result = client.sessions().abort(&session.id).await;
    // Abort may fail if session is already idle, that's OK
    if let Err(e) = &result {
        // Check it's not a 404
        assert!(!e.is_not_found(), "Session should exist");
    }

    // Cleanup
    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");
}

/// Test permissions API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_permissions_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // List permissions (may be empty, that's fine)
    let permissions = client
        .permissions()
        .list()
        .await
        .expect("Failed to list permissions");

    // Just verify we got a list (could be empty, that's OK)
    let _ = permissions.len();
}

/// Test files API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_files_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    let files = client
        .files()
        .list(".")
        .await
        .expect("Files list should succeed with required path query");

    let _ = files.len();
}

/// Test file status.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_files_status() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get file status (VCS status)
    let status = client
        .files()
        .status()
        .await
        .expect("Failed to get file status");

    // Just verify we got a response
    let _ = status.len();
}

/// Test project API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_project_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // List projects
    let projects = client
        .project()
        .list()
        .await
        .expect("Failed to list projects");

    // Should have at least one project
    assert!(!projects.is_empty(), "Should have at least one project");
}

/// Test current project.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_project_current() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get current project
    let project = client
        .project()
        .current()
        .await
        .expect("Failed to get current project");

    assert!(!project.id.is_empty(), "Project should have an ID");
}

/// Test providers API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_providers_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // List providers - returns ProviderListResponse with all/default/connected
    let response = client
        .providers()
        .list()
        .await
        .expect("Failed to list providers");

    // Should have some providers in the 'all' field
    // Note: List may be empty if no providers are configured
    for provider in &response.all {
        assert!(!provider.id.is_empty(), "Provider should have an ID");
        assert!(!provider.name.is_empty(), "Provider should have a name");
    }

    // The response also includes default models and connected providers
    println!(
        "Found {} providers, {} defaults, {} connected",
        response.all.len(),
        response.default.len(),
        response.connected.len()
    );
}

/// Test MCP status.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_mcp_status() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get MCP status
    let status = client
        .mcp()
        .status()
        .await
        .expect("Failed to get MCP status");

    // Just verify we got a response
    let _ = status.servers.len();
}

/// Test config API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_config_get() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get config
    let config = client.config().get().await.expect("Failed to get config");

    // Just verify we got a config response
    // The specific fields depend on OpenCode's config structure
    assert!(config.extra.is_object() || config.extra.is_null());
}

/// Test tools/agents API.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_agents_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // List agents
    let agents = client
        .tools()
        .agents()
        .await
        .expect("Failed to list agents");

    // Should have at least one agent
    assert!(!agents.is_empty(), "Should have at least one agent");
}

/// Test that command dispatch reaches command lookup instead of request validation.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_command_dispatch_passes_request_validation() {
    if !should_run() {
        return;
    }

    let client = build_client().await;
    let session = client
        .sessions()
        .create(&CreateSessionRequest::default())
        .await
        .expect("Failed to create session");

    let result = client
        .messages()
        .command(
            &session.id,
            &CommandRequest {
                command: "___definitely_not_a_real_command___".into(),
                arguments: "hello".into(),
                message_id: None,
            },
        )
        .await;

    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");

    let err = result.expect_err("unknown command should fail after request validation passes");
    let err_text = err.to_string();
    assert!(
        err_text.contains("HTTP error 500") || err_text.contains("UnknownError"),
        "expected 1.17.4 unknown-command server error shape, got: {err_text}"
    );
    assert!(
        !err_text.contains("messageID"),
        "request should not fail validation on messageID anymore: {err_text}"
    );
}

/// Test commands list.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_commands_list() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // List commands
    let commands = client
        .tools()
        .commands()
        .await
        .expect("Failed to list commands");

    // May or may not have commands
    let _ = commands.len();
}

/// Test VCS info.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_vcs_info() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get VCS info - the .expect() validates we got a successful response
    let vcs = client.misc().vcs().await.expect("Failed to get VCS info");

    // Log the VCS type for debugging (type is optional as this could be non-git)
    println!("VCS type: {:?}", vcs.r#type);
}

/// Test path info.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_path_info() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get path info
    let path = client.misc().path().await.expect("Failed to get path info");

    assert!(!path.directory.is_empty(), "Directory should not be empty");
}

/// Test `OpenAPI` doc endpoint.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_openapi_doc() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Get OpenAPI doc
    let doc = client
        .misc()
        .doc()
        .await
        .expect("Failed to get OpenAPI doc");

    // Should be a valid OpenAPI document
    assert!(doc.spec.is_object(), "Doc should be a JSON object");
    assert!(
        doc.spec.get("openapi").is_some() || doc.spec.get("swagger").is_some(),
        "Should be an OpenAPI/Swagger document"
    );
}

/// Test session fork.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_fork() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create parent session
    let parent = client
        .sessions()
        .create(&CreateSessionRequest {
            title: Some("Parent Session".into()),
            ..Default::default()
        })
        .await
        .expect("Failed to create parent session");

    // Fork session
    let forked = client
        .sessions()
        .fork(&parent.id)
        .await
        .expect("Failed to fork session");

    assert_ne!(forked.id, parent.id, "Forked session should have new ID");
    // Note: parent_id field may not be set in all OpenCode versions

    // Cleanup
    client
        .sessions()
        .delete(&forked.id)
        .await
        .expect("Failed to delete forked session");
    client
        .sessions()
        .delete(&parent.id)
        .await
        .expect("Failed to delete parent session");
}

/// Test session children.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_children() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create parent session
    let parent = client
        .sessions()
        .create(&CreateSessionRequest {
            title: Some("Parent Session".into()),
            ..Default::default()
        })
        .await
        .expect("Failed to create parent session");

    // Fork to create a child
    let child = client
        .sessions()
        .fork(&parent.id)
        .await
        .expect("Failed to fork session");

    // Get children - this may or may not include the forked session
    // depending on OpenCode version and how forking is tracked
    let children = client
        .sessions()
        .children(&parent.id)
        .await
        .expect("Failed to get children");

    // Just verify we got a response (children list may be empty in some versions)
    let _ = children.len();

    // Cleanup
    client
        .sessions()
        .delete(&child.id)
        .await
        .expect("Failed to delete child session");
    client
        .sessions()
        .delete(&parent.id)
        .await
        .expect("Failed to delete parent session");
}

/// Test session diff.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_diff() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create session
    let session = client
        .sessions()
        .create(&CreateSessionRequest::default())
        .await
        .expect("Failed to create session");

    // Get diff (may be empty for new session)
    let diff = client
        .sessions()
        .diff(&session.id)
        .await
        .expect("Failed to get session diff");

    // Just verify we got a response (may be empty for new session with no changes)
    println!("Session diff has {} file entries", diff.len());

    // Cleanup
    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");
}

/// Test session todos.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_session_todos() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Create session
    let session = client
        .sessions()
        .create(&CreateSessionRequest::default())
        .await
        .expect("Failed to create session");

    // Get todos (may be empty)
    let todos = client
        .sessions()
        .todo(&session.id)
        .await
        .expect("Failed to get todos");

    // Just verify we got a list
    let _ = todos.len();

    // Cleanup
    client
        .sessions()
        .delete(&session.id)
        .await
        .expect("Failed to delete session");
}

/// Test global event subscription.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_global_events() {
    if !should_run() {
        return;
    }

    let client = build_client().await;

    // Subscribe to global events
    let mut subscription = client
        .subscribe_global()
        .expect("Failed to subscribe to global events");

    // Wait briefly for any events (heartbeat, etc.)
    let timeout = Duration::from_secs(5);
    let start = std::time::Instant::now();
    let mut got_event = false;

    while start.elapsed() < timeout {
        tokio::select! {
            event = subscription.recv() => {
                match event {
                    Some(_) => {
                        got_event = true;
                        break;
                    }
                    None => break,
                }
            }
            () = tokio::time::sleep(Duration::from_millis(100)) => {}
        }
    }

    // May or may not get an event depending on server activity
    // Just verify we could subscribe
    subscription.close();

    // This is informational - we successfully subscribed
    if got_event {
        println!("Received global event");
    } else {
        println!("No global events received within timeout (this is OK)");
    }
}

/// Test that server version matches SDK pinned version.
///
/// This test validates that when running against a bunx-provisioned server
/// with the pinned version, the health endpoint returns the expected version.
#[tokio::test]
#[ignore = "requires: opencode serve"]
async fn test_health_returns_pinned_version() {
    if !should_run() {
        return;
    }
    let client = build_client().await;
    let health = client.misc().health().await.expect("health check failed");

    // Validate version matches pinned version
    opencode_rs::version::validate_exact_version(health.version.as_deref())
        .expect("server version must match SDK pinned version");
}
