//! Cross-language conformance for the rate-shaping source operators
//! (`#lzrateshape`) — see `lazily-spec/docs/rate-shaping.md` and
//! `lazily-spec/conformance/rateshape/*.json`.
//!
//! Compute fixtures: replay each `input`/`tick` op, assert the emitted value
//! (`returns`), the projected `output`, and that the `output` reader invalidates
//! exactly on an emit (observed via `ctx.is_set` on a wrapping `computed`).

mod common;

use common::Expect;
use lazily::{
    Context, DebounceCell, Lcg, ProbabilisticSampleCell, SampleCell, SampleMode, ThrottleCell,
    ThrottleEdge,
};
use serde_json::Value;

const SPEC_DIR: common::SpecDir = common::SpecDir("rateshape");

fn load_fixture(name: &str) -> Value {
    let path = format!("{SPEC_DIR}/{name}");
    let raw = crate::common::spec_read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
    serde_json::from_str(&raw).unwrap_or_else(|e| panic!("failed to parse fixture {path}: {e}"))
}

fn present() -> bool {
    SPEC_DIR.join("debounce.json").exists()
}

fn steps(fx: &Value) -> &Vec<Value> {
    fx["steps"].as_array().unwrap()
}
fn op_now(step: &Value) -> u64 {
    step["op"]["now"].as_u64().unwrap()
}
fn op_val(step: &Value) -> String {
    step["op"]["value"].as_str().unwrap().to_string()
}
fn ret(step: &Value) -> Option<String> {
    step["returns"].as_str().map(|s| s.to_string())
}
/// The step's op discriminator, read and CHECKED (`#lzscenariobodyskip`).
///
/// `sample_count` and `probabilistic_sample` used to replay every step as an
/// `input` without reading `op.type` at all, and the four time-driven operators
/// dispatched with a bare `else` that ASSUMED `tick`. Both are the same
/// fail-open: a fixture naming an op this runner does not implement replays as
/// something else, and the step's `expected` block is then compared against
/// state the named op never produced.
fn op_type<'a>(fixture: &str, step: &'a Value) -> &'a str {
    step["op"]["type"]
        .as_str()
        .unwrap_or_else(|| panic!("{fixture}: step op carries no `type`: {step}"))
}
/// Run a fixture whose emit projection is a `Source<Option<String>>`, given a
/// per-step driver returning the emitted value and the current output.
///
/// Each step's `expected` block is guarded (`#lzassertunknownkeys`): a key this
/// runner never reads fails the fixture instead of passing unnoticed.
fn run<F>(
    ctx: &Context,
    name: &str,
    fx: &Value,
    observed: lazily::Computed<Option<String>>,
    mut drive: F,
) where
    F: FnMut(&Value) -> (Option<String>, Option<String>),
{
    let _ = observed.get(ctx);
    for (i, step) in steps(fx).iter().enumerate() {
        let (emitted, output) = drive(step);
        let exp = Expect::new(
            format!("{SPEC_DIR}/{name}"),
            format!("steps[{i}].expected"),
            &step["expected"],
        );
        let inv = exp.sub("invalidates");
        assert_eq!(emitted, ret(step), "emit for {step}");
        exp.assert_key_with("output", |want| {
            assert_eq!(
                output,
                want.as_str().map(|s| s.to_string()),
                "output for {step}"
            )
        });

        let was_cached = ctx.is_set(&observed);
        let _ = observed.get(ctx);
        inv.assert_key_at("output", !was_cached, &format!("step {step}"));
    }
}

#[test]
fn debounce() {
    if !present() {
        return;
    }
    let fx = load_fixture("debounce.json");
    let ctx = Context::new();
    let quiet = fx["initial"]["quiet"].as_u64().unwrap();
    let cell = DebounceCell::<String>::new(&ctx, quiet);
    let out = cell.output_cell();
    let observed = ctx.computed(move |c| out.get(c));
    run(&ctx, "debounce.json", &fx, observed, |step| {
        let emitted = match op_type("debounce.json", step) {
            "input" => {
                cell.input(&ctx, op_now(step), op_val(step));
                None
            }
            "tick" => cell.tick(&ctx, op_now(step)),
            other => panic!("debounce.json: unknown rateshape op `{other}`"),
        };
        (emitted, cell.output(&ctx))
    });
}

fn run_throttle(name: &str, edge: ThrottleEdge) {
    let fx = load_fixture(name);
    let ctx = Context::new();
    let window = fx["initial"]["window"].as_u64().unwrap();
    let cell = ThrottleCell::<String>::new(&ctx, edge, window);
    let out = cell.output_cell();
    let observed = ctx.computed(move |c| out.get(c));
    run(&ctx, name, &fx, observed, |step| {
        let emitted = match op_type(name, step) {
            "input" => cell.input(&ctx, op_now(step), op_val(step)),
            "tick" => cell.tick(&ctx, op_now(step)),
            other => panic!("{name}: unknown rateshape op `{other}`"),
        };
        (emitted, cell.output(&ctx))
    });
}

#[test]
fn throttle_leading() {
    if !present() {
        return;
    }
    run_throttle("throttle_leading.json", ThrottleEdge::Leading);
}

#[test]
fn throttle_trailing() {
    if !present() {
        return;
    }
    run_throttle("throttle_trailing.json", ThrottleEdge::Trailing);
}

#[test]
fn sample_count() {
    if !present() {
        return;
    }
    let fx = load_fixture("sample_count.json");
    let ctx = Context::new();
    let n = fx["initial"]["n"].as_u64().unwrap();
    let cell = SampleCell::<String>::new(&ctx, SampleMode::Count(n));
    let out = cell.output_cell();
    let observed = ctx.computed(move |c| out.get(c));
    run(&ctx, "sample_count.json", &fx, observed, |step| {
        let emitted = match op_type("sample_count.json", step) {
            "input" => cell.input(&ctx, op_val(step)),
            other => panic!("sample_count.json: unknown rateshape op `{other}`"),
        };
        (emitted, cell.output(&ctx))
    });
}

#[test]
fn sample_time() {
    if !present() {
        return;
    }
    let fx = load_fixture("sample_time.json");
    let ctx = Context::new();
    let period = fx["initial"]["period"].as_u64().unwrap();
    let cell = SampleCell::<String>::new(&ctx, SampleMode::Time(period));
    let out = cell.output_cell();
    let observed = ctx.computed(move |c| out.get(c));
    run(&ctx, "sample_time.json", &fx, observed, |step| {
        let emitted = match op_type("sample_time.json", step) {
            "input" => {
                cell.input(&ctx, op_val(step));
                None
            }
            "tick" => cell.tick(&ctx, op_now(step)),
            other => panic!("sample_time.json: unknown rateshape op `{other}`"),
        };
        (emitted, cell.output(&ctx))
    });
}

#[test]
fn probabilistic_sample() {
    if !present() {
        return;
    }
    let fx = load_fixture("probabilistic_sample.json");
    let ctx = Context::new();
    let rate = fx["initial"]["rate"].as_f64().unwrap();
    // Draws are injected per step via `input_with_draw`, so the owned RNG is
    // unused here; a deterministic `Lcg` satisfies the type bound.
    let cell = ProbabilisticSampleCell::<String, Lcg>::new(&ctx, rate, Lcg::new(0));
    let out = cell.output_cell();
    let observed = ctx.computed(move |c| out.get(c));
    run(&ctx, "probabilistic_sample.json", &fx, observed, |step| {
        let emitted = match op_type("probabilistic_sample.json", step) {
            "input" => {
                let draw = step["op"]["draw"].as_f64().unwrap();
                cell.input_with_draw(&ctx, op_val(step), draw)
            }
            other => panic!("probabilistic_sample.json: unknown rateshape op `{other}`"),
        };
        (emitted, cell.output(&ctx))
    });
}
