// acceptance.rs --- M1 acceptance suite.

//! Acceptance tests for Milestone 1.
//!
//! Each test corresponds to one bullet in the M1 acceptance criteria from
//! the tasks doc (T M1.10). Most are gated behind `#[ignore]` because
//! they are slow or perf-sensitive (release-mode only); run them with:
//!
//! ```sh
//! cargo test --release --test acceptance -- --ignored --nocapture
//! ```
//!
//! Targets:
//! * Open 100 MB file < 200 ms.
//! * Edit p99 < 1 ms on 10 MB buffer.
//! * Snapshot < 10 µs.
//! * Memory after 10 000 random edits < 3 × source file size.
//! * 30 s fuzz (10 min via `PMACS_FUZZ_SECS=600`) produces no crashes.

use std::time::{Duration, Instant};

use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::rope::{Range, Rope};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn synthetic_bytes(n: usize) -> Vec<u8> {
    (0..n).map(|i| (i % 251) as u8).collect()
}

/// Approximate live byte count: sum of all leaf chunk sizes.
///
/// Walks the rope and sums chunk lengths. Chunks may be shared (Arc) so
/// this overestimates memory in the presence of structural sharing, which
/// is the conservative direction for the "≤ 3× source size" gate.
fn approx_bytes(rope: &Rope) -> u64 {
    rope.chunks(0, rope.len()).map(|c| c.len() as u64).sum()
}

// ---------------------------------------------------------------------------
// Perf gates (release-only, ignored by default)
// ---------------------------------------------------------------------------

#[test]
#[ignore = "release-only perf gate; run with --ignored --release"]
fn open_100mb_under_200ms() {
    let src = synthetic_bytes(100 * 1024 * 1024);
    let started = Instant::now();
    let rope = Rope::from_bytes(&src);
    let elapsed = started.elapsed();
    eprintln!("open_100mb: {elapsed:?}");
    assert_eq!(rope.len(), src.len() as u64);
    assert!(
        elapsed < Duration::from_millis(200),
        "100MB load took {elapsed:?}, target < 200ms"
    );
}

#[test]
#[ignore = "release-only perf gate; run with --ignored --release"]
fn edit_p99_under_1ms_on_10mb_buffer() {
    let src = synthetic_bytes(10 * 1024 * 1024);
    let mut rope = Rope::from_bytes(&src);

    let mut times = Vec::with_capacity(1000);
    for i in 0..1000 {
        let pos = ((i * 1024) as u64) % rope.len();
        let started = Instant::now();
        rope = rope.insert(pos, b"x").unwrap().new_rope;
        times.push(started.elapsed());
    }
    times.sort_unstable();
    let p50 = times[500];
    let p99 = times[990];
    let max = times[999];
    eprintln!("edit p50={p50:?} p99={p99:?} max={max:?}");
    assert!(
        p99 < Duration::from_millis(1),
        "edit p99 was {p99:?}, target < 1ms"
    );
}

#[test]
#[ignore = "release-only perf gate; run with --ignored --release"]
fn snapshot_under_10us() {
    let src = synthetic_bytes(10 * 1024 * 1024);
    let rope = Rope::from_bytes(&src);

    // Warm + measure 10 000 snapshots; per-op average must be under 10 µs.
    // Snapshot is an Arc::clone, so the floor is sub-µs in practice.
    let started = Instant::now();
    let snaps: Vec<Rope> = (0..10_000).map(|_| rope.snapshot()).collect();
    let elapsed = started.elapsed();
    let per_op = elapsed / 10_000;
    eprintln!("snapshot per-op (avg over 10k): {per_op:?}");
    std::hint::black_box(snaps);
    assert!(
        per_op < Duration::from_micros(10),
        "snapshot avg was {per_op:?}, target < 10µs"
    );
}

#[test]
#[ignore = "release-only memory gate; run with --ignored --release"]
fn memory_after_10k_edits_within_3x() {
    let original = synthetic_bytes(64 * 1024); // 64 KB source
    let mut rope = Rope::from_bytes(&original);

    let mut state: u64 = 0xACCE_5817;
    let mut rng = || {
        state = state
            .wrapping_mul(6_364_136_223_846_793_005)
            .wrapping_add(1);
        (state >> 33) as u32
    };

    for _ in 0..10_000 {
        let len = rope.len();
        match rng() % 3 {
            0 => {
                let pos = u64::from(rng()) % (len + 1);
                let n = (rng() % 64 + 1) as usize;
                let bytes: Vec<u8> = (0..n)
                    .map(|i| (rng() as u8).wrapping_add(i as u8))
                    .collect();
                rope = rope.insert(pos, &bytes).unwrap().new_rope;
            }
            1 if len > 0 => {
                let s = u64::from(rng()) % len;
                let e = (s + u64::from(rng()) % (len - s).max(1)).min(len);
                if s < e {
                    rope = rope.delete(s, e).unwrap().new_rope;
                }
            }
            _ if len > 0 => {
                let s = u64::from(rng()) % len;
                let e = (s + u64::from(rng()) % (len - s).max(1)).min(len);
                if s < e {
                    let n = (rng() % 16) as usize;
                    let bytes: Vec<u8> = (0..n)
                        .map(|i| (rng() as u8).wrapping_add(i as u8))
                        .collect();
                    rope = rope.replace(s, e, &bytes).unwrap().new_rope;
                }
            }
            _ => {}
        }
    }

    let live = approx_bytes(&rope);
    let limit = (original.len() as u64) * 3;
    eprintln!(
        "after 10k edits: rope.len = {}, leaf-bytes = {} (limit = {})",
        rope.len(),
        live,
        limit
    );
    assert!(
        live <= limit,
        "leaf bytes {live} exceed 3× source ({limit})"
    );
}

#[test]
#[ignore = "long; default 30s, override with PMACS_FUZZ_SECS=N"]
fn fuzz_no_crashes() {
    let secs: u64 = std::env::var("PMACS_FUZZ_SECS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(30);
    let deadline = Instant::now() + Duration::from_secs(secs);

    let mut state: u64 = 0xF000_0001;
    let mut rng = || {
        state = state
            .wrapping_mul(6_364_136_223_846_793_005)
            .wrapping_add(1);
        (state >> 33) as u32
    };

    let mut buf = Buffer::from_bytes(BufferId::next(), "fuzz", &synthetic_bytes(256));
    let mut iters = 0u64;
    while Instant::now() < deadline {
        iters += 1;
        let len = buf.len();
        let _ = match rng() % 6 {
            0 => {
                let pos = u64::from(rng()) % (len + 1);
                let n = (rng() % 32 + 1) as usize;
                let bytes: Vec<u8> = (0..n)
                    .map(|i| (rng() as u8).wrapping_add(i as u8))
                    .collect();
                buf.apply_edit(EditOp::Insert { pos, bytes: &bytes })
                    .map(|_| ())
            }
            1 if len > 0 => {
                let s = u64::from(rng()) % len;
                let e = (s + u64::from(rng()) % (len - s).max(1)).min(len);
                if s < e {
                    buf.apply_edit(EditOp::Delete {
                        range: Range::new(s, e),
                    })
                    .map(|_| ())
                } else {
                    Ok(())
                }
            }
            2 if len > 0 => {
                let s = u64::from(rng()) % len;
                let e = (s + u64::from(rng()) % (len - s).max(1)).min(len);
                if s < e {
                    let n = (rng() % 16) as usize;
                    let bytes: Vec<u8> = (0..n)
                        .map(|i| (rng() as u8).wrapping_add(i as u8))
                        .collect();
                    buf.apply_edit(EditOp::Replace {
                        range: Range::new(s, e),
                        bytes: &bytes,
                    })
                    .map(|_| ())
                } else {
                    Ok(())
                }
            }
            3 => {
                let _ = buf.undo();
                Ok(())
            }
            4 => {
                let _ = buf.redo();
                Ok(())
            }
            _ => Ok(()),
        };
    }
    eprintln!("fuzz: {iters} iterations in {secs}s, no crash");
}

// ---------------------------------------------------------------------------
// Always-on smoke tests (cheap; run every CI build)
// ---------------------------------------------------------------------------

#[test]
fn snapshot_independence_after_many_edits() {
    // Take a snapshot, then mutate the source many times; the snapshot
    // must remain byte-identical to its original content. This exercises
    // the structural-sharing contract end-to-end at the public API.
    let src = synthetic_bytes(8 * 1024);
    let original = Rope::from_bytes(&src);
    let snap = original.snapshot();

    let mut current = original;
    for i in 0..200 {
        let pos = (i * 17) as u64 % (current.len() + 1);
        current = current.insert(pos, b"X").unwrap().new_rope;
    }

    // Snapshot still round-trips to the source bytes.
    let mut out = vec![0u8; snap.len() as usize];
    snap.slice(0, snap.len(), &mut out);
    assert_eq!(out, src);
}

#[test]
fn round_trip_via_buffer() {
    // The integration path: Buffer::from_bytes → many edits → undo all →
    // recover source bytes exactly.
    let src = synthetic_bytes(2 * 1024);
    let mut buf = Buffer::from_bytes(BufferId::next(), "test", &src);
    let edits = 50;
    for i in 0..edits {
        let pos = (i * 41) as u64 % (buf.len() + 1);
        buf.apply_edit(EditOp::Insert { pos, bytes: b"Z" }).unwrap();
    }
    for _ in 0..edits {
        buf.undo().unwrap();
    }
    let mut out = vec![0u8; buf.len() as usize];
    buf.snapshot_rope().slice(0, buf.len(), &mut out);
    assert_eq!(out, src);
}

// ---------------------------------------------------------------------------
// M3.8 — Memory and lifecycle audit (long-running soak)
// ---------------------------------------------------------------------------
//
// The spec asks for a 1-hour continuous-load run with stable memory.
// CI cannot afford that, so the test is `#[ignore]`'d and reads its
// duration from `PMACS_SOAK_SECS` (default: 10 seconds for a quick
// local sanity check). The 1-hour run is:
//
// ```sh
// PMACS_SOAK_SECS=3600 cargo test --release --test acceptance \
//     -- --ignored --nocapture async_runtime_soak_lifecycle_stable
// ```
//
// Acceptance bullets covered:
//   * Memory growth under 5%/hour under representative load.
//   * No file-descriptor leaks.
//   * Internal state-tables (pending, supersede) return to zero on
//     drain; the completion ring stays bounded.
//
// Linux-only for the FD/RSS instrumentation; the test still runs
// elsewhere but skips those gates with a stderr note.
//
// Valgrind / sanitizer gates (run manually; long):
//
// ```sh
// # Valgrind: leak-check the 1000-cycle stress test.
// valgrind --leak-check=full --error-exitcode=1 \
//     ./target/debug/deps/pmacs-* dispatch_cancel_1000_cycles_no_leak \
//     --test-threads=1 --nocapture
//
// # AddressSanitizer (nightly): catches use-after-free / overflows.
// RUSTFLAGS="-Zsanitizer=address" \
//     cargo +nightly test --target x86_64-unknown-linux-gnu --lib \
//     dispatch_cancel_1000_cycles_no_leak supersede_churn_500 stream_dispatch_close
//
// # LeakSanitizer (nightly).
// RUSTFLAGS="-Zsanitizer=leak" \
//     cargo +nightly test --target x86_64-unknown-linux-gnu \
//     dispatch_cancel_1000_cycles_no_leak
// ```
//
// The Rust+luajit FFI surface is the primary suspect for any
// finding; the runtime itself is pure-Rust and ownership-driven.

fn read_proc_fd_count() -> Option<usize> {
    std::fs::read_dir("/proc/self/fd").ok().map(Iterator::count)
}

fn read_proc_rss_kib() -> Option<u64> {
    let s = std::fs::read_to_string("/proc/self/status").ok()?;
    for line in s.lines() {
        if let Some(rest) = line.strip_prefix("VmRSS:") {
            let kib: u64 = rest.split_whitespace().next()?.parse().ok()?;
            return Some(kib);
        }
    }
    None
}

mod soak_helpers {
    use std::time::{Duration, Instant};

    use pmacs::async_runtime::{AsyncRuntime, JobOutcome};

    /// One soak cycle: dispatch a workload, optionally cancel it,
    /// drain it through to eviction. Returns once the dispatched id
    /// is no longer in `pending`.
    pub(super) fn one_cycle(rt: &AsyncRuntime, rng: &mut dyn FnMut() -> u32, iter: u64) {
        let id = match rng() % 3 {
            0 => rt.dispatch_sleep(i64::from(rng()) % 4 + 1, None),
            1 => rt.dispatch_compute_sum((u64::from(rng()) % 1_000) + 1, None),
            _ => rt.dispatch_emit_n((u64::from(rng()) % 32) + 1, None, Some(8)),
        };
        if rng().is_multiple_of(4) {
            rt.cancel(id);
        }
        let inner_deadline = Instant::now() + Duration::from_secs(2);
        loop {
            let _ = rt.tick();
            for batch in rt.take_stream_batches() {
                if batch.id == id && batch.closed {
                    break;
                }
            }
            if rt.is_complete(id) {
                let outcome = rt.take_result(id);
                debug_assert!(matches!(
                    outcome,
                    Some(JobOutcome::Complete(_) | JobOutcome::Cancelled | JobOutcome::Failed(_))
                        | None
                ));
                return;
            }
            if rt.workers_snapshot().active.iter().all(|a| a.id != id) {
                return;
            }
            assert!(
                Instant::now() < inner_deadline,
                "soak: per-cycle settle deadline at iter {iter}"
            );
            std::thread::sleep(Duration::from_millis(1));
        }
    }

    /// Drain the runtime to `pending_len() == 0` after the soak loop
    /// exits. Anything still pending must reach a terminal state and
    /// be evicted within a bounded window.
    pub(super) fn final_drain(rt: &AsyncRuntime) {
        let deadline = Instant::now() + Duration::from_secs(5);
        while rt.pending_len() > 0 {
            assert!(
                Instant::now() < deadline,
                "soak: post-loop drain stuck at pending_len={}",
                rt.pending_len()
            );
            let _ = rt.tick();
            let _ = rt.take_stream_batches();
            let snap = rt.workers_snapshot();
            for a in snap.active {
                if rt.is_complete(a.id) {
                    let _ = rt.take_result(a.id);
                }
            }
            std::thread::sleep(Duration::from_millis(1));
        }
    }
}

#[test]
#[ignore = "long; default 10s, override with PMACS_SOAK_SECS=N (3600 for spec gate)"]
fn async_runtime_soak_lifecycle_stable() {
    use pmacs::async_runtime::AsyncRuntime;

    let secs: u64 = std::env::var("PMACS_SOAK_SECS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(10);
    let rss_growth_pct: f64 = std::env::var("PMACS_SOAK_RSS_PCT")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(5.0);

    // Warm-up: a full dispatch / settle / take_result so the
    // baseline excludes first-touch costs and leaves zero pending.
    let rt = AsyncRuntime::with_pool_size(2);
    let warm = rt.dispatch_sleep(1, None);
    let warm_deadline = Instant::now() + Duration::from_secs(1);
    while !rt.is_complete(warm) {
        assert!(Instant::now() < warm_deadline, "warm-up dispatch stuck");
        let _ = rt.tick();
        std::thread::sleep(Duration::from_millis(1));
    }
    let _ = rt.take_result(warm);

    let baseline_fd = read_proc_fd_count();
    let baseline_rss = read_proc_rss_kib();
    let mut peak_pending: usize = 0;
    let mut peak_supersede: usize = 0;
    let mut iters: u64 = 0;

    let deadline = Instant::now() + Duration::from_secs(secs);
    let mut state: u64 = 0xD17C_0DE5;
    let mut rng = || {
        state = state
            .wrapping_mul(6_364_136_223_846_793_005)
            .wrapping_add(1);
        (state >> 33) as u32
    };

    while Instant::now() < deadline {
        iters += 1;
        soak_helpers::one_cycle(&rt, &mut rng, iters);
        peak_pending = peak_pending.max(rt.pending_len());
        peak_supersede = peak_supersede.max(rt.supersede_len());
    }

    soak_helpers::final_drain(&rt);

    eprintln!(
        "soak: {iters} iters in {secs}s, peak pending={peak_pending}, peak supersede={peak_supersede}"
    );
    assert_eq!(rt.pending_len(), 0, "post-soak pending leaked");
    assert_eq!(rt.supersede_len(), 0, "post-soak supersede leaked");

    // FD gate (Linux-only): tolerate up to +4 fds for caches the
    // stdlib opens lazily (timezone files, locale data, etc).
    if let (Some(b), Some(a)) = (baseline_fd, read_proc_fd_count()) {
        eprintln!("soak: fd baseline={b}, after={a}");
        assert!(
            a <= b + 4,
            "soak: file-descriptor leak --- baseline {b}, after {a}"
        );
    } else {
        eprintln!("soak: /proc/self/fd unavailable; FD gate skipped");
    }

    // RSS gate (Linux-only): scale the allowed growth to the soak
    // duration. The spec calls for 5%/hour; over a 10-second smoke
    // run the absolute slack is tiny, so we floor the allowance at
    // 8 MiB to keep the gate noise-tolerant for short runs.
    if let (Some(b), Some(a)) = (baseline_rss, read_proc_rss_kib()) {
        let scaled_pct = rss_growth_pct * (secs as f64) / 3600.0;
        #[allow(
            clippy::cast_precision_loss,
            clippy::cast_possible_truncation,
            clippy::cast_sign_loss
        )]
        let allowed_kib = (b as f64 * scaled_pct / 100.0).max(8.0 * 1024.0) as u64;
        let grew = a.saturating_sub(b);
        eprintln!(
            "soak: rss baseline={b} KiB, after={a} KiB, grew={grew} KiB, allowed={allowed_kib} KiB"
        );
        assert!(
            grew <= allowed_kib,
            "soak: RSS grew {grew} KiB over {secs}s; allowed {allowed_kib} KiB"
        );
    } else {
        eprintln!("soak: /proc/self/status unavailable; RSS gate skipped");
    }
}
