//! Tests for `script/ci-publish-artifact` and `.gitea/workflows/release-artifact.yml`
//! (story 1189). Exercises the publish script end-to-end as a subprocess against a
//! minimal local HTTP stub rather than a live channel host — this crate has no
//! production code path that talks to the dev channel itself.

use std::collections::{HashMap, VecDeque};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;

/// One request as observed by the stub: method, path, and the two headers
/// the publish script is required to send.
struct ObservedRequest {
    method: String,
    path: String,
    authorization: Option<String>,
    git_hash: Option<String>,
}

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}

fn script_path() -> PathBuf {
    repo_root().join("script/ci-publish-artifact")
}

/// Read one HTTP/1.1 request off `stream`: request line, headers (lower-cased
/// keys), and body (read exactly `Content-Length` bytes, defaulting to none).
fn read_request(stream: &mut TcpStream) -> (String, String, HashMap<String, String>, Vec<u8>) {
    let mut header_bytes = Vec::new();
    let mut byte = [0u8; 1];
    loop {
        stream.read_exact(&mut byte).expect("read request byte");
        header_bytes.push(byte[0]);
        if header_bytes.ends_with(b"\r\n\r\n") {
            break;
        }
    }
    let header_text = String::from_utf8_lossy(&header_bytes);
    let mut lines = header_text.lines();
    let request_line = lines.next().unwrap_or("");
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("").to_string();
    let path = parts.next().unwrap_or("").to_string();

    let mut headers = HashMap::new();
    for line in lines {
        if let Some((k, v)) = line.split_once(':') {
            headers.insert(k.trim().to_lowercase(), v.trim().to_string());
        }
    }

    let content_length: usize = headers
        .get("content-length")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);
    let mut body = vec![0u8; content_length];
    if content_length > 0 {
        stream.read_exact(&mut body).expect("read request body");
    }
    (method, path, headers, body)
}

fn write_response(stream: &mut TcpStream, status: u16, body: &str) {
    let reason = match status {
        200 => "OK",
        401 => "Unauthorized",
        403 => "Forbidden",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "Unknown",
    };
    let response = format!(
        "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    let _ = stream.write_all(response.as_bytes());
    let _ = stream.flush();
}

/// Spawn a background thread serving `responses` in order, one per accepted
/// connection; once exhausted, further requests get a 500 fallback (a test
/// bug, not an expected script behaviour). Returns the port and a log of
/// every request observed, in arrival order.
fn spawn_stub(responses: Vec<(u16, &'static str)>) -> (u16, Arc<Mutex<Vec<ObservedRequest>>>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
    let port = listener.local_addr().expect("stub local addr").port();
    let queue = Arc::new(Mutex::new(responses.into_iter().collect::<VecDeque<_>>()));
    let log: Arc<Mutex<Vec<ObservedRequest>>> = Arc::new(Mutex::new(Vec::new()));
    let log_writer = Arc::clone(&log);

    thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(mut stream) = stream else { break };
            let (method, path, headers, _body) = read_request(&mut stream);
            log_writer.lock().unwrap().push(ObservedRequest {
                method,
                path,
                authorization: headers.get("authorization").cloned(),
                git_hash: headers.get("x-git-hash").cloned(),
            });
            let (status, body) = queue
                .lock()
                .unwrap()
                .pop_front()
                .unwrap_or((500, "no more stub responses queued"));
            write_response(&mut stream, status, body);
        }
    });

    (port, log)
}

struct RunResult {
    success: bool,
    stderr: String,
}

fn run_publish_script(port: u16, git_hash: &str) -> RunResult {
    let dir = tempfile::tempdir().expect("tempdir");
    let artifact_path = dir.path().join("huskies-linux-arm64");
    std::fs::write(&artifact_path, b"fake sled binary").expect("write fake artifact");

    let output = Command::new("bash")
        .arg(script_path())
        .arg(&artifact_path)
        .arg(git_hash)
        .env("HUSKIES_CHANNEL_URL", format!("http://127.0.0.1:{port}"))
        .env("HUSKIES_CHANNEL_TOKEN", "test-token")
        .env("HUSKIES_CI_PUBLISH_BACKOFF_SECS", "1")
        .output()
        .expect("run script/ci-publish-artifact");

    RunResult {
        success: output.status.success(),
        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
    }
}

// ── Script behaviour ─────────────────────────────────────────────────────

#[test]
fn publish_succeeds_and_verifies_manifest() {
    let (port, log) = spawn_stub(vec![
        (200, r#"{"status":"ok"}"#),
        (200, r#"{"git_hash":"abc1234"}"#),
    ]);

    let result = run_publish_script(port, "abc1234");
    assert!(result.success, "expected success: {}", result.stderr);

    let requests = log.lock().unwrap();
    assert_eq!(requests.len(), 2, "expected one upload + one manifest GET");
    assert_eq!(requests[0].method, "POST");
    assert_eq!(
        requests[0].authorization.as_deref(),
        Some("Bearer test-token")
    );
    assert_eq!(requests[0].git_hash.as_deref(), Some("abc1234"));
    assert_eq!(requests[1].method, "GET");
    assert_eq!(requests[1].path, "/manifest.json");
}

#[test]
fn publish_fails_immediately_on_auth_failure_without_retry() {
    let (port, log) = spawn_stub(vec![(401, r#"{"error":"bad token"}"#)]);

    let result = run_publish_script(port, "abc1234");
    assert!(!result.success, "auth failure must not succeed");
    assert!(
        result.stderr.contains("authentication failed"),
        "stderr should explain the auth failure: {}",
        result.stderr
    );

    let requests = log.lock().unwrap();
    assert_eq!(
        requests.len(),
        1,
        "a 401 must not be retried, and must not reach the manifest check"
    );
}

#[test]
fn publish_retries_transient_failure_then_succeeds() {
    let (port, log) = spawn_stub(vec![
        (500, "boom"),
        (200, r#"{"status":"ok"}"#),
        (200, r#"{"git_hash":"abc1234"}"#),
    ]);

    let result = run_publish_script(port, "abc1234");
    assert!(
        result.success,
        "expected eventual success: {}",
        result.stderr
    );

    let requests = log.lock().unwrap();
    assert_eq!(
        requests.len(),
        3,
        "expected the failed upload, the retried upload, and the manifest GET"
    );
    assert_eq!(requests[0].method, "POST");
    assert_eq!(requests[1].method, "POST");
    assert_eq!(requests[2].method, "GET");
}

#[test]
fn publish_fails_on_manifest_mismatch() {
    let (port, log) = spawn_stub(vec![
        (200, r#"{"status":"ok"}"#),
        (200, r#"{"git_hash":"deadbee"}"#),
        (200, r#"{"git_hash":"deadbee"}"#),
        (200, r#"{"git_hash":"deadbee"}"#),
    ]);

    let result = run_publish_script(port, "abc1234");
    assert!(!result.success, "manifest mismatch must fail");
    assert!(
        result.stderr.contains("manifest mismatch"),
        "stderr should explain the mismatch: {}",
        result.stderr
    );

    let requests = log.lock().unwrap();
    assert_eq!(
        requests.len(),
        4,
        "expected the upload plus 3 manifest-check attempts"
    );
}

// ── Workflow yaml structure ──────────────────────────────────────────────

#[test]
fn workflow_yaml_triggers_on_master_push_and_targets_arm64_mac() {
    let path = repo_root().join(".gitea/workflows/release-artifact.yml");
    let content = std::fs::read_to_string(&path).expect("read release-artifact.yml");
    let value: serde_yaml::Value = serde_yaml::from_str(&content).expect("valid yaml");

    let branches = value["on"]["push"]["branches"]
        .as_sequence()
        .expect("on.push.branches sequence")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect::<Vec<_>>();
    assert_eq!(branches, vec!["master".to_string()]);

    let jobs = value["jobs"].as_mapping().expect("jobs mapping");
    let (_, job) = jobs.iter().next().expect("at least one job defined");

    let runs_on = job["runs-on"].as_sequence().expect("runs-on sequence");
    let runs_on: Vec<&str> = runs_on.iter().filter_map(|v| v.as_str()).collect();
    assert_eq!(runs_on, vec!["arm64-mac"]);

    let steps = job["steps"].as_sequence().expect("steps sequence");
    let calls_publish_script = steps.iter().any(|step| {
        step.get("run")
            .and_then(|r| r.as_str())
            .is_some_and(|s| s.contains("script/ci-publish-artifact"))
    });
    assert!(
        calls_publish_script,
        "a step must invoke script/ci-publish-artifact"
    );

    let references_channel_secrets = steps.iter().any(|step| {
        step.get("env")
            .and_then(|e| e.as_mapping())
            .is_some_and(|env| {
                env.values().any(|v| {
                    v.as_str().is_some_and(|s| {
                        s.contains("secrets.HUSKIES_CHANNEL_URL")
                            || s.contains("secrets.HUSKIES_CHANNEL_TOKEN")
                    })
                })
            })
    });
    assert!(
        references_channel_secrets,
        "workflow must reference the channel secrets via ${{{{ secrets.* }}}}"
    );
}

#[test]
fn readme_documents_runner_label_and_secrets() {
    let path = repo_root().join(".gitea/workflows/README.md");
    let content = std::fs::read_to_string(&path).expect("read .gitea/workflows/README.md");
    assert!(
        content.contains("arm64-mac"),
        "README must document the runner label"
    );
    assert!(
        content.contains("HUSKIES_CHANNEL_URL"),
        "README must document HUSKIES_CHANNEL_URL"
    );
    assert!(
        content.contains("HUSKIES_CHANNEL_TOKEN"),
        "README must document HUSKIES_CHANNEL_TOKEN"
    );
}
