//! Coverage for previously-shallow areas:
//!   - `ProjectsClient::resolve` / `ScriptsClient::resolve` (id-or-name).
//!   - the `PublishBuilder` surface (`execute` → PublishOutcome with rebase
//!     summary, `execute_dry_run`, `execute_version_only`).
//!   - error / network-failure classification paths.

use akribes_sdk::{AkribesClient, AkribesError};
use mockito::{Matcher, Server};

fn make_client(server: &Server) -> AkribesClient {
    AkribesClient::builder(server.url())
        .project_id(1)
        .name("resolve-test")
        .id("resolve-id")
        .build()
}

// ── ProjectsClient::resolve ──────────────────────────────────────────────────

#[tokio::test]
async fn projects_resolve_numeric_hits_get_by_id() {
    let mut server = Server::new_async().await;
    // A numeric input is treated as an id → single GET /projects/{id}, no list.
    let _m = server
        .mock("GET", "/projects/10")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"id":10,"name":"Alpha","created_at":"2024-01-01T00:00:00Z"}"#)
        .create_async()
        .await;
    let p = make_client(&server)
        .projects()
        .resolve("10")
        .await
        .unwrap()
        .expect("resolved");
    assert_eq!(p.id, 10);
    assert_eq!(p.name, "Alpha");
}

#[tokio::test]
async fn projects_resolve_name_lists_and_filters() {
    let mut server = Server::new_async().await;
    // A non-numeric input lists all projects and filters by exact name.
    let _m = server
        .mock("GET", "/projects")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"[{"id":10,"name":"Alpha","created_at":"2024-01-01T00:00:00Z"},
                {"id":11,"name":"Beta","created_at":"2024-01-01T00:00:00Z"}]"#,
        )
        .create_async()
        .await;
    let p = make_client(&server)
        .projects()
        .resolve("Beta")
        .await
        .unwrap()
        .expect("resolved");
    assert_eq!(p.id, 11);
}

#[tokio::test]
async fn projects_resolve_name_no_match_is_none() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"[{"id":10,"name":"Alpha","created_at":"2024-01-01T00:00:00Z"}]"#)
        .create_async()
        .await;
    assert!(
        make_client(&server)
            .projects()
            .resolve("Gamma")
            .await
            .unwrap()
            .is_none()
    );
}

#[tokio::test]
async fn projects_resolve_numeric_missing_is_none() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/999")
        .with_status(404)
        .create_async()
        .await;
    assert!(
        make_client(&server)
            .projects()
            .resolve("999")
            .await
            .unwrap()
            .is_none()
    );
}

// ── ScriptsClient::resolve ───────────────────────────────────────────────────

#[tokio::test]
async fn scripts_resolve_name_uses_get_by_name() {
    let mut server = Server::new_async().await;
    // Non-numeric → GET /projects/{id}/scripts/{name} directly (no list).
    let _m = server
        .mock("GET", "/projects/1/scripts/summarise")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"id":5,"project_id":1,"name":"summarise","created_at":"2024-01-01T00:00:00Z"}"#,
        )
        .create_async()
        .await;
    let s = make_client(&server)
        .project(1)
        .scripts()
        .resolve("summarise")
        .await
        .unwrap()
        .expect("resolved");
    assert_eq!(s.id, 5);
    assert_eq!(s.name, "summarise");
}

#[tokio::test]
async fn scripts_resolve_numeric_lists_and_filters_by_id() {
    let mut server = Server::new_async().await;
    // Numeric → there is no GET-script-by-id route, so resolve lists and
    // filters by the `id` field.
    let _m = server
        .mock("GET", "/projects/1/scripts")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"[{"id":5,"project_id":1,"name":"summarise","created_at":"2024-01-01T00:00:00Z"},
                {"id":6,"project_id":1,"name":"classify","created_at":"2024-01-01T00:00:00Z"}]"#,
        )
        .create_async()
        .await;
    let s = make_client(&server)
        .project(1)
        .scripts()
        .resolve("6")
        .await
        .unwrap()
        .expect("resolved");
    assert_eq!(s.name, "classify");
}

#[tokio::test]
async fn scripts_resolve_numeric_no_match_is_none() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/1/scripts")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"[{"id":5,"project_id":1,"name":"summarise","created_at":"2024-01-01T00:00:00Z"}]"#,
        )
        .create_async()
        .await;
    assert!(
        make_client(&server)
            .project(1)
            .scripts()
            .resolve("999")
            .await
            .unwrap()
            .is_none()
    );
}

// ── PublishBuilder ───────────────────────────────────────────────────────────

#[tokio::test]
async fn publish_execute_returns_version_and_rebase_summary() {
    let mut server = Server::new_async().await;
    // The builder posts channels/label/published_by; dry_run is forced to
    // None on a real execute (never sent as `true`). The response carries
    // a first-publish `rebased` array that must surface on PublishOutcome.
    let _m = server
        .mock("POST", "/projects/1/scripts/summarise/publish")
        .match_body(Matcher::Json(serde_json::json!({
            "channels": ["production"],
            "label": "v1",
            "published_by": "alice",
        })))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"version":{"id":42,"script_id":5,"source":"workflow {}","label":"v1",
                "published_by":"alice","created_at":"2026-01-01T00:00:00Z"},
                "rebased":[{"kind":"bench_case","count":3},{"kind":"judge","count":1}]}"#,
        )
        .create_async()
        .await;

    let outcome = make_client(&server)
        .project(1)
        .versions()
        .publish("summarise")
        .channels(vec!["production".into()])
        .label("v1")
        .published_by("alice")
        .execute()
        .await
        .unwrap();
    assert_eq!(outcome.version.id, 42);
    let rebased = outcome.rebased.expect("rebase summary present");
    assert_eq!(rebased.len(), 2);
    assert_eq!(rebased[0].kind, "bench_case");
    assert_eq!(rebased[0].count, 3);
}

#[tokio::test]
async fn publish_execute_version_only_drops_rebase() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("POST", "/projects/1/scripts/summarise/publish")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"version":{"id":42,"script_id":5,"source":"workflow {}","label":null,
                "published_by":null,"created_at":"2026-01-01T00:00:00Z"}}"#,
        )
        .create_async()
        .await;
    let version = make_client(&server)
        .project(1)
        .versions()
        .publish("summarise")
        .channels(vec!["dev".into()])
        .execute_version_only()
        .await
        .unwrap();
    assert_eq!(version.id, 42);
}

#[tokio::test]
async fn publish_dry_run_sends_dry_run_true_and_parses_breaks() {
    let mut server = Server::new_async().await;
    // execute_dry_run must set dry_run=true in the body and decode the
    // DryRunResult (would_break + breaking_interests), NOT a ScriptVersion.
    let _m = server
        .mock("POST", "/projects/1/scripts/summarise/publish")
        .match_body(Matcher::PartialJson(serde_json::json!({"dry_run": true})))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"dry_run":true,"would_break":1,
                "breaking_interests":[{"client_id":"c1","client_name":"puto","channel":"production",
                    "lifetime":"session","mismatch":{"missing":[["score","Number"]],
                    "wrong_type":[],"extra":[]}}]}"#,
        )
        .create_async()
        .await;
    let result = make_client(&server)
        .project(1)
        .versions()
        .publish("summarise")
        .channels(vec!["production".into()])
        .execute_dry_run()
        .await
        .unwrap();
    assert!(result.dry_run);
    assert_eq!(result.would_break, 1);
    assert_eq!(result.breaking_interests.len(), 1);
    assert_eq!(result.breaking_interests[0].client_name, "puto");
}

#[tokio::test]
async fn publish_force_flag_serialised() {
    let mut server = Server::new_async().await;
    // `.force(true)` must put `force:true` on the wire (skipped when unset).
    let _m = server
        .mock("POST", "/projects/1/scripts/summarise/publish")
        .match_body(Matcher::PartialJson(serde_json::json!({"force": true})))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"version":{"id":43,"script_id":5,"source":"x","label":null,
                "published_by":null,"created_at":"2026-01-01T00:00:00Z"}}"#,
        )
        .create_async()
        .await;
    make_client(&server)
        .project(1)
        .versions()
        .publish("summarise")
        .channels(vec!["production".into()])
        .force(true)
        .execute()
        .await
        .unwrap();
}

// ── Error / failure paths ────────────────────────────────────────────────────

#[tokio::test]
async fn rate_limit_429_classifies_transient_with_retry_after() {
    let mut server = Server::new_async().await;
    // 429 with a numeric Retry-After header → Transient carrying status 429
    // and a parsed retry_after Duration.
    let _m = server
        .mock("GET", "/projects/5")
        .with_status(429)
        .with_header("retry-after", "12")
        .with_body("slow down")
        .create_async()
        .await;
    let err = make_client(&server).projects().get(5).await.unwrap_err();
    match err {
        AkribesError::Transient {
            status,
            retry_after,
            ..
        } => {
            assert_eq!(status, Some(429));
            assert_eq!(retry_after, Some(std::time::Duration::from_secs(12)));
        }
        other => panic!("expected Transient, got {other:?}"),
    }
}

#[tokio::test]
async fn server_500_classifies_transient_with_status() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/5")
        .with_status(503)
        .with_body("unavailable")
        .create_async()
        .await;
    let err = make_client(&server).projects().get(5).await.unwrap_err();
    match err {
        AkribesError::Transient { status, .. } => assert_eq!(status, Some(503)),
        other => panic!("expected Transient, got {other:?}"),
    }
}

#[tokio::test]
async fn unauthorized_401_classifies_fatal() {
    let mut server = Server::new_async().await;
    let _m = server
        .mock("GET", "/projects/5")
        .with_status(401)
        .with_body("token expired")
        .create_async()
        .await;
    let err = make_client(&server).projects().get(5).await.unwrap_err();
    assert!(matches!(err, AkribesError::Fatal { .. }), "got {err:?}");
}

#[tokio::test]
async fn malformed_json_body_surfaces_decode_error_with_snippet() {
    let mut server = Server::new_async().await;
    // A 200 with a body that doesn't match the target type must NOT panic or
    // return an opaque reqwest error — it surfaces an AkribesError::Other
    // naming the type and quoting a body snippet.
    let _m = server
        .mock("GET", "/projects/5")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"id":"not-a-number","name":42}"#)
        .create_async()
        .await;
    let err = make_client(&server).projects().get(5).await.unwrap_err();
    match err {
        AkribesError::Other(msg) => {
            assert!(
                msg.contains("Project"),
                "should name the target type: {msg}"
            );
            assert!(msg.contains("not-a-number"), "should quote body: {msg}");
        }
        other => panic!("expected Other decode error, got {other:?}"),
    }
}

#[tokio::test]
async fn network_failure_surfaces_http_error() {
    // No server listening on this port → connection refused. The SDK must
    // surface AkribesError::Http (the reqwest transport error), not hang.
    let client = AkribesClient::builder("http://127.0.0.1:1")
        .project_id(1)
        .name("net-test")
        .build();
    let err = client.projects().get(1).await.unwrap_err();
    assert!(
        matches!(err, AkribesError::Http(_)),
        "expected transport error, got {err:?}"
    );
}

#[tokio::test]
async fn conflict_409_already_exists_classified_from_structured_body() {
    let mut server = Server::new_async().await;
    // Any POST that returns a 409 with the structured `suite_already_exists`
    // body is mapped to AkribesError::AlreadyExists carrying the existing id.
    // This classification lives in the shared request path, so it fires
    // regardless of which endpoint produced the conflict — exercised here via
    // a bench create.
    let _m = server
        .mock("POST", "/projects/1/scripts/summarise/bench")
        .with_status(409)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"error_type":"suite_already_exists","existing_suite_id":77,
                "error":"a bench already exists for this script"}"#,
        )
        .create_async()
        .await;
    let req = akribes_sdk::CreateOrUpdateBenchRequest {
        judge_script_id: Some(12),
        judge_channel: None,
        config: None,
    };
    let err = make_client(&server)
        .project(1)
        .bench()
        .create_or_update("summarise", &req)
        .await
        .unwrap_err();
    match err {
        AkribesError::AlreadyExists { existing_id, .. } => assert_eq!(existing_id, 77),
        other => panic!("expected AlreadyExists, got {other:?}"),
    }
}
