//! End-to-end: `#[ktstr_test(host_only)]` runs on the real host
//! and sees the real-host topology (not the synthesized 2-CPU
//! placeholder from `KtstrTestEntry::DEFAULT.topology`).
//!
//! Pins the swap from `TestTopology::from_vm_topology(&entry.topology)`
//! to `TestTopology::from_system()?` inside
//! `crate::test_support::dispatch::run_host_only_test_inner`.
//!
//! The `from_vm_topology` default topology resolves to 2 CPUs (1
//! LLC × 2 cores × 1 thread, per the macro defaults at
//! `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
//! Asserting `cpu_count >= 4` here catches the regression
//! reliably across every CI runner the project supports — GHA
//! ubuntu-latest is 2-4 CPUs (marginal but typically the project
//! gates host_only tests behind sufficient runners), Apple
//! Silicon CI is 8-core, and dev hosts ≥ 8. A regression that
//! fell back to
//! `from_vm_topology` would report exactly 2 and trip the floor.
//!
//! Also covers the `WorkSpec::workers_pct(1.0)` opt-in scaling
//! contract. (The `KTSTR_HOST_CGROUP_PARENT` default-resolution
//! cascade moved to unit tests in `dispatch.rs` —
//! `resolve_host_cgroup_parent_*` — since it's pure env+const logic
//! that needs no VM and ignored `ctx`.)

use anyhow::Context;
use ktstr::assert::AssertResult;
use ktstr::ktstr_test;
use ktstr::scenario::Ctx;

/// Real-host topology must show ≥ 4 CPUs on any runner the project
/// supports. A regression that fell back to
/// `from_vm_topology(&entry.topology)` would yield exactly 2 CPUs
/// (1 LLC × 2 cores × 1 thread, per `KtstrTestEntry::DEFAULT.topology`
/// derived from macro defaults at
/// `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
/// The lower bound is portable to every project-supported runner;
/// raising it from 2 to 4 ensures the synth-topology regression
/// (2-CPU report) is caught reliably.
#[ktstr_test(host_only)]
fn host_mode_sees_real_host_cpus(ctx: &Ctx) -> Result<AssertResult, anyhow::Error> {
    // Detection signal that survives no_perf_mode CPU-pinning (which
    // narrows the test process to 1-2 host CPUs and would make
    // `ctx.topo.all_cpus().len() == 2` coincide with the
    // `from_vm_topology` fallback signature):
    //
    //   from_vm_topology synthesizes CPU IDs starting at 0
    //   (contiguous: 0, 1, ...).
    //   from_system reads sysfs-online CPUs (likely non-contiguous,
    //   especially after sched_getaffinity narrowing to a host pin).
    //
    // Cross-check the topo's CPU IDs against /sys/devices/system/cpu/online
    // (which bypasses sched_getaffinity). If every topo CPU is a member
    // of the sysfs-online set AND at least one topo CPU id > 1, the
    // routing went through from_system (synth would produce IDs 0..n
    // only). If the topo is exactly {0, 1} AND sysfs has > 2 CPUs, the
    // routing fell back to from_vm_topology (the regression).
    let topo_cpus: std::collections::BTreeSet<usize> =
        ctx.topo.all_cpus().iter().copied().collect();
    let sysfs_online = std::fs::read_to_string("/sys/devices/system/cpu/online")
        .context("read /sys/devices/system/cpu/online")?;
    let sysfs_cpus =
        parse_cpu_list(sysfs_online.trim()).context("parse /sys/devices/system/cpu/online")?;
    if sysfs_cpus.len() < 4 {
        // Test environment limitation: cargo-ktstr's no_perf_mode +
        // mount-namespace sandbox can virtualize
        // /sys/devices/system/cpu/online down to the test process's
        // CPU pin (typically 1-2 CPUs). In that env we cannot reliably
        // distinguish a correct from_system routing from a regressed
        // from_vm_topology fallback (both can return 2). SKIP — not
        // PASS — so this env is not counted as a run that actually
        // verified the routing; a real-host CI runner (>= 4 sysfs CPUs)
        // still gates the regression below.
        return Ok(AssertResult::skip(format!(
            "sysfs reports {} CPUs (< 4) in this test env (sandboxed \
             cargo-ktstr context with narrowed /sys); the from_system \
             routing regression check cannot reliably trigger. Run on a \
             real-host CI with >= 4 visible sysfs CPUs to gate.",
            sysfs_cpus.len(),
        )));
    }
    // Distinguishing signal: the synth topo produces CPU ids
    // starting at 0 (1×2×1 → {0, 1}). On a >= 4-CPU host where
    // no_perf_mode narrowed the test process to 2 CPUs, those 2
    // CPUs are almost-never {0, 1} (sysfs-online typically starts at
    // 0 but the no_perf_mode pinner chooses based on hash/round-
    // robin across the available host CPU pool — the chance of
    // hitting {0, 1} on a 316-CPU host is ~2/316²). Detect the
    // regression when topo CPUs are exactly {0, 1} AND sysfs has
    // more than 2 CPUs.
    if topo_cpus == std::collections::BTreeSet::from([0, 1]) && sysfs_cpus.len() > 2 {
        return Ok(AssertResult::fail_msg(format!(
            "host_only routed through `from_vm_topology` instead of \
             `from_system`: ctx.topo.all_cpus() = {{0, 1}} matches \
             KtstrTestEntry::DEFAULT.topology (1 LLC × 2 cores × 1 \
             thread) exactly while the real host has {} online CPUs.",
            sysfs_cpus.len(),
        )));
    }
    // Every topo CPU id MUST be a member of the sysfs-online set —
    // catches a regression where from_vm_topology synthesizes IDs
    // that don't correspond to real CPUs (e.g. a 1×8×1 default
    // would produce 0..=7 even on a 316-CPU host with no_perf_mode
    // pinning to {16, 17}).
    let sysfs_set: std::collections::BTreeSet<usize> = sysfs_cpus.iter().copied().collect();
    let synth_ids: Vec<usize> = topo_cpus.difference(&sysfs_set).copied().collect();
    if !synth_ids.is_empty() {
        return Ok(AssertResult::fail_msg(format!(
            "ctx.topo.all_cpus() contains IDs {synth_ids:?} not present \
             in /sys/devices/system/cpu/online (sysfs_count = {}); \
             host_only must derive its topology from real sysfs CPUs \
             — synthesized IDs indicate a from_vm_topology routing.",
            sysfs_cpus.len(),
        )));
    }
    Ok(AssertResult::pass())
}

/// Parse the kernel's `/sys/devices/system/cpu/online` cpu-list
/// format ("0-3,7,10-12") into a Vec of CPU IDs. Used to read the
/// real-host online-CPU set directly without going through
/// `sched_getaffinity` (which the test process may have narrowed
/// under no_perf_mode CPU-pinning).
///
/// Returns `Err` on any malformed token rather than silently
/// skipping — the kernel never emits malformed lines today and a
/// silent skip would let a truncated / corrupt sysfs read (e.g.
/// `unwrap_or(0)` mapping "foo-bar" to {0}) trigger the
/// early-PASS branch above OR the topo-vs-sysfs membership
/// mismatch below with a misleading diagnostic. Per the
/// no-silent-drops policy, surfacing the parse failure here
/// gives the test author an accurate signal instead of a
/// downstream contradiction.
fn parse_cpu_list(s: &str) -> anyhow::Result<Vec<usize>> {
    use anyhow::anyhow;
    let mut cpus = Vec::new();
    for range in s.split(',') {
        let range = range.trim();
        if range.is_empty() {
            continue;
        }
        match range.split_once('-') {
            Some((lo, hi)) => {
                let lo: usize = lo
                    .trim()
                    .parse()
                    .map_err(|e| anyhow!("cpu-list range lo {lo:?}: {e}"))?;
                let hi: usize = hi
                    .trim()
                    .parse()
                    .map_err(|e| anyhow!("cpu-list range hi {hi:?}: {e}"))?;
                if hi < lo {
                    return Err(anyhow!(
                        "cpu-list range {lo}-{hi}: hi < lo (kernel emits monotonic ranges)"
                    ));
                }
                for c in lo..=hi {
                    cpus.push(c);
                }
            }
            None => {
                let c: usize = range
                    .parse()
                    .map_err(|e| anyhow!("cpu-list singleton {range:?}: {e}"))?;
                cpus.push(c);
            }
        }
    }
    Ok(cpus)
}

/// Pin the opt-in scaling contract end-to-end:
/// `WorkSpec::workers_pct(1.0)` in host_only mode resolves to
/// `usable_cpuset().len()` because host_only routes topology through
/// `TestTopology::from_system`. That is the host CPU count minus the
/// one CPU reserved for the root cgroup when the host has more than 2
/// CPUs (`usable_cpus` returns `cpus[..len - 1]` for `len > 2`, all
/// CPUs otherwise). The internal `WorkSpec::resolve_workers_pct` is
/// `pub(crate)` — not callable from an integration test — so this pin
/// reduces to the upstream invariant: the size the ctx exposes as
/// `usable_cpuset()` is what `apply_setup` feeds `resolve_workers_pct`
/// as its `cpuset_cpus` argument when the CgroupDef inherits the
/// topology-default cpuset. `ceil(usable_cpuset().len() * 1.0)` then
/// equals `usable_cpuset().len()`, so a workers_pct(1.0) workload
/// spawns one worker per usable CPU.
#[ktstr_test(host_only)]
fn host_mode_workers_pct_scales_to_host_cpu_count(
    ctx: &Ctx,
) -> Result<AssertResult, anyhow::Error> {
    let all_cpus = ctx.topo.all_cpus().len();
    let usable = ctx.topo.usable_cpuset().len();
    if all_cpus < 4 {
        // Test environment limitation: cargo-ktstr's no_perf_mode +
        // mount-namespace sandbox narrows sysfs and sched_getaffinity
        // such that `ctx.topo.all_cpus()` reports the test's pinned
        // CPU count (typically 1-2), not the real host's CPU count.
        // Under that narrowing the workers_pct(1.0) scaling cannot
        // be distinguished from the `from_vm_topology` default-topology
        // fallback. SKIP — not PASS — so this env is not counted as a
        // passing run; the sibling test `host_mode_sees_real_host_cpus`
        // gates the from_system routing regression via
        // /sys/devices/system/cpu/online cross-check that bypasses
        // the affinity-narrowing layer.
        return Ok(AssertResult::skip(format!(
            "ctx.topo reports {all_cpus} CPUs (< 4) in this test env \
             (sandboxed cargo-ktstr context with narrowed \
             sched_getaffinity); the workers_pct scaling check cannot \
             reliably trigger. Run on a real-host CI with >= 4 visible \
             CPUs to gate."
        )));
    }
    // Real multi-CPU host visible (>= 4). `usable_cpus` reserves the
    // last CPU for the root cgroup when the host has > 2 CPUs, so the
    // usable set is exactly `all_cpus - 1`. workers_pct(1.0) resolves
    // to `usable_cpuset().len()` (the cpuset `apply_setup` feeds
    // `resolve_workers_pct`), so pinning `usable == all_cpus - 1` with
    // `all_cpus >= 4` proves host_only routed topology through
    // `from_system` (the real host count), not the VM-default fallback
    // — and that a workers_pct(1.0) workload therefore spawns one
    // worker per usable host CPU.
    let expected_usable = all_cpus - 1;
    if usable != expected_usable {
        return Ok(AssertResult::fail_msg(format!(
            "ctx.topo.usable_cpuset().len()={usable} != all_cpus().len()-1={expected_usable} \
             (the last CPU is reserved for the root cgroup when > 2 CPUs). The \
             usable set diverged from the topology default — an operator CPU \
             restriction (KTSTR_NO_PERF_MODE / KTSTR_CPU_CAP / a sched_setaffinity \
             policy) intersected it. Unset the restriction or skip this test."
        )));
    }
    Ok(AssertResult::pass())
}
