//! Network-level integration tests using `wiremock`.
//!
//! Covers the HTTP-fetching paths in `version.rs` that take a URL parameter
//! directly. We don't need `serial_test` here because each `MockServer` binds
//! to its own random port and tests don't touch global state.
//!
//! Release JSON fixtures mirror the real GitHub REST API
//! (https://docs.github.com/en/rest/releases/releases#get-the-latest-release):
//! `GET /repos/{owner}/{repo}/releases/latest` →
//! `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`.
//!
//! NOTE on retry timing: the prod retry backoff is 1s + 2s + 4s = 7s
//! wall-clock. We can't use `tokio::time::pause()` because reqwest's I/O
//! reactor uses the same tokio timer and stalls when time is paused. So
//! retry-exhaustion tests are intrinsically slow (~7s each); we keep the
//! count small and let them run in parallel (wiremock binds random ports
//! so there's no contention).

use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};

use kigi_update::auto_update::{download_silent, download_with_progress};
use kigi_update::version::{fetch_latest_release_from_base, fetch_release_for_version_from_base};

fn tag_json(tag: &str) -> serde_json::Value {
    serde_json::json!({ "tag_name": tag, "draft": false, "prerelease": false, "assets": [] })
}

// ─────────────────────────────────────────────────────────────────────────────
// Happy-path tests (fast, no retries triggered).
// ─────────────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn latest_release_returns_version_on_success() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
        .expect(1)
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

#[tokio::test]
async fn latest_release_accepts_bare_semver_tag() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("0.1.181")))
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

#[tokio::test]
async fn latest_release_rejects_non_semver_tag_without_retry() {
    // A non-semver tag is a repo data bug, not a transient failure — the
    // fetch succeeds in one request and version() reports the bad tag.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("release-one")))
        .expect(1)
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap();
    let err = release.version().unwrap_err();
    assert!(format!("{err}").contains("not semver"), "err: {err}");
}

#[tokio::test]
async fn alpha_channel_picks_semver_max_from_release_list() {
    // The list is ordered by publication date (newest first) — NOT semver.
    // Alpha must take the semver max, so a newer-published pre-release does
    // not shadow a semver-higher stable and vice versa.
    let server = MockServer::start().await;
    let list = serde_json::json!([
        { "tag_name": "v0.1.180-alpha.5", "draft": false, "prerelease": true, "assets": [] },
        { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
        { "tag_name": "v0.1.179", "draft": false, "prerelease": false, "assets": [] },
    ]);
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(200).set_body_json(list))
        .expect(1)
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("alpha", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

#[tokio::test]
async fn alpha_channel_returns_prerelease_when_it_is_max() {
    let server = MockServer::start().await;
    let list = serde_json::json!([
        { "tag_name": "v0.1.182-alpha.1", "draft": false, "prerelease": true, "assets": [] },
        { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
    ]);
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(200).set_body_json(list))
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("alpha", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.182-alpha.1");
}

#[tokio::test]
async fn alpha_channel_skips_drafts_and_non_semver_tags() {
    let server = MockServer::start().await;
    let list = serde_json::json!([
        { "tag_name": "v9.9.9", "draft": true, "prerelease": false, "assets": [] },
        { "tag_name": "nightly", "draft": false, "prerelease": false, "assets": [] },
        { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
    ]);
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(200).set_body_json(list))
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("alpha", &server.uri())
        .await
        .unwrap();
    assert_eq!(
        release.version().unwrap(),
        "0.1.181",
        "drafts and non-semver tags must not win"
    );
}

#[tokio::test]
async fn alpha_channel_empty_list_is_an_error() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server)
        .await;

    let err = fetch_latest_release_from_base("alpha", &server.uri())
        .await
        .unwrap_err();
    assert!(format!("{err:#}").contains("no releases"), "err: {err:#}");
}

#[tokio::test]
async fn stable_channel_does_not_fetch_the_release_list() {
    // Stable users resolve /latest only; the list endpoint must not be hit.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(500))
        .expect(0)
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

#[tokio::test]
async fn release_for_version_fetches_tag_endpoint() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/tags/v0.1.150"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.150")))
        .expect(1)
        .mount(&server)
        .await;

    let release = fetch_release_for_version_from_base("0.1.150", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.150");
}

#[tokio::test]
async fn base_url_trailing_slash_is_tolerated() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
        .mount(&server)
        .await;

    let base = format!("{}/", server.uri());
    let release = fetch_latest_release_from_base("stable", &base)
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

// ─────────────────────────────────────────────────────────────────────────────
// Retry behavior — these tests intentionally exercise the 1s+2s+4s backoff,
// so each takes up to ~7 seconds. They run in parallel.
// ─────────────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn latest_release_retries_on_5xx_then_succeeds() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(503).set_body_string("backend down"))
        .up_to_n_times(2)
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
        .mount(&server)
        .await;

    let release = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap();
    assert_eq!(release.version().unwrap(), "0.1.181");
}

#[tokio::test]
async fn latest_release_gives_up_after_max_retries() {
    let server = MockServer::start().await;
    // 4 attempts total: initial + 3 retries.
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(500))
        .expect(4)
        .mount(&server)
        .await;

    let err = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("HTTP 500"), "msg: {msg}");
    assert!(msg.contains("/latest"), "url should be in error: {msg}");
}

#[tokio::test]
async fn latest_release_404_fails_fast_without_retry() {
    // 404 = release/repo missing — a data condition, not transient. Exactly
    // one request, and the GitHub error body is surfaced.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#))
        .expect(1)
        .mount(&server)
        .await;

    let err = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("HTTP 404"), "msg: {msg}");
    assert!(msg.contains("Not Found"), "msg: {msg}");
}

#[tokio::test]
async fn latest_release_malformed_json_fails_without_retry() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
        .expect(1)
        .mount(&server)
        .await;

    let err = fetch_latest_release_from_base("stable", &server.uri())
        .await
        .unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("unexpected JSON"), "msg: {msg}");
}

#[tokio::test]
async fn latest_release_connection_refused_is_retried_and_returns_error() {
    // Bind a TcpListener to claim a port, then drop it so connections refuse.
    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    drop(listener);
    let url = format!("http://127.0.0.1:{port}");

    let err = fetch_latest_release_from_base("stable", &url)
        .await
        .unwrap_err();
    let msg = format!("{err:#}").to_lowercase();
    assert!(
        msg.contains("request failed")
            || msg.contains("connection")
            || msg.contains("error sending request")
            || msg.contains("refused"),
        "expected network error message, got: {msg}"
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// download_silent — same body shape as download_with_progress but no
// progress bar to capture.
// ─────────────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn download_silent_writes_body_to_dest() {
    let server = MockServer::start().await;
    let body = b"binary contents \x00\x01\x02".to_vec();
    Mock::given(method("GET"))
        .and(path("/kigi-0.1.181-macos-aarch64"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    let url = format!("{}/kigi-0.1.181-macos-aarch64", server.uri());
    download_silent(&url, &dest).await.unwrap();

    let written = std::fs::read(&dest).unwrap();
    assert_eq!(written, body);
}

#[tokio::test]
async fn download_silent_preserves_binary_bytes_unchanged() {
    // Verify that arbitrary binary content (including null bytes, high
    // bytes, control chars) round-trips intact.
    let server = MockServer::start().await;
    let body: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
    Mock::given(method("GET"))
        .and(path("/bin"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("bin");
    download_silent(&format!("{}/bin", server.uri()), &dest)
        .await
        .unwrap();

    let written = std::fs::read(&dest).unwrap();
    assert_eq!(written, body);
}

#[tokio::test]
async fn download_silent_atomically_renames_via_tmp_file() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/bin"))
        .respond_with(ResponseTemplate::new(200).set_body_string("hello"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    download_silent(&format!("{}/bin", server.uri()), &dest)
        .await
        .unwrap();

    // After successful download, only the final file should exist.
    assert!(dest.exists());
    assert!(
        !dest.with_extension("tmp").exists(),
        "tmp file must be renamed away on success"
    );
}

/// A downloaded artifact must be published already executable (the install
/// path execs it right after download).
#[cfg(unix)]
#[tokio::test]
async fn download_silent_publishes_executable() {
    use std::os::unix::fs::PermissionsExt;

    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/bin"))
        .respond_with(ResponseTemplate::new(200).set_body_string("#!/bin/sh\necho ok\n"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi-0.1.181-linux-x86_64");
    download_silent(&format!("{}/bin", server.uri()), &dest)
        .await
        .unwrap();

    let mode = std::fs::metadata(&dest).unwrap().permissions().mode();
    assert_ne!(
        mode & 0o111,
        0,
        "downloaded artifact must be executable on publish (mode {mode:o})"
    );
}

#[tokio::test]
async fn download_silent_fails_on_4xx() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/missing"))
        .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    let err = download_silent(&format!("{}/missing", server.uri()), &dest)
        .await
        .unwrap_err();

    let msg = format!("{err:#}");
    assert!(msg.contains("Download failed"), "msg: {msg}");
    assert!(msg.contains("404"), "msg: {msg}");
    assert!(!dest.exists(), "no file should be created on HTTP error");
}

#[tokio::test]
async fn download_silent_fails_on_5xx() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(503))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    let err = download_silent(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap_err();
    assert!(format!("{err:#}").contains("503"));
}

#[tokio::test]
async fn download_silent_overwrites_existing_dest() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(200).set_body_string("new content"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    std::fs::write(&dest, "old content").unwrap();

    download_silent(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap();

    let written = std::fs::read_to_string(&dest).unwrap();
    assert_eq!(written, "new content");
}

#[tokio::test]
async fn download_silent_handles_empty_body() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(Vec::<u8>::new()))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    download_silent(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap();

    assert!(dest.exists());
    assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0);
}

#[tokio::test]
async fn download_silent_streams_large_body() {
    // 5 MB to verify streaming (file is written incrementally, not loaded
    // entirely in memory before write).
    let server = MockServer::start().await;
    let body = vec![0xAB_u8; 5 * 1024 * 1024];
    Mock::given(method("GET"))
        .and(path("/big"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    download_silent(&format!("{}/big", server.uri()), &dest)
        .await
        .unwrap();

    let written = std::fs::read(&dest).unwrap();
    assert_eq!(written.len(), body.len());
    assert_eq!(written, body);
}

#[tokio::test]
async fn download_silent_to_nonexistent_parent_dir_fails() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(200).set_body_string("hi"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    // Parent directory does NOT exist — should fail at file create.
    let dest = tmp.path().join("missing-subdir").join("kigi");
    let err = download_silent(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap_err();
    let msg = format!("{err:#}").to_lowercase();
    assert!(
        msg.contains("no such file") || msg.contains("not found") || msg.contains("os error"),
        "expected fs error: {msg}"
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// download_with_progress — same contract; covers the spinner path
// (no Content-Length) and the progress-bar path (with Content-Length).
// ─────────────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn download_with_progress_writes_body_with_content_length() {
    // Wiremock sets Content-Length when set_body_bytes is used, so this
    // exercises the determinate-progress-bar path.
    let server = MockServer::start().await;
    let body = b"binary content".to_vec();
    Mock::given(method("GET"))
        .and(path("/kigi"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    download_with_progress(&format!("{}/kigi", server.uri()), &dest)
        .await
        .unwrap();

    assert_eq!(std::fs::read(&dest).unwrap(), body);
}

#[tokio::test]
async fn download_with_progress_fails_on_http_error() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(500))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    let err = download_with_progress(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("Download failed"), "msg: {msg}");
    assert!(msg.contains("500"), "msg: {msg}");
}

#[tokio::test]
async fn download_with_progress_atomic_rename() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi");
    download_with_progress(&format!("{}/x", server.uri()), &dest)
        .await
        .unwrap();

    assert!(dest.exists());
    assert!(!dest.with_extension("tmp").exists());
}

// ─────────────────────────────────────────────────────────────────────────────
// Parallel byte-range path — exercises the HEAD + 206 Partial Content code path
// in download_silent / download_with_progress for files >= 16 MiB.
// ─────────────────────────────────────────────────────────────────────────────

/// Wiremock responder for `GET` that honors `Range: bytes=A-B` with `206`.
/// Without a Range header it returns the full body with `200`.
#[derive(Clone)]
struct RangeResponder {
    body: std::sync::Arc<Vec<u8>>,
}

impl Respond for RangeResponder {
    fn respond(&self, request: &Request) -> ResponseTemplate {
        let total = self.body.len();
        let spec = request
            .headers
            .get("range")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.strip_prefix("bytes=").map(|x| x.to_string()));
        if let Some(spec) = spec
            && let Some((start_str, end_str)) = spec.split_once('-')
            && let (Ok(start), Ok(end)) = (start_str.parse::<usize>(), end_str.parse::<usize>())
        {
            let end = end.min(total - 1);
            if start <= end {
                let slice = self.body[start..=end].to_vec();
                return ResponseTemplate::new(206)
                    .insert_header("content-range", format!("bytes {start}-{end}/{total}"))
                    .set_body_bytes(slice);
            }
        }
        ResponseTemplate::new(200).set_body_bytes((*self.body).clone())
    }
}

#[tokio::test]
async fn download_silent_parallel_path_reassembles_bytes() {
    // 32 MiB body — clears the parallel threshold and yields 2 chunks
    // (size_mb / 16 = 2, clamped to [1, 8]), so this actually exercises
    // concurrent range fetches and the seek+write reassembly.
    let body: Vec<u8> = (0u32..(32 * 1024 * 1024 / 4))
        .flat_map(|n| n.to_le_bytes())
        .collect();
    assert_eq!(body.len(), 32 * 1024 * 1024);
    let arc = std::sync::Arc::new(body.clone());

    let server = MockServer::start().await;
    Mock::given(method("HEAD"))
        .and(path("/big"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-length", body.len().to_string())
                .insert_header("accept-ranges", "bytes"),
        )
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/big"))
        .respond_with(RangeResponder { body: arc })
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let dest = tmp.path().join("kigi-binary");
    download_silent(&format!("{}/big", server.uri()), &dest)
        .await
        .unwrap();

    let written = std::fs::read(&dest).unwrap();
    assert_eq!(written.len(), body.len());
    assert_eq!(
        written, body,
        "reassembled file must match original byte-for-byte"
    );
    assert!(
        !dest.with_extension("tmp").exists(),
        "tmp file must be cleaned up"
    );
}
