//! Integration tests for `mnem fetch` / `push` / `pull` against a
//! live HTTP server.
//!
//! After the v0.2.0 merge, the server is `mnem http serve` (subcommand
//! inside the unified `mnem` binary). These tests spin up a real server
//! on a loopback ephemeral port, `mnem remote add` against it from a
//! second TempDir, then exercise the three wire verbs end-to-end.
//!
//! # Harness
//!
//! [`HttpServer::spawn`] pre-binds a `TcpListener` on `127.0.0.1:0`,
//! takes the kernel-allocated port, drops the listener, then launches
//! `mnem http serve` with `--bind 127.0.0.1:<port>`. There is a small
//! race window between the drop and the child's `bind`; we cover it
//! by polling `GET /v1/healthz` up to a few seconds. If the port is
//! grabbed by another process in that gap the test fails fast with a
//! diagnostic rather than hanging.
//!
//! # Auth
//!
//! The server-side bearer is injected via `MNEM_HTTP_PUSH_TOKEN` on
//! the spawned child's environment. The client-side bearer is
//! injected via `MNEM_REMOTE_<UPPER>_TOKEN` on the `mnem` subprocess
//! invocation. Env vars are scoped per-`Command` so they never leak
//! into the parent test process (Rust 2024 makes `std::env::set_var`
//! unsafe for exactly this reason).
//!
//! # Cleanup
//!
//! [`HttpServer`] holds the `Child` and implements `Drop` to kill
//! the process; tempdirs clean themselves via `TempDir::drop`. If a
//! test panics mid-run the destructors still fire.

#![allow(clippy::unwrap_used)]

use std::net::TcpListener;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use assert_cmd::prelude::*;
use tempfile::TempDir;

/// Environment variable that holds the server-side push token.
const SERVER_TOKEN_ENV: &str = "MNEM_HTTP_PUSH_TOKEN";
/// Bearer value used throughout the test suite. Arbitrary string;
/// constant-time compared server-side.
const TEST_TOKEN: &str = "b3-integration-token";

// ---------- Test harness ----------

/// RAII handle for a spawned `mnem http serve` subprocess. `Drop` sends
/// `kill` so a panicking test never leaks a server.
struct HttpServer {
    child: Child,
    base_url: String,
    /// Owned TempDir backing the server's `.mnem/` so it outlives the
    /// child. The field is prefixed `_` because we only hold it for
    /// its `Drop`.
    _repo: TempDir,
}

impl HttpServer {
    /// Spawn `mnem http serve` on a loopback ephemeral port, waiting
    /// until `/v1/healthz` returns 200 (bounded by a ~5 s budget).
    ///
    /// `token` seeds `MNEM_HTTP_PUSH_TOKEN` on the child. Pass `None`
    /// to start a server with authentication administratively
    /// disabled (fail-closed 503 on push-side verbs).
    fn spawn(token: Option<&str>) -> Self {
        // Pre-bind a TcpListener on ephemeral port to discover an
        // available one, then release it for the child to take over.
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
        let port = listener.local_addr().expect("local_addr").port();
        drop(listener);

        let repo = TempDir::new().expect("repo tempdir");
        let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
        cmd.args(["http"])
            .arg("-R")
            .arg(repo.path())
            .arg("--bind")
            .arg(format!("127.0.0.1:{port}"))
            // `--in-memory` avoids redb-fsync overhead and ensures
            // the server releases its repo on kill without leaving
            // lock files. Harness tests don't care about durability.
            .arg("--in-memory")
            // Scrub any ambient MNEM_* env that could bleed in from
            // the parent shell (`RUST_LOG`, `MNEM_BENCH`, etc.).
            .env_remove("MNEM_BENCH")
            .env_remove(SERVER_TOKEN_ENV)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if let Some(t) = token {
            cmd.env(SERVER_TOKEN_ENV, t);
        }
        let mut child = cmd.spawn().expect("spawn mnem http serve");

        let base_url = format!("http://127.0.0.1:{port}");

        // Poll /v1/healthz until 200. Bound the wait so a failed
        // bind doesn't hang CI.
        let deadline = Instant::now() + Duration::from_secs(5);
        let healthz = format!("{base_url}/v1/healthz");
        let mut last_err: Option<String> = None;
        loop {
            if Instant::now() > deadline {
                let _ = child.kill();
                panic!(
                    "mnem http serve did not come up on {base_url} within 5s: last={last_err:?}"
                );
            }
            match ureq::get(&healthz)
                .timeout(Duration::from_millis(250))
                .call()
            {
                Ok(resp) if resp.status() == 200 => break,
                Ok(resp) => last_err = Some(format!("status {}", resp.status())),
                Err(e) => last_err = Some(format!("{e}")),
            }
            std::thread::sleep(Duration::from_millis(50));
        }

        Self {
            child,
            base_url,
            _repo: repo,
        }
    }

    fn url(&self) -> &str {
        &self.base_url
    }
}

impl Drop for HttpServer {
    fn drop(&mut self) {
        // Best-effort shutdown. A dead child is fine.
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// Build a `mnem` subprocess scoped to `repo`, with no ambient
/// bearer env bleeding in.
fn mnem(repo: &Path) -> Command {
    let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
    cmd.current_dir(repo);
    cmd.arg("-R").arg(repo);
    // Start each invocation with a clean auth slate; individual
    // tests set MNEM_REMOTE_<NAME>_TOKEN per-Command when needed.
    cmd.env_remove(SERVER_TOKEN_ENV);
    cmd.env_remove("MNEM_REMOTE_ORIGIN_TOKEN");
    cmd
}

/// Initialise a repo under `dir` and add a single memory node so the
/// op-log has something to advance over.
fn init_repo_with_node(dir: &Path, summary: &str) {
    mnem(dir).arg("init").arg(dir).assert().success();
    mnem(dir)
        .args([
            "add",
            "node",
            "--label",
            "Memory",
            "--summary",
            summary,
            "--no-embed",
        ])
        .assert()
        .success();
}

/// Wire up `origin` pointing at `server` on `dir`'s `.mnem/config.toml`.
fn add_origin(dir: &Path, server: &HttpServer) {
    mnem(dir)
        .args(["remote", "add", "origin", server.url()])
        .assert()
        .success();
}

// ---------- Tests ----------

#[test]
fn fetch_round_trip_against_local_server() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "fetch round-trip");
    add_origin(client_dir.path(), &server);

    // `mnem fetch origin` against a bare server with no matching
    // refs must succeed (it's a no-op: nothing on remote to pull).
    // The exit 0 contract is what we assert.
    let out = mnem(client_dir.path())
        .arg("fetch")
        .arg("origin")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "fetch failed; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
#[ignore = "TODO(B3.5): first-push birth-commit protocol gap. Client \
            sends `old=local_head` when remote is empty; server \
            rejects because current heads are empty. Fix requires a \
            joint client+server change (either a `null`/zero-CID \
            sentinel in advance-head, or a dedicated `/birth-head` \
            route). Schema fix + auth coverage is sufficient for B3.4 \
            acceptance; full push round-trip lands in B3.5."]
fn push_round_trip_against_local_server() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "push round-trip");
    add_origin(client_dir.path(), &server);

    // push requires bearer. Pass the matching token via env.
    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(
        out.status.success(),
        "push failed; stdout={stdout}; stderr={stderr}"
    );
    // Best-effort output shape: git-like "To <url>" line on success.
    assert!(
        stdout.contains("To "),
        "expected Git-style push report, got: {stdout}"
    );
}

#[test]
fn push_without_token_is_401_to_cli() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "no-token push");
    add_origin(client_dir.path(), &server);

    // No bearer env supplied to the client. The CLI surfaces the
    // auth hint ("Set MNEM_REMOTE_<UPPER>_TOKEN").
    let out = mnem(client_dir.path())
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(!out.status.success(), "push must fail without a token");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("Authentication required") || stderr.contains("MNEM_REMOTE_"),
        "expected auth hint, got: {stderr}"
    );
}

#[test]
fn push_with_wrong_token_is_401_to_cli() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "wrong-token push");
    add_origin(client_dir.path(), &server);

    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", "definitely-not-the-real-token")
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        !out.status.success(),
        "push with wrong token must fail; stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("Authentication required")
            || stderr.contains("401")
            || stderr.contains("auth"),
        "expected auth rejection, got: {stderr}"
    );
}

#[test]
#[ignore = "TODO(B3.5): depends on first-push birth-commit protocol \
            (see `push_round_trip_against_local_server`)."]
fn push_then_second_push_no_op_is_idempotent() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "idempotent");
    add_origin(client_dir.path(), &server);

    // First push.
    mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Second push with nothing new: CLI short-circuits when remote
    // tip already matches local HEAD (see push.rs step 4). Treated
    // as success; the user sees no new ref advancement.
    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "repeat push of identical tip must be idempotent; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn fetch_twice_is_idempotent() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "fetch idempotent");
    add_origin(client_dir.path(), &server);

    // First fetch = no-op (empty remote). Second fetch = same.
    for _ in 0..2 {
        mnem(client_dir.path())
            .arg("fetch")
            .arg("origin")
            .assert()
            .success();
    }
}

#[test]
#[ignore = "TODO(B3.5): publisher's initial push hits the birth-commit \
            gap; once that lands this test flips back on."]
fn pull_fast_forward_succeeds() {
    // Publisher writes a commit + pushes; consumer pulls it.
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let pub_dir = TempDir::new().unwrap();
    init_repo_with_node(pub_dir.path(), "publisher-seed");
    add_origin(pub_dir.path(), &server);
    mnem(pub_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Consumer: fresh repo, no commits, pulls to obtain the branch.
    let sub_dir = TempDir::new().unwrap();
    mnem(sub_dir.path())
        .arg("init")
        .arg(sub_dir.path())
        .assert()
        .success();
    add_origin(sub_dir.path(), &server);

    let out = mnem(sub_dir.path())
        .args(["pull", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "fast-forward pull must succeed; stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
#[ignore = "TODO(B3.5): needs a prior successful push (birth-commit \
            gap) before the non-ff scenario can be reproduced."]
fn pull_non_ff_prints_merge_hint() {
    // Two clients push independent histories. After A pushes, B has
    // a divergent local head; `mnem pull` from B must refuse with
    // the merge-verb hint.
    let server = HttpServer::spawn(Some(TEST_TOKEN));

    // Client A: single commit, push.
    let a_dir = TempDir::new().unwrap();
    init_repo_with_node(a_dir.path(), "A commit");
    add_origin(a_dir.path(), &server);
    mnem(a_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Client B: independent commit (divergent history, no shared
    // ancestor with A).
    let b_dir = TempDir::new().unwrap();
    init_repo_with_node(b_dir.path(), "B commit");
    add_origin(b_dir.path(), &server);

    // Pull from B. Local head != remote tip, and local head is not
    // an ancestor of remote tip (disjoint histories) so the verb
    // must refuse with the merge-verb pointer.
    let out = mnem(b_dir.path())
        .args(["pull", "origin", "main"])
        .output()
        .unwrap();
    assert!(!out.status.success(), "non-ff pull must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("non-fast-forward") || stderr.contains("mnem merge"),
        "expected non-ff hint, got: {stderr}"
    );
}

#[test]
#[ignore = "TODO(B3.5): needs a prior successful push from client A \
            (birth-commit gap) before client B's competing push can \
            observe the CAS mismatch."]
fn push_cas_mismatch_surfaces_pull_hint_to_cli() {
    // Two clients push to the same ref without syncing. The second
    // push trips the CAS and the CLI prints the "run mnem pull" hint.
    let server = HttpServer::spawn(Some(TEST_TOKEN));

    let a_dir = TempDir::new().unwrap();
    init_repo_with_node(a_dir.path(), "A first");
    add_origin(a_dir.path(), &server);
    mnem(a_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    let b_dir = TempDir::new().unwrap();
    init_repo_with_node(b_dir.path(), "B competing");
    add_origin(b_dir.path(), &server);
    let out = mnem(b_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    // B's push should be rejected because the remote tip is A's,
    // not the <new>/<local_head> value B claims.
    assert!(!out.status.success(), "second competing push must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("mnem pull")
            || stderr.contains("Integrate remote changes")
            || stderr.contains("Updates were rejected"),
        "expected CAS-mismatch hint pointing at mnem pull, got: {stderr}"
    );
}

// ---------- Non-network smoke (retained from B3.3) ----------

#[test]
fn fetch_verb_rejects_missing_remote() {
    // Non-networked smoke: `mnem fetch origin` without `mnem
    // remote add origin` must error actionably. Exercises arg
    // parse + the config-load error path without any network.
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path())
        .arg("fetch")
        .arg("origin")
        .output()
        .unwrap();
    assert!(!out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("no remote") || stderr.contains("origin"),
        "expected actionable missing-remote diagnostic, got: {stderr}"
    );
}

#[test]
fn push_without_remote_errors() {
    // Defence-in-depth: `mnem push` without any configured remote
    // must exit non-zero; the fetch-precondition path covers
    // similar ground but `push` has its own error message.
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path()).arg("push").output().unwrap();
    assert!(!out.status.success());
}

#[test]
fn pull_without_tracking_ref_errors() {
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path()).arg("pull").output().unwrap();
    assert!(!out.status.success());
}
