//! Pointwise-claim accumulator API: [`Verdict`], [`ClaimBuilder`],
//! [`SetClaim`], [`SeqClaim`], and the [`claim!`](crate::claim) macro.
//!
//! `Verdict` is the per-test claim accumulator. [`Assert`] holds threshold
//! config and stays `Copy`; `Verdict` carries the per-test claim records
//! (which include `Vec`/`String` allocations) and is built via
//! [`Assert::default_checks`]`().verdict()` or [`Verdict::new`].
//!
//! Test authors reach for one of two compile-mechanical labelers:
//!
//!   1. Typed field accessors generated by `#[derive(Claim)]` on stats
//!      structs — e.g. `stats.claim_max_gap_ms(&mut verdict).at_most(100)`.
//!      The label `"max_gap_ms"` comes from `stringify!(max_gap_ms)` in
//!      the generated method body; renaming the field updates both the
//!      method name AND the rendered label.
//!
//!   2. The [`claim!`](crate::claim) macro on a local binding or
//!      expression — e.g. `claim!(verdict, iter_delta).at_least(100)`.
//!      The label comes from `stringify!(<token tree>)` over the
//!      expression tokens.
//!
//! There is NO third recommended "manual string" path. The
//! string-taking entry points on `Verdict` (`claim` / `claim_set` /
//! `claim_seq` / `claim_present`) are `#[doc(hidden)]` and expected to
//! be fed only by `stringify!` via the derive or the `claim!` macro.
//!
//! Comparator surface for [`ClaimBuilder<T>`]:
//!   * `T: PartialOrd + Display` -> `at_least` / `at_most` / `lt` / `gt`
//!     / `between`
//!   * `T: PartialEq + Display`  -> `eq` / `ne`
//!   * `T = f64`                 -> `is_finite` / `near`
//!
//! Container claims (set / sequence) bypass scalar comparators and offer
//! `empty` / `nonempty` / `contains` / `len_eq` / `len_at_most` /
//! `len_at_least` / `subset_of` / `disjoint_from` instead.

use std::collections::BTreeSet;

use super::{Assert, AssertDetail, AssertResult, DetailKind, NoteValue, Outcome};

/// Read the `KTSTR_LOG_PASSES` env var to seed
/// [`Verdict::log_passes`]. Any value other than `""` or `"0"` enables
/// the flag — so `KTSTR_LOG_PASSES=1`, `KTSTR_LOG_PASSES=true`, and a
/// bare `KTSTR_LOG_PASSES=` (empty) all behave intuitively. Read once
/// at [`Verdict::new`] time per call; cheap (`getenv` + tiny match)
/// and respects a mid-process env-var flip without process restart.
fn log_passes_default() -> bool {
    match std::env::var(crate::KTSTR_LOG_PASSES_ENV) {
        Ok(v) => !(v.is_empty() || v == "0"),
        Err(_) => false,
    }
}

/// Pointwise-claim accumulator.
///
/// Carries an [`AssertResult`] under the hood and exposes the same
/// `outcome()` / `outcomes` / `stats` shape on completion. Build via
/// [`Verdict::new`] or [`Assert::verdict`]; finish with
/// [`Verdict::into_result`] (a bound local, zero-clone) or
/// [`Verdict::to_result`] (a fluent chain off a temporary).
///
/// ```
/// # use ktstr::assert::{Assert, Verdict};
/// // Empty verdict — no claims, passes.
/// let v = Assert::default_checks().verdict();
/// let r = v.into_result();
/// assert!(r.is_pass());
/// ```
#[must_use = "Verdict accumulates claims; call into_result() (bound local) or to_result() (fluent chain) to consume"]
#[derive(Debug, Clone)]
pub struct Verdict {
    /// Threshold config the verdict was opened against. Carried so
    /// downstream merge sites (e.g. retrofitted `assert_not_starved`)
    /// can read thresholds out of the verdict directly without an
    /// extra parameter. Optional because [`Verdict::new`] does not
    /// require an `Assert`; tests that only run pointwise claims
    /// don't need a threshold layer attached.
    assert: Option<Assert>,
    /// Underlying [`AssertResult`] envelope — claim records and merged
    /// upstream results write into this. Initialized as a passing
    /// result at `new`; mutates as comparators record outcomes.
    result: AssertResult,
    /// When true, every comparator's pass arm emits a
    /// `tracing::info!` event naming the claim and the compared
    /// values. Off by default so the no-allocation pass path stays
    /// allocation-free under normal runs; flipped on via
    /// [`Self::with_log_passes`] (or the
    /// `KTSTR_LOG_PASSES` env var, read once at [`Self::new`] time)
    /// when a test author wants positive confirmation of every
    /// compared value in `--nocapture` output. The pass-message
    /// formatting is gated behind the flag, so the `format!` cost
    /// is paid only on the explicit opt-in.
    log_passes: bool,
}

impl Default for Verdict {
    /// Equivalent to [`Verdict::new`]: no threshold config attached, no
    /// claims accumulated.
    fn default() -> Self {
        Self::new()
    }
}

impl Verdict {
    /// Empty accumulator with no [`Assert`] attached. Use when a test
    /// is composing pointwise claims and does not care about the
    /// scheduler-level threshold layer.
    pub fn new() -> Self {
        Self {
            assert: None,
            result: AssertResult::pass(),
            log_passes: log_passes_default(),
        }
    }

    /// Empty accumulator carrying `assert` for downstream lookup.
    /// Reached via [`Assert::verdict`]; not part of the test author's
    /// usual entry point.
    pub fn with_assert(assert: Assert) -> Self {
        Self {
            assert: Some(assert),
            result: AssertResult::pass(),
            log_passes: log_passes_default(),
        }
    }

    /// Toggle positive-confirmation logging. When `on`, every
    /// comparator's pass arm emits a `tracing::info!` event naming
    /// the claim and the values compared, visible to operators
    /// running tests with `--nocapture`. Off by default — the
    /// pass path stays allocation-free.
    ///
    /// Initial value reads `KTSTR_LOG_PASSES` (any value other than
    /// `""` or `"0"` enables) so a debugging session can flip the
    /// flag from the shell without touching test source.
    /// Programmatic override always wins over the env var; chain
    /// `.with_log_passes(false)` to silence a single verdict even
    /// when the env var is set.
    pub fn with_log_passes(mut self, on: bool) -> Self {
        self.log_passes = on;
        self
    }

    /// Read the current positive-confirmation logging flag.
    pub fn log_passes(&self) -> bool {
        self.log_passes
    }

    /// Open a per-claim builder for the named `(subject, value)` pair.
    /// `name` is `&'static str` because the only legitimate sources for
    /// a claim label are `stringify!(field)` (from the
    /// [`#[derive(Claim)]`](ktstr_macros::Claim) generator) and
    /// `stringify!(expr)` (from the [`claim!`](crate::claim) macro) —
    /// both produce static string slices.
    ///
    /// Public so the `#[derive(Claim)]` generator and the `claim!`
    /// macro can dispatch through it; not the recommended user-facing
    /// entry point. Hand-typing `verdict.claim("subject", value)` is
    /// disallowed: a manual string can drift from the value it
    /// labels (rename a field, leave the literal stale), so labels
    /// must originate from `stringify!(field)` or `stringify!(expr)`
    /// via the derive or the macro.
    #[doc(hidden)]
    pub fn claim<T>(&mut self, name: &'static str, value: T) -> ClaimBuilder<'_, T> {
        ClaimBuilder {
            verdict: self,
            name,
            value,
            kind: DetailKind::Other,
            reason: None,
        }
    }

    /// Open a per-claim builder for a set-typed value. Same naming
    /// rules as [`Self::claim`]; the [`#[derive(Claim)]`](ktstr_macros::Claim)
    /// generator emits accessors that route through this dispatch
    /// helper for `BTreeSet<T>` fields.
    #[doc(hidden)]
    pub fn claim_set<'a, T: Ord + std::fmt::Debug>(
        &'a mut self,
        name: &'static str,
        set: &'a BTreeSet<T>,
    ) -> SetClaim<'a, T> {
        SetClaim {
            verdict: self,
            name,
            value: set,
            kind: DetailKind::Other,
            reason: None,
        }
    }

    /// Open a per-claim builder for a sequence-typed value. Same naming
    /// rules as [`Self::claim`]; the [`#[derive(Claim)]`](ktstr_macros::Claim)
    /// generator emits accessors that route through this dispatch
    /// helper for `Vec<T>` and slice fields.
    #[doc(hidden)]
    pub fn claim_seq<'a, T: std::fmt::Debug>(
        &'a mut self,
        name: &'static str,
        seq: &'a [T],
    ) -> SeqClaim<'a, T> {
        SeqClaim {
            verdict: self,
            name,
            value: seq,
            kind: DetailKind::Other,
            reason: None,
        }
    }

    /// Open a presence-checked claim for a possibly-absent metric.
    ///
    /// Use this on the direct-claim path when the value comes from an
    /// `Option`-returning lookup (e.g. `VmResult::phase_metric` or a
    /// `ScenarioStats` accessor): a `None` (the metric was never
    /// produced) records a LOUD `Fail` rather than panicking on
    /// `.unwrap()` or silently passing a sentinel from
    /// `.unwrap_or(0.0)`. On `Some(v)` the returned [`PresentClaim`]
    /// behaves exactly like [`Self::claim`].
    ///
    /// Public so the [`claim_present!`](crate::claim_present) macro can
    /// dispatch through it; prefer the macro so the label is
    /// `stringify!`-derived (same `&'static str` discipline as
    /// [`Self::claim`]).
    #[doc(hidden)]
    pub fn claim_present(&mut self, name: &'static str, value: Option<f64>) -> PresentClaim<'_> {
        PresentClaim {
            verdict: self,
            name,
            value,
            kind: DetailKind::Other,
            reason: None,
        }
    }

    /// Append an informational note to the underlying [`AssertResult`]
    /// without altering the verdict. Mirrors [`AssertResult::note`].
    pub fn note(&mut self, msg: impl Into<String>) -> &mut Self {
        self.result.note(msg);
        self
    }

    /// Attach a structured `(key, value)` measurement to the
    /// underlying [`AssertResult::measurements`] map without
    /// altering the verdict. Mirrors [`AssertResult::note_value`].
    /// Use when a test wants to surface a programmatically
    /// consumable measurement (e.g. `verdict.note_value("max_wchar",
    /// 12345i64)`) alongside pass/fail claims, so sidecar parsers
    /// and `perf-delta` dashboards can read the typed value
    /// without re-grepping `details`.
    pub fn note_value(&mut self, key: impl Into<String>, value: impl Into<NoteValue>) -> &mut Self {
        self.result.note_value(key, value);
        self
    }

    /// Mark the verdict as skipped with the supplied reason. Pushes
    /// one [`Outcome::Skip`] carrying a [`DetailKind::Skip`] detail
    /// onto the outcome stream. Use when a precondition is missing
    /// and the scenario cannot run.
    ///
    /// Prior outcomes are preserved: a verdict whose earlier claim
    /// failed and then transitions to skip stays failed. The merge
    /// lattice (`Fail > Inconclusive > Pass > Skip`) keeps any
    /// recorded [`Outcome::Fail`] dominant, so once a claim records
    /// a real failure, a later skip cannot mask it. Distinct from
    /// [`AssertResult::skip`], which is a CONSTRUCTOR producing a
    /// fresh skipped envelope from no prior state.
    pub fn skip(&mut self, reason: impl Into<String>) -> &mut Self {
        self.result.record_skip(reason);
        self
    }

    /// Conditional skip: skip with `reason` when `cond` is true; no-op
    /// otherwise. Convenience over `if cond { v.skip(reason); }`.
    pub fn skip_if(&mut self, cond: bool, reason: impl Into<String>) -> &mut Self {
        if cond {
            self.skip(reason);
        }
        self
    }

    /// Mark the verdict as inconclusive with the supplied detail.
    /// Pushes one [`Outcome::Inconclusive`] carrying `detail` onto
    /// the outcome stream. Use when the gate APPLIED (preconditions
    /// were met) but the signal needed to evaluate it was absent —
    /// e.g. a ratio whose denominator is INSTRUMENT-derived and
    /// happened to be zero (no migrations observed), so the
    /// comparison cannot be performed and the verdict is neither
    /// pass nor fail.
    ///
    /// Boundary with [`Self::skip`]: Inconclusive = the gate
    /// applied but the signal was absent; Skip = the gate's
    /// precondition was unmet so the gate did not apply at all.
    /// Boundary with comparator-driven `record_fail`: Inconclusive
    /// = the denominator is INSTRUMENT-derived (a measurement that
    /// happened to be zero); Fail = the denominator is
    /// POLICY-derived (a configured expectation that must hold —
    /// see the [`Outcome`] doc's `MemPolicy::Bind` carve-out).
    ///
    /// Prior outcomes are preserved: a verdict whose earlier claim
    /// failed and then transitions to inconclusive stays failed
    /// per the merge lattice (`Fail > Inconclusive > Pass > Skip`).
    pub fn inconclusive(&mut self, detail: AssertDetail) -> &mut Self {
        self.result.record_inconclusive(detail);
        self
    }

    /// Conditional inconclusive: record `detail` when `cond` is
    /// true; no-op otherwise. Convenience over
    /// `if cond { v.inconclusive(detail); }`. Mirrors
    /// [`Self::skip_if`].
    pub fn inconclusive_if(&mut self, cond: bool, detail: AssertDetail) -> &mut Self {
        if cond {
            self.inconclusive(detail);
        }
        self
    }

    /// Fold an external [`AssertResult`] into this verdict. Useful when
    /// a test combines pointwise claims with the result of an upstream
    /// `assert_*` call (e.g. `assert_not_starved`). Mirrors
    /// [`AssertResult::merge`] semantics — `other.outcomes` are
    /// appended to this verdict's outcome stream, so the merge
    /// lattice (`Fail > Inconclusive > Pass > Skip`) folds across
    /// both sides; notes are concatenated, measurements are folded as
    /// a keyed union (other's keys overwrite self's on collision);
    /// aggregate stats adopt the worst per dimension.
    pub fn merge(&mut self, other: AssertResult) -> &mut Self {
        self.result.merge(other);
        self
    }

    /// True iff the folded outcome is [`Outcome::Pass`]. Per
    /// [`AssertResult::is_pass`], this requires no recorded
    /// `Fail`, no recorded `Inconclusive`, AND the outcome
    /// stream is either empty or contains at least one
    /// non-`Skip` claim. An empty verdict (no claims recorded)
    /// folds to Pass; an all-`Skip` stream returns `false`
    /// here — the verdict didn't fail, but it also didn't
    /// actually run. Read-only. Name matches the rest of the
    /// 4-state vocabulary across [`AssertResult::is_pass`] /
    /// [`crate::test_support::SidecarResult::is_pass`] /
    /// [`Outcome::is_pass`] / [`Self::is_pass`] /
    /// `MonitorVerdict::is_pass` (in the `monitor` module,
    /// which is `pub(crate)`) / `GauntletRow::is_pass` (in
    /// the `stats` module, which is `pub(crate)`). The methods
    /// are `pub`; only the containing modules are `pub(crate)`,
    /// so the bare rustdoc links don't resolve from this pub
    /// item — the code spans on those two are bare to avoid a
    /// private intra-doc-link warning.
    ///
    /// Note: a verdict that recorded one or more [`Outcome::Inconclusive`]
    /// outcomes returns `false` here even though it did not record a
    /// hard failure. Use [`Verdict::into_result`] + [`AssertResult::outcome`]
    /// when callers need to distinguish Pass from Inconclusive from Fail.
    pub fn is_pass(&self) -> bool {
        self.result.is_pass()
    }

    /// True iff any recorded outcome is [`Outcome::Fail`]. Mirrors
    /// [`AssertResult::is_fail`] — any Fail in the outcome stream
    /// dominates under the `Fail > Inconclusive > Pass > Skip`
    /// lattice, so a single failed claim makes this true regardless
    /// of how many later inconclusive or pass claims followed.
    pub fn is_fail(&self) -> bool {
        self.result.is_fail()
    }

    /// True iff any recorded outcome is [`Outcome::Inconclusive`] and
    /// no [`Outcome::Fail`] was recorded. Mirrors
    /// [`AssertResult::is_inconclusive`]. Fail dominates: a verdict
    /// with both Fail and Inconclusive outcomes returns false here
    /// and true from [`Self::is_fail`].
    pub fn is_inconclusive(&self) -> bool {
        self.result.is_inconclusive()
    }

    /// True iff the outcome stream is non-empty and every recorded
    /// outcome is [`Outcome::Skip`]. Mirrors [`AssertResult::is_skip`].
    /// An empty verdict (the merge identity) returns false here — it
    /// is reported as Pass via [`Self::is_pass`], not Skip.
    pub fn is_skip(&self) -> bool {
        self.result.is_skip()
    }

    /// Number of recorded outcomes carrying a payload
    /// (Fail + Inconclusive + Skip) — i.e. recorded diagnostics so
    /// far. Pass markers don't count.
    pub fn detail_count(&self) -> usize {
        self.result
            .outcomes
            .iter()
            .filter(|o| !matches!(o, Outcome::Pass))
            .count()
    }

    /// Read the [`Assert`] threshold config attached at construction
    /// time, if any. `None` when the verdict was built via
    /// [`Verdict::new`] (no threshold layer).
    pub fn assert(&self) -> Option<&Assert> {
        self.assert.as_ref()
    }

    /// Consume the accumulator and return the merged [`AssertResult`].
    /// Terminal for a BOUND verdict — zero-clone, takes `self`.
    ///
    /// ```
    /// # use ktstr::assert::Verdict;
    /// let mut v = Verdict::new();
    /// v.claim("answer", 42u64).at_least(40);
    /// let r = v.into_result();
    /// assert!(r.is_pass());
    /// ```
    ///
    /// Cannot terminate a fluent chain off a temporary: the comparators
    /// return `&mut Verdict`, so `Verdict::new().claim(..).at_least(..)`
    /// yields a borrow, and `into_result(self)` cannot move the temporary
    /// out from under it (E0507). Use [`Self::to_result`] for the fluent
    /// one-liner.
    pub fn into_result(self) -> AssertResult {
        self.result
    }

    /// Clone out the merged [`AssertResult`] from `&self` — the terminal
    /// for a FLUENT chain. The comparators return `&mut Verdict`, so a
    /// chain built on a temporary (`Verdict::new().claim(..).at_least(..)`)
    /// only ever has a borrow at the end; [`Self::into_result`] would have
    /// to move the temporary out from under that borrow (E0507). This
    /// borrows instead and clones, so the one-liner compiles:
    ///
    /// ```
    /// # use ktstr::assert::Verdict;
    /// let r = Verdict::new()
    ///     .claim("answer", 42u64)
    ///     .at_least(40)
    ///     .to_result();
    /// assert!(r.is_pass());
    /// ```
    ///
    /// Prefer [`Self::into_result`] when the verdict is already a bound
    /// local (it consumes, no clone). The clone here is a test-path cost
    /// (claim records are bounded by [`super::MAX_RECORDED_PASSES`]).
    pub fn to_result(&self) -> AssertResult {
        self.result.clone()
    }

    /// Terminal post_vm-callback helper. Equivalent to
    /// `self.into_result().into_anyhow_or_log()` — drains the
    /// accumulator, logs every [`crate::assert::InfoNote`] via
    /// `tracing::info!`, and bails on any accumulated failure
    /// (with all failure details concatenated). See
    /// [`AssertResult::into_anyhow_or_log`] for the full contract.
    ///
    /// Use this to collapse a typical post_vm callback's terminal
    /// `let r = v.into_result(); … bail/log loop` boilerplate
    /// into a single chainable call.
    pub fn into_anyhow_or_log(self) -> anyhow::Result<()> {
        self.into_result().into_anyhow_or_log()
    }

    /// Mutable handle to the underlying [`AssertResult`]. Used by
    /// the [`crate::assert::temporal`] patterns to push
    /// [`DetailKind::Temporal`] details and flip `passed` without
    /// going through the comparator surface (which is shaped for
    /// scalar `(name, value)` claims, not per-sample multi-failure
    /// pile-on). Crate-internal — out-of-tree callers use
    /// [`Self::merge`] / [`Self::note`] / [`Self::note_value`].
    pub(crate) fn result_mut(&mut self) -> &mut AssertResult {
        &mut self.result
    }

    /// Read-only handle to the underlying [`AssertResult`]. Used by
    /// the [`crate::assert::temporal`] patterns to count
    /// [`DetailKind::Temporal`] failures around a pattern's main
    /// loop so positive-confirmation logging can gate on
    /// "this pattern call added zero failures" (notes do not count
    /// against the gate — a pattern that skipped projection errors
    /// but otherwise passed every sample still emits the positive
    /// log). Crate-internal mirror of [`Self::result_mut`].
    pub(crate) fn result(&self) -> &AssertResult {
        &self.result
    }

    /// Internal: record one claim outcome (pass = no detail, fail =
    /// push detail with `kind` and conjoin `passed`). Shared by every
    /// comparator on every builder type.
    fn record(&mut self, outcome: ClaimOutcome) {
        if let ClaimOutcome::Fail { kind, message } = outcome {
            self.result.record_fail(AssertDetail::new(kind, message));
        }
    }

    /// Record a passing claim as both a structured
    /// [`PassDetail`](super::PassDetail) on `result.passes` AND a
    /// `tracing::info!` event (gated on [`Self::log_passes`]).
    ///
    /// The structured push is unconditional: every comparator's pass
    /// arm contributes one [`super::PassDetail`] to the result so the
    /// auto-repro renderer (and any other consumer that wants per-
    /// claim fidelity) can iterate passing assertions alongside
    /// failing ones without re-running the comparators.
    ///
    /// The tracing event remains gated on `log_passes` so the
    /// `--nocapture` operator surface stays clean by default; only
    /// the structured push is universal.
    fn record_pass_binary(
        &mut self,
        name: &str,
        comparator: impl Into<std::borrow::Cow<'static, str>>,
        value: impl std::fmt::Display,
        expected: impl std::fmt::Display,
    ) {
        self.record_pass_inner(
            name,
            comparator.into(),
            value.to_string(),
            Some(expected.to_string()),
        );
    }

    fn record_pass_unary(
        &mut self,
        name: &str,
        comparator: impl Into<std::borrow::Cow<'static, str>>,
        value: impl std::fmt::Display,
    ) {
        self.record_pass_inner(name, comparator.into(), value.to_string(), None);
    }

    fn record_pass_inner(
        &mut self,
        name: &str,
        comparator: std::borrow::Cow<'static, str>,
        value: String,
        expected: Option<String>,
    ) {
        // Catch unregistered comparator tokens at debug-build runtime.
        // Vocabulary count + membership guards in
        // tests/claim_comparator_tokens_canonical.rs catch the
        // forgot-to-update-the-vocab case at test-collection time;
        // this debug_assert catches it inside any test that exercises
        // the new comparator path (zero release-build cost).
        debug_assert!(
            super::COMPARATOR_VOCABULARY.contains(&comparator.as_ref())
                || comparator == super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
            "comparator token {comparator:?} not in COMPARATOR_VOCABULARY \
             — add it to the const slice + regression test before shipping"
        );
        if self.log_passes {
            match expected.as_ref() {
                Some(exp) => tracing::info!(
                    target: "ktstr::assert::claim",
                    "{name}: {value} {comparator} {exp}"
                ),
                None => tracing::info!(
                    target: "ktstr::assert::claim",
                    "{name}: {value} {comparator}"
                ),
            }
        }
        // Cap + truncation sentinel: once `MAX_RECORDED_PASSES` real
        // records have been stored, one sentinel record naming the
        // dropped-count is appended (pushing the vec to
        // `MAX_RECORDED_PASSES + 1`); further pushes become no-ops.
        // The cap bounds the wire-formatted AssertResult so a
        // pathological test firing millions of claims can't blow
        // past `MAX_BULK_FRAME_PAYLOAD`. Sentinel pattern mirrors
        // `SnapshotBridgeEvent::EventLogTruncated`.
        let len = self.result.passes.len();
        if len < super::MAX_RECORDED_PASSES {
            let detail = match expected {
                Some(exp) => super::PassDetail::binary(name, comparator, value, exp),
                None => super::PassDetail::unary(name, comparator, value),
            };
            self.result.passes.push(detail);
        } else if len == super::MAX_RECORDED_PASSES {
            self.result.passes.push(super::PassDetail::unary(
                super::PASSES_TRUNCATION_SENTINEL_NAME,
                super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
                format!("cap={}", super::MAX_RECORDED_PASSES),
            ));
        }
        // len > MAX_RECORDED_PASSES: drop on the floor — sentinel is
        // already published.
    }
}

/// Outcome of a single comparator. `Pass` is the no-allocation arm;
/// `Fail` carries the formatted failure message and its [`DetailKind`].
enum ClaimOutcome {
    Pass,
    Fail { kind: DetailKind, message: String },
}

/// Per-claim builder for scalar values. Produced by [`Verdict::claim`]
/// (and the typed `claim_<field>` accessors generated by
/// [`#[derive(Claim)]`](ktstr_macros::Claim)). Chain a comparator
/// (`at_least`, `at_most`, `lt`, `gt`, `between`, `eq`, `ne`,
/// `is_finite`, `near`) to record the outcome.
#[must_use = "ClaimBuilder records nothing until a comparator is invoked"]
pub struct ClaimBuilder<'a, T> {
    verdict: &'a mut Verdict,
    name: &'static str,
    value: T,
    kind: DetailKind,
    reason: Option<&'a str>,
}

impl<'a, T> ClaimBuilder<'a, T> {
    /// Override the [`DetailKind`] used on failure. Defaults to
    /// [`DetailKind::Other`]; tests with a structural category set it
    /// here so downstream `kind`-based filters route the failure
    /// correctly.
    pub fn kind(mut self, kind: DetailKind) -> Self {
        self.kind = kind;
        self
    }

    /// Attach a human-readable rationale appended to the failure
    /// message as `(reason)`. No effect on the pass path. Use to
    /// record *why* the bound matters so a regression two months
    /// later carries original intent in its diagnostic.
    pub fn because(mut self, reason: &'a str) -> Self {
        self.reason = Some(reason);
        self
    }
}

/// Append the optional `reason` to a failure message, returning the
/// final formatted string. Centralized so every comparator's failure
/// arm shares one definition of "( reason )" appendix formatting.
fn append_reason(msg: String, reason: Option<&str>) -> String {
    match reason {
        Some(r) => format!("{msg} ({r})"),
        None => msg,
    }
}

impl<'a, T> ClaimBuilder<'a, T>
where
    T: PartialEq + std::fmt::Display,
{
    /// Pass when `value == expected`; fail with
    /// `<name>: expected <expected>, was <value>` otherwise.
    ///
    /// **NaN gotcha** (only relevant for `T = f64`): IEEE 754 says
    /// `NaN == NaN` is `false`. Use `is_finite` or
    /// [`claim!`](crate::claim) on `value.is_nan()` instead.
    pub fn eq(self, expected: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value == expected {
            verdict.record_pass_binary(name, "eq", value, expected);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected {expected}, was {value}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `value != forbidden`; fail with
    /// `<name>: expected != <forbidden>, was <value>` otherwise.
    pub fn ne(self, forbidden: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value != forbidden {
            verdict.record_pass_binary(name, "ne", value, forbidden);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected != {forbidden}, was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }
}

impl<'a, T> ClaimBuilder<'a, T>
where
    T: PartialOrd + std::fmt::Display,
{
    /// Pass when `value >= floor`; fail with `<name>: expected at
    /// least <floor>, was <value>` otherwise.
    pub fn at_least(self, floor: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value >= floor {
            verdict.record_pass_binary(name, "ge", value, floor);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected at least {floor}, was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `value <= ceiling`; fail with `<name>: expected at
    /// most <ceiling>, was <value>` otherwise.
    pub fn at_most(self, ceiling: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value <= ceiling {
            verdict.record_pass_binary(name, "le", value, ceiling);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected at most {ceiling}, was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `value < ceiling`; fail otherwise.
    pub fn lt(self, ceiling: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value < ceiling {
            verdict.record_pass_binary(name, "lt", value, ceiling);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected less than {ceiling}, was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `value > floor`; fail otherwise.
    pub fn gt(self, floor: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value > floor {
            verdict.record_pass_binary(name, "gt", value, floor);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected greater than {floor}, was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `lo <= value <= hi` (inclusive on both ends). Fails
    /// with caller-error message when `lo > hi`.
    pub fn between(self, lo: T, hi: T) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if lo > hi {
            let msg = append_reason(
                format!("{name}: caller error: interval inverted (lo={lo} > hi={hi})"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        } else if value >= lo && value <= hi {
            verdict.record_pass_binary(name, "in_range", value, format_args!("[{lo}, {hi}]"));
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected in [{lo}, {hi}], was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }
}

impl<'a> ClaimBuilder<'a, f64> {
    /// Pass when the value is finite (neither NaN nor ±Infinity).
    pub fn is_finite(self) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value.is_finite() {
            verdict.record_pass_unary(name, "is_finite", value);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected finite, was {value}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass when `(value - target).abs() <= tolerance`. `value ==
    /// target` short-circuits as a pass before the subtraction so
    /// `±inf == ±inf` reads as a match. Negative `tolerance` is a
    /// caller bug — the failure message names it.
    pub fn near(self, target: f64, tolerance: f64) -> &'a mut Verdict {
        let ClaimBuilder {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if tolerance < 0.0 {
            let msg = append_reason(
                format!("{name}: caller error: tolerance negative (={tolerance})"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        } else if value == target || (value - target).abs() <= tolerance {
            verdict.record_pass_binary(
                name,
                "near_within",
                value,
                format_args!("{target} (±{tolerance})"),
            );
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected near {target} (±{tolerance}), was {value}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }
}

/// Per-claim builder for a possibly-absent (`Option`-valued) metric.
/// Produced by [`Verdict::claim_present`] and the
/// [`claim_present!`](crate::claim_present) macro. On `Some(v)` every
/// comparator delegates to the value-bound [`ClaimBuilder`], so a
/// present metric behaves EXACTLY like [`claim!`](crate::claim). On
/// `None` the metric is absent — there is nothing to compare, so every
/// comparator records a LOUD `Fail` (`<name>: metric absent`) rather
/// than silently passing. This mirrors the no-guessed-value discipline
/// of `MetricId::def` returning `None` for an unregistered key: an
/// absent signal fails the gate, it does not vacuously satisfy it (the
/// hazard of `metric.unwrap_or(0.0)`, where a missing metric becomes a
/// sentinel that can pass a bound).
#[must_use = "PresentClaim records nothing until a comparator is invoked"]
pub struct PresentClaim<'a> {
    verdict: &'a mut Verdict,
    name: &'static str,
    value: Option<f64>,
    kind: DetailKind,
    reason: Option<&'a str>,
}

impl<'a> PresentClaim<'a> {
    /// Override the [`DetailKind`] recorded on failure (whether the
    /// metric is absent OR present-but-out-of-bound). Mirrors
    /// [`ClaimBuilder::kind`].
    pub fn kind(mut self, kind: DetailKind) -> Self {
        self.kind = kind;
        self
    }

    /// Attach a rationale appended to the failure message as
    /// `(reason)`. Mirrors [`ClaimBuilder::because`].
    pub fn because(mut self, reason: &'a str) -> Self {
        self.reason = Some(reason);
        self
    }

    /// Record the absent-metric `Fail`. Shared by every comparator's
    /// `None` arm.
    fn record_absent(
        verdict: &'a mut Verdict,
        name: &str,
        kind: DetailKind,
        reason: Option<&str>,
    ) -> &'a mut Verdict {
        let msg = append_reason(
            format!("{name}: metric absent (no value to compare)"),
            reason,
        );
        verdict.record(ClaimOutcome::Fail { kind, message: msg });
        verdict
    }

    /// Build the value-bound [`ClaimBuilder`] for the present case,
    /// carrying the `kind` / `because` modifiers forward. Shared by
    /// every comparator's `Some` arm so the present path is
    /// behavior-identical to [`Verdict::claim`].
    fn present(
        verdict: &'a mut Verdict,
        name: &'static str,
        value: f64,
        kind: DetailKind,
        reason: Option<&'a str>,
    ) -> ClaimBuilder<'a, f64> {
        let mut cb = verdict.claim(name, value).kind(kind);
        if let Some(r) = reason {
            cb = cb.because(r);
        }
        cb
    }

    /// Present and `>= floor`; absent → `Fail`. See
    /// [`ClaimBuilder::at_least`].
    pub fn at_least(self, floor: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).at_least(floor),
        }
    }

    /// Present and `<= ceiling`; absent → `Fail`. See
    /// [`ClaimBuilder::at_most`].
    pub fn at_most(self, ceiling: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).at_most(ceiling),
        }
    }

    /// Present and `< ceiling`; absent → `Fail`. See [`ClaimBuilder::lt`].
    pub fn lt(self, ceiling: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).lt(ceiling),
        }
    }

    /// Present and `> floor`; absent → `Fail`. See [`ClaimBuilder::gt`].
    pub fn gt(self, floor: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).gt(floor),
        }
    }

    /// Present and within `[lo, hi]` (inclusive); absent → `Fail`. See
    /// [`ClaimBuilder::between`].
    pub fn between(self, lo: f64, hi: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).between(lo, hi),
        }
    }

    /// Present and within `tolerance` of `target`; absent → `Fail`. See
    /// [`ClaimBuilder::near`].
    pub fn near(self, target: f64, tolerance: f64) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).near(target, tolerance),
        }
    }

    /// Present and finite (neither NaN nor ±∞); absent → `Fail`. See
    /// [`ClaimBuilder::is_finite`].
    pub fn is_finite(self) -> &'a mut Verdict {
        let PresentClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        match value {
            None => Self::record_absent(verdict, name, kind, reason),
            Some(v) => Self::present(verdict, name, v, kind, reason).is_finite(),
        }
    }
}

/// Per-claim builder for `BTreeSet<T>` values. Same `kind` / `because`
/// modifiers as [`ClaimBuilder`]; comparator surface is set-specific.
#[must_use = "SetClaim records nothing until a comparator is invoked"]
pub struct SetClaim<'a, T: Ord + std::fmt::Debug> {
    verdict: &'a mut Verdict,
    name: &'static str,
    value: &'a BTreeSet<T>,
    kind: DetailKind,
    reason: Option<&'a str>,
}

impl<'a, T: Ord + std::fmt::Debug> SetClaim<'a, T> {
    pub fn kind(mut self, kind: DetailKind) -> Self {
        self.kind = kind;
        self
    }
    pub fn because(mut self, reason: &'a str) -> Self {
        self.reason = Some(reason);
        self
    }

    /// Pass iff the set is empty.
    pub fn empty(self) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value.is_empty() {
            verdict.record_pass_unary(name, "set_is_empty", "");
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff the set is non-empty.
    pub fn nonempty(self) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if !value.is_empty() {
            let len = value.len();
            verdict.record_pass_unary(name, "set_is_non_empty", len);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff the set contains `needle`.
    pub fn contains(self, needle: &T) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value.contains(needle) {
            // Pass-actual is the set's length — naming the haystack
            // size at pass time without dumping the full set into
            // PassDetail::value (pass records are bounded by
            // MAX_RECORDED_PASSES so we keep the payload compact, and
            // the analogous `set_len_eq` comparator already publishes
            // len-as-actual so consumers see a uniform shape across
            // the set_* family). Prior emission used the empty string
            // — that left consumers with no haystack signal at all,
            // forcing them to re-derive the set context from the
            // surrounding test setup.
            verdict.record_pass_binary(
                name,
                "set_contains",
                value.len(),
                format_args!("{needle:?}"),
            );
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected to contain {needle:?}, set was {value:?}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `set.len() == n`.
    pub fn len_eq(self, n: usize) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual == n {
            verdict.record_pass_binary(name, "set_len_eq", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `set.len() <= n`.
    pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual <= n {
            verdict.record_pass_binary(name, "set_len_le", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `set.len() >= n`.
    pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual >= n {
            verdict.record_pass_binary(name, "set_len_ge", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff every element of `value` is in `whitelist`. Fails with
    /// the offending elements listed.
    pub fn subset_of(self, whitelist: &BTreeSet<T>) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let bad: Vec<&T> = value.iter().filter(|x| !whitelist.contains(x)).collect();
        let outcome = if bad.is_empty() {
            verdict.record_pass_binary(
                name,
                "subset_of",
                format_args!("{value:?}"),
                format_args!("{whitelist:?}"),
            );
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected subset of {whitelist:?}, but {bad:?} are not in the set"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `value` shares no element with `forbidden`. Fails with
    /// the offending elements listed.
    pub fn disjoint_from(self, forbidden: &BTreeSet<T>) -> &'a mut Verdict {
        let SetClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let bad: Vec<&T> = value.iter().filter(|x| forbidden.contains(x)).collect();
        let outcome = if bad.is_empty() {
            verdict.record_pass_binary(
                name,
                "disjoint_from",
                format_args!("{value:?}"),
                format_args!("{forbidden:?}"),
            );
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!(
                    "{name}: expected disjoint from {forbidden:?}, but {bad:?} are present in both"
                ),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }
}

/// Per-claim builder for slice / `Vec<T>` values. Same modifier
/// surface as [`SetClaim`]; comparator surface is sequence-specific.
#[must_use = "SeqClaim records nothing until a comparator is invoked"]
pub struct SeqClaim<'a, T: std::fmt::Debug> {
    verdict: &'a mut Verdict,
    name: &'static str,
    value: &'a [T],
    kind: DetailKind,
    reason: Option<&'a str>,
}

impl<'a, T: std::fmt::Debug> SeqClaim<'a, T> {
    pub fn kind(mut self, kind: DetailKind) -> Self {
        self.kind = kind;
        self
    }
    pub fn because(mut self, reason: &'a str) -> Self {
        self.reason = Some(reason);
        self
    }

    /// Pass iff the sequence is empty.
    pub fn empty(self) -> &'a mut Verdict {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value.is_empty() {
            verdict.record_pass_unary(name, "sequence_is_empty", "");
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff the sequence is non-empty.
    pub fn nonempty(self) -> &'a mut Verdict {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if !value.is_empty() {
            let len = value.len();
            verdict.record_pass_unary(name, "sequence_is_non_empty", len);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff the sequence contains an element equal to `needle`.
    pub fn contains(self, needle: &T) -> &'a mut Verdict
    where
        T: PartialEq,
    {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let outcome = if value.iter().any(|x| x == needle) {
            // Pass-actual is the sequence length — mirrors the
            // `sequence_len_eq` comparator's len-as-actual emission
            // and the parallel `set_contains` rationale above
            // (compact, uniform, gives consumers a haystack signal).
            verdict.record_pass_binary(
                name,
                "sequence_contains",
                value.len(),
                format_args!("{needle:?}"),
            );
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(
                format!("{name}: expected to contain {needle:?}, sequence was {value:?}"),
                reason,
            );
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `seq.len() == n`.
    pub fn len_eq(self, n: usize) -> &'a mut Verdict {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual == n {
            verdict.record_pass_binary(name, "sequence_len_eq", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `seq.len() <= n`.
    pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual <= n {
            verdict.record_pass_binary(name, "sequence_len_le", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }

    /// Pass iff `seq.len() >= n`.
    pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
        let SeqClaim {
            verdict,
            name,
            value,
            kind,
            reason,
        } = self;
        let actual = value.len();
        let outcome = if actual >= n {
            verdict.record_pass_binary(name, "sequence_len_ge", actual, n);
            ClaimOutcome::Pass
        } else {
            let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
            ClaimOutcome::Fail { kind, message: msg }
        };
        verdict.record(outcome);
        verdict
    }
}

/// Open a [`Verdict`] claim from a local binding or expression. The
/// label is `stringify!(<expr-tokens>)` so a regression that renames
/// the binding or alters the expression updates the rendered failure
/// message in lock-step.
///
/// Use as `claim!(verdict, my_local).at_most(N)` or
/// `claim!(verdict, end - start).at_least(N)`. For struct fields under
/// a `#[derive(Claim)]`, prefer the typed accessor
/// (`verdict.claim_<field>(s)`) — the macro is the escape hatch when
/// no derived accessor exists.
///
/// Expanded form: `verdict.claim(stringify!(expr), expr)`. The macro
/// is a thin wrapper so `Verdict::claim`'s `'static` label invariant
/// holds (string literals from `stringify!` always have `'static`
/// lifetime).
///
/// ```
/// # use ktstr::{assert::Verdict, claim};
/// let mut v = Verdict::new();
/// let answer = 42u64;
/// claim!(v, answer).at_least(40);
/// claim!(v, answer * 2).at_most(100);
/// let r = v.into_result();
/// assert!(r.is_pass());
/// ```
#[macro_export]
macro_rules! claim {
    ($verdict:expr, $value:expr) => {
        $verdict.claim(stringify!($value), $value)
    };
}

/// Presence-checked sibling of [`claim!`] for a possibly-absent metric.
///
/// Expands to [`Verdict::claim_present`](crate::assert::Verdict::claim_present)
/// with a `stringify!`-derived label (the same `&'static str` discipline
/// as [`claim!`]). The argument is an `Option`-valued expression (e.g. a
/// `phase_metric` lookup); a `None` records a LOUD `Fail`
/// (`<expr>: metric absent`), a `Some(v)` behaves exactly like [`claim!`].
///
/// ```
/// # use ktstr::{assert::Verdict, claim_present};
/// let mut v = Verdict::new();
/// let throughput: Option<f64> = Some(1500.0);
/// claim_present!(v, throughput).at_least(1000.0);
/// let missing: Option<f64> = None;
/// claim_present!(v, missing).at_least(1.0); // records a Fail, not a panic
/// let r = v.into_result();
/// assert!(!r.is_pass()); // the absent metric failed the verdict
/// ```
#[macro_export]
macro_rules! claim_present {
    ($verdict:expr, $value:expr) => {
        $verdict.claim_present(stringify!($value), $value)
    };
}
