//! Metamorphic (oracle-free) relations over tool calls.
//!
//! Why this module exists: every matcher we ship needs a known expected value
//! (`exact`, `contains`, `schema`, `regex`, `similar`, `levenshtein`) or a
//! model verdict (`llm-judge`). A search, a summarizer, or a recommender has no
//! stable golden output, so today those tools go untested or get pushed onto an
//! expensive judge. Metamorphic testing removes the golden-output requirement:
//! it asserts a *relation between calls* (a property that must hold between the
//! output of a base call and the output of a transformed call) whatever the
//! actual values are. This is the standard answer to the test-oracle problem,
//! brought to MCP tools by ARMeta (arXiv:2605.28321) and LLMORPH
//! (arXiv:2603.23611). See `docs/metamorphic-testing.md` and WOR-1237.
//!
//! The core here is deterministic and model-free. It splits into two pure
//! steps the runner wires around a live server: [`transform_args`] builds the
//! follow-up call's arguments from the base arguments and a [`Relation`], and
//! [`evaluate`] decides whether the relation held between the two results. The
//! report-plus-target shape mirrors [`crate::eval::narrative_trace`], so
//! reporters resolve `metamorphic.*` targets the same way they resolve
//! `narrative.*`.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

/// The metrics-object key prefix the runner resolves these targets under.
pub const METRICS_PREFIX: &str = "metamorphic";

/// A metamorphic relation: an input transform plus the output property it keeps.
///
/// Unit variants serialize as a bare snake_case string (`idempotent`); struct
/// variants serialize as a single-key map (`{ noop_filter: { arg, value } }`),
/// which is exactly the YAML form in the design note.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Relation {
    /// Calling the same arguments twice must yield an equal result.
    Idempotent,
    /// Permuting object keys and array argument order must not change the result.
    ArgOrderInsensitive,
    /// Lowercasing every string argument must not change the result.
    CaseInsensitive,
    /// Collapsing and padding whitespace in string arguments must not change it.
    WhitespaceInsensitive,
    /// Adding a select-everything filter must not shrink the result set.
    NoopFilter {
        /// The array-valued argument the filter lives in.
        arg: String,
        /// The select-everything filter value to append (for example `"*"`).
        value: Value,
    },
    /// Adding a real, narrowing filter must return a subset of the base result.
    SubsetUnderFilter {
        /// The array-valued argument the filter lives in.
        arg: String,
        /// The narrowing filter value to append.
        value: Value,
    },
    /// Raising a `limit`-style numeric argument must not shrink the result set.
    Monotone {
        /// The numeric argument to raise.
        arg: String,
    },
    /// Appending a duplicate of an existing array element must not change the
    /// result: a set-valued argument must ignore redundant input (WOR-1403).
    DuplicateInsensitive {
        /// The array-valued argument to duplicate an element of.
        arg: String,
    },
    /// Swapping two named arguments must yield an equal result: the operation is
    /// commutative in those two arguments (WOR-1403).
    SymmetricArgs {
        /// The first argument to swap.
        arg_a: String,
        /// The second argument to swap.
        arg_b: String,
    },
    /// Raising a `limit`-style numeric argument must return a result that
    /// contains every base item, a strict-inclusion property stronger than
    /// `monotone`, which checks only that the count did not shrink (WOR-1403).
    LimitSuperset {
        /// The numeric argument to raise.
        arg: String,
    },
    /// Replacing a substring with a documented synonym in a string argument must
    /// not change the result: the tool treats the paraphrase as equivalent
    /// (WOR-1403), the soft oracle for doc-vs-behavior conformance.
    SubstitutionInsensitive {
        /// The string-valued argument to substitute within.
        arg: String,
        /// The substring to replace.
        from: String,
        /// The synonym to replace it with.
        to: String,
    },
}

/// The output property a relation asserts between the base and follow-up result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationKind {
    /// The two results must be equal up to key order.
    Equal,
    /// The follow-up result set must be no smaller than the base set.
    NonShrink,
    /// The follow-up result set must be a subset of the base set.
    Subset,
    /// The follow-up result set must contain every item of the base set (the
    /// base is a subset of the follow-up). Stronger than
    /// [`RelationKind::NonShrink`], which compares only counts.
    Superset,
}

impl Relation {
    /// Returns the stable snake_case label used in reports and targets.
    pub fn label(&self) -> &'static str {
        match self {
            Relation::Idempotent => "idempotent",
            Relation::ArgOrderInsensitive => "arg_order_insensitive",
            Relation::CaseInsensitive => "case_insensitive",
            Relation::WhitespaceInsensitive => "whitespace_insensitive",
            Relation::NoopFilter { .. } => "noop_filter",
            Relation::SubsetUnderFilter { .. } => "subset_under_filter",
            Relation::Monotone { .. } => "monotone",
            Relation::DuplicateInsensitive { .. } => "duplicate_insensitive",
            Relation::SymmetricArgs { .. } => "symmetric_args",
            Relation::LimitSuperset { .. } => "limit_superset",
            Relation::SubstitutionInsensitive { .. } => "substitution_insensitive",
        }
    }

    /// Returns the output property this relation asserts.
    pub fn kind(&self) -> RelationKind {
        match self {
            Relation::Idempotent
            | Relation::ArgOrderInsensitive
            | Relation::CaseInsensitive
            | Relation::WhitespaceInsensitive
            | Relation::DuplicateInsensitive { .. }
            | Relation::SymmetricArgs { .. }
            | Relation::SubstitutionInsensitive { .. } => RelationKind::Equal,
            Relation::NoopFilter { .. } | Relation::Monotone { .. } => RelationKind::NonShrink,
            Relation::SubsetUnderFilter { .. } => RelationKind::Subset,
            Relation::LimitSuperset { .. } => RelationKind::Superset,
        }
    }
}

/// A typed failure from building a relation's follow-up arguments.
///
/// Transform failures are configuration errors (the relation names an argument
/// that is missing or the wrong type), surfaced to the runner rather than
/// swallowed, so a metamorphic test cannot silently pass on a broken relation.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum MetamorphicError {
    /// The base arguments were not a JSON object.
    #[error("metamorphic relations require an object of arguments, found {found}")]
    NotAnObject {
        /// The JSON type that was found instead of an object.
        found: &'static str,
    },
    /// A relation targeted an argument that is present but not an array.
    #[error("relation argument `{arg}` must be an array")]
    ArgNotArray {
        /// The offending argument name.
        arg: String,
    },
    /// A `monotone` or `limit_superset` relation targeted an argument that is
    /// not a number.
    #[error("monotone argument `{arg}` must be a number")]
    ArgNotNumber {
        /// The offending argument name.
        arg: String,
    },
    /// A relation named an argument that is not present in the base call.
    #[error("relation argument `{arg}` is not present in the call")]
    ArgMissing {
        /// The offending argument name.
        arg: String,
    },
    /// A `substitution_insensitive` relation targeted an argument that is not a
    /// string.
    #[error("substitution argument `{arg}` must be a string")]
    ArgNotString {
        /// The offending argument name.
        arg: String,
    },
}

/// Builds the follow-up call arguments for a relation from the base arguments.
///
/// Pure and deterministic: the same base and relation always produce the same
/// follow-up arguments. The runner issues the returned arguments as a second
/// call and passes both results to [`evaluate`].
pub fn transform_args(base: &Value, relation: &Relation) -> Result<Value, MetamorphicError> {
    let obj = base.as_object().ok_or(MetamorphicError::NotAnObject {
        found: json_type(base),
    })?;
    match relation {
        Relation::Idempotent => Ok(Value::Object(obj.clone())),
        Relation::ArgOrderInsensitive => Ok(reverse_order(obj)),
        Relation::CaseInsensitive => Ok(map_strings(obj, |s| s.to_lowercase())),
        Relation::WhitespaceInsensitive => Ok(map_strings(obj, pad_and_collapse)),
        Relation::NoopFilter { arg, value } | Relation::SubsetUnderFilter { arg, value } => {
            append_to_array(obj, arg, value)
        }
        Relation::Monotone { arg } | Relation::LimitSuperset { arg } => bump_number(obj, arg),
        Relation::DuplicateInsensitive { arg } => duplicate_element(obj, arg),
        Relation::SymmetricArgs { arg_a, arg_b } => swap_args(obj, arg_a, arg_b),
        Relation::SubstitutionInsensitive { arg, from, to } => substitute(obj, arg, from, to),
    }
}

/// Appends a duplicate of the array's first element, so a set-valued argument
/// sees redundant input. An empty array is left unchanged (nothing to
/// duplicate), which a set-semantics tool also treats as a no-op.
fn duplicate_element(obj: &Map<String, Value>, arg: &str) -> Result<Value, MetamorphicError> {
    let mut out = obj.clone();
    match out.get_mut(arg) {
        Some(Value::Array(a)) => {
            if let Some(first) = a.first().cloned() {
                a.push(first);
            }
        }
        Some(_) => {
            return Err(MetamorphicError::ArgNotArray {
                arg: arg.to_string(),
            })
        }
        None => {
            return Err(MetamorphicError::ArgMissing {
                arg: arg.to_string(),
            })
        }
    }
    Ok(Value::Object(out))
}

/// Swaps the values of two named arguments. Both must be present, since a
/// commutativity relation is meaningless when one side is absent.
fn swap_args(
    obj: &Map<String, Value>,
    arg_a: &str,
    arg_b: &str,
) -> Result<Value, MetamorphicError> {
    let a = obj.get(arg_a).ok_or(MetamorphicError::ArgMissing {
        arg: arg_a.to_string(),
    })?;
    let b = obj.get(arg_b).ok_or(MetamorphicError::ArgMissing {
        arg: arg_b.to_string(),
    })?;
    let mut out = obj.clone();
    out.insert(arg_a.to_string(), b.clone());
    out.insert(arg_b.to_string(), a.clone());
    Ok(Value::Object(out))
}

/// Replaces every occurrence of `from` with `to` in the string argument `arg`.
fn substitute(
    obj: &Map<String, Value>,
    arg: &str,
    from: &str,
    to: &str,
) -> Result<Value, MetamorphicError> {
    let value = obj.get(arg).ok_or(MetamorphicError::ArgMissing {
        arg: arg.to_string(),
    })?;
    let text = value.as_str().ok_or(MetamorphicError::ArgNotString {
        arg: arg.to_string(),
    })?;
    let mut out = obj.clone();
    out.insert(arg.to_string(), Value::String(text.replace(from, to)));
    Ok(Value::Object(out))
}

/// Reverses key order and reverses every array-valued argument.
fn reverse_order(obj: &Map<String, Value>) -> Value {
    let mut out = Map::new();
    for (k, v) in obj.iter().rev() {
        let v = match v {
            Value::Array(a) => Value::Array(a.iter().rev().cloned().collect()),
            other => other.clone(),
        };
        out.insert(k.clone(), v);
    }
    Value::Object(out)
}

/// Applies `f` to every top-level string argument, leaving non-strings as is.
fn map_strings(obj: &Map<String, Value>, f: impl Fn(&str) -> String) -> Value {
    let mut out = Map::new();
    for (k, v) in obj {
        let v = match v {
            Value::String(s) => Value::String(f(s)),
            other => other.clone(),
        };
        out.insert(k.clone(), v);
    }
    Value::Object(out)
}

/// Collapses internal whitespace runs to one space and pads with a leading and
/// trailing space, a benign change a robust tool must ignore.
fn pad_and_collapse(s: &str) -> String {
    let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
    format!(" {collapsed} ")
}

/// Appends `value` to the array argument `arg`, creating it when absent.
fn append_to_array(
    obj: &Map<String, Value>,
    arg: &str,
    value: &Value,
) -> Result<Value, MetamorphicError> {
    let mut out = obj.clone();
    match out.get_mut(arg) {
        Some(Value::Array(a)) => a.push(value.clone()),
        Some(_) => {
            return Err(MetamorphicError::ArgNotArray {
                arg: arg.to_string(),
            })
        }
        None => {
            out.insert(arg.to_string(), Value::Array(vec![value.clone()]));
        }
    }
    Ok(Value::Object(out))
}

/// Raises the numeric argument `arg` to a strictly larger value (`2n + 1`).
fn bump_number(obj: &Map<String, Value>, arg: &str) -> Result<Value, MetamorphicError> {
    let mut out = obj.clone();
    let current = out
        .get(arg)
        .and_then(Value::as_f64)
        .ok_or(MetamorphicError::ArgNotNumber {
            arg: arg.to_string(),
        })?;
    let raised = match out.get(arg).and_then(Value::as_i64) {
        Some(n) => Value::from(n.saturating_mul(2).saturating_add(1)),
        None => Value::from(current * 2.0 + 1.0),
    };
    out.insert(arg.to_string(), raised);
    Ok(Value::Object(out))
}

/// The outcome of checking one relation between a base and follow-up result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RelationOutcome {
    /// The relation label, for example `idempotent`.
    pub relation: String,
    /// True when the relation held.
    pub holds: bool,
    /// Human-readable detail, populated when the relation is violated.
    pub detail: String,
}

/// Checks whether a relation held between the base and follow-up results.
///
/// Infallible: a relation either holds or is a violation. Equality is compared
/// up to object-key order via a canonical key-sort; set relations compare the
/// result items extracted from a `content` array or a top-level array.
pub fn evaluate(relation: &Relation, base: &Value, followup: &Value) -> RelationOutcome {
    let label = relation.label().to_string();
    let (holds, detail) = match relation.kind() {
        RelationKind::Equal => {
            let same = canonical(base) == canonical(followup);
            (
                same,
                if same {
                    String::new()
                } else {
                    "result changed under a transform that should not change it".to_string()
                },
            )
        }
        RelationKind::NonShrink => {
            let (b, f) = (result_items(base).len(), result_items(followup).len());
            (
                f >= b,
                if f >= b {
                    String::new()
                } else {
                    format!("result set shrank from {b} to {f}")
                },
            )
        }
        RelationKind::Subset => subset_outcome(base, followup),
        RelationKind::Superset => superset_outcome(base, followup),
    };
    RelationOutcome {
        relation: label,
        holds,
        detail,
    }
}

/// Evaluates the superset relation: the follow-up must contain every base item,
/// reporting how many base items went missing.
fn superset_outcome(base: &Value, followup: &Value) -> (bool, String) {
    let base_items: Vec<Value> = result_items(base).iter().map(canonical).collect();
    let follow_items: Vec<Value> = result_items(followup).iter().map(canonical).collect();
    let dropped = base_items
        .iter()
        .filter(|b| !follow_items.contains(b))
        .count();
    let holds = dropped == 0;
    let detail = if holds {
        String::new()
    } else {
        format!("{dropped} base item(s) were absent from the broadened result")
    };
    (holds, detail)
}

/// Evaluates the subset relation, reporting how many follow-up items escaped.
fn subset_outcome(base: &Value, followup: &Value) -> (bool, String) {
    let base_items: Vec<Value> = result_items(base).iter().map(canonical).collect();
    let follow_items: Vec<Value> = result_items(followup).iter().map(canonical).collect();
    let escaped = follow_items
        .iter()
        .filter(|f| !base_items.contains(f))
        .count();
    let holds = escaped == 0 && follow_items.len() <= base_items.len();
    let detail = if holds {
        String::new()
    } else {
        format!("{escaped} item(s) in the filtered result were absent from the base result")
    };
    (holds, detail)
}

/// Recursively sorts object keys so equality ignores key order. Array order is
/// preserved, since a reordered output list is itself a relation violation.
fn canonical(value: &Value) -> Value {
    match value {
        Value::Object(m) => {
            let mut keys: Vec<&String> = m.keys().collect();
            keys.sort();
            let mut out = Map::new();
            for k in keys {
                out.insert(k.clone(), canonical(&m[k]));
            }
            Value::Object(out)
        }
        Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
        other => other.clone(),
    }
}

/// Extracts the result item list for set relations.
///
/// Prefers an MCP `content` array, then a top-level array. Other shapes yield an
/// empty list, so a `NonShrink` or `Subset` relation against a non-list result
/// is vacuously satisfied; pair those relations with list-returning tools.
fn result_items(value: &Value) -> Vec<Value> {
    if let Some(content) = value.get("content").and_then(Value::as_array) {
        return content.clone();
    }
    if let Some(array) = value.as_array() {
        return array.clone();
    }
    Vec::new()
}

/// Returns the JSON type name of a value, for error messages.
fn json_type(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

/// The outcome of checking a set of relations for one base call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetamorphicReport {
    /// Every relation outcome, in the order the relations were declared.
    pub outcomes: Vec<RelationOutcome>,
}

impl MetamorphicReport {
    /// Builds a report from the relations and their two results per relation.
    ///
    /// `pairs` carries, per relation, the base result and the follow-up result
    /// produced by issuing [`transform_args`] against the server.
    pub fn build(pairs: &[(Relation, Value, Value)]) -> Self {
        let outcomes = pairs
            .iter()
            .map(|(relation, base, followup)| evaluate(relation, base, followup))
            .collect();
        Self { outcomes }
    }

    /// Count of relations evaluated.
    pub fn relations_checked(&self) -> usize {
        self.outcomes.len()
    }

    /// Count of relations that were violated.
    pub fn violations(&self) -> usize {
        self.outcomes.iter().filter(|o| !o.holds).count()
    }

    /// Returns true when no relation was violated.
    pub fn is_clean(&self) -> bool {
        self.violations() == 0
    }
}

/// Gate for a metamorphic report. The default fails on any deterministic
/// violation; there is no flake budget, since a violation is a reproducible
/// disagreement between two calls.
#[derive(Debug, Clone, Default)]
pub struct MetamorphicGate;

impl MetamorphicGate {
    /// Returns true when the report passes (no relation was violated).
    pub fn passes(&self, report: &MetamorphicReport) -> bool {
        report.is_clean()
    }
}

/// Scorecard-ready summary derived from a [`MetamorphicReport`].
///
/// Mirrors [`crate::eval::narrative_trace::NarrativeSummary`]: a small struct
/// plus `resolve_target` so reporters treat the `metamorphic.*` targets like
/// every other metric.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetamorphicSummary {
    /// Value for `metamorphic.relations_checked`.
    pub relations_checked: usize,
    /// Value for `metamorphic.violations`.
    pub violations: usize,
    /// Value for the boolean target `metamorphic.gate_passed`.
    pub gate_passed: bool,
}

impl MetamorphicSummary {
    /// Folds a report and gate into the assertable summary.
    pub fn from_report(report: &MetamorphicReport, gate: &MetamorphicGate) -> Self {
        Self {
            relations_checked: report.relations_checked(),
            violations: report.violations(),
            gate_passed: gate.passes(report),
        }
    }

    /// Resolves a dotted target like `metamorphic.violations` to a number.
    ///
    /// The boolean `metamorphic.gate_passed` resolves to `1.0` (pass) or `0.0`
    /// (fail) so it sits in the same numeric metrics object as the counts.
    pub fn resolve_target(&self, target: &str) -> Option<f64> {
        let suffix = target.strip_prefix("metamorphic.")?;
        match suffix {
            "relations_checked" => Some(self.relations_checked as f64),
            "violations" => Some(self.violations as f64),
            "gate_passed" => Some(if self.gate_passed { 1.0 } else { 0.0 }),
            _ => None,
        }
    }
}

/// The built-in metamorphic-relation presets, in declared order. Each is a
/// curated, named bundle of parameter-free relations a suite can run out of the
/// box (no per-tool argument wiring), the answer to "which relations should I
/// even try?" for a tool with no stable golden output (WOR-1370). The bundles
/// are drawn from the metamorphic-testing survey (arXiv:2511.02108) reduced to
/// the relations that hold structurally, without naming a specific argument.
pub const METAMORPHIC_PRESETS: &[&str] = &["universal", "text"];

/// Expand a named preset into its relations, or `None` for an unknown name.
///
/// - `universal`: [`Relation::Idempotent`] and [`Relation::ArgOrderInsensitive`],
///   the two relations that assume nothing about argument types and hold for any
///   deterministic, key-order-insensitive tool.
/// - `text`: [`Relation::CaseInsensitive`] and [`Relation::WhitespaceInsensitive`],
///   for a tool that normalizes textual input (search, lookup). Not universal: a
///   deliberately case- or whitespace-sensitive tool will (correctly) violate
///   these, so this bundle is opt-in by name rather than folded into `universal`.
pub fn preset_relations(name: &str) -> Option<Vec<Relation>> {
    match name {
        "universal" => Some(vec![Relation::Idempotent, Relation::ArgOrderInsensitive]),
        "text" => Some(vec![
            Relation::CaseInsensitive,
            Relation::WhitespaceInsensitive,
        ]),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn content(items: Value) -> Value {
        json!({ "content": items })
    }

    #[test]
    fn the_universal_preset_is_the_two_assumption_free_relations() {
        assert_eq!(
            preset_relations("universal"),
            Some(vec![Relation::Idempotent, Relation::ArgOrderInsensitive])
        );
    }

    #[test]
    fn the_text_preset_is_the_two_text_normalization_relations() {
        assert_eq!(
            preset_relations("text"),
            Some(vec![
                Relation::CaseInsensitive,
                Relation::WhitespaceInsensitive
            ])
        );
    }

    #[test]
    fn an_unknown_preset_resolves_to_none() {
        assert!(preset_relations("does-not-exist").is_none());
    }

    #[test]
    fn every_advertised_preset_resolves_to_parameter_free_relations() {
        // The catalog and the resolver must agree, and a preset must expand to
        // relations that need no per-tool argument (so it runs out of the box):
        // a parameter-free relation's label has no struct fields, which we proxy
        // by round-tripping it through its bare-string serialization.
        for name in METAMORPHIC_PRESETS {
            let relations = preset_relations(name).expect("advertised preset resolves");
            assert!(!relations.is_empty(), "preset {name} is empty");
            for relation in relations {
                let serialized = serde_json::to_value(&relation).expect("relation serializes");
                assert!(
                    serialized.is_string(),
                    "preset {name} relation {relation:?} needs an argument; presets must be parameter-free"
                );
            }
        }
    }

    #[test]
    fn idempotent_transform_repeats_the_base_arguments() {
        let base = json!({ "query": "anthropic", "limit": 10 });
        let out = transform_args(&base, &Relation::Idempotent).unwrap();
        assert_eq!(out, base);
    }

    #[test]
    fn arg_order_transform_reverses_array_arguments() {
        let base = json!({ "filters": ["a", "b", "c"] });
        let out = transform_args(&base, &Relation::ArgOrderInsensitive).unwrap();
        assert_eq!(out["filters"], json!(["c", "b", "a"]));
    }

    #[test]
    fn case_insensitive_transform_lowercases_string_arguments() {
        let base = json!({ "query": "Anthropic", "limit": 5 });
        let out = transform_args(&base, &Relation::CaseInsensitive).unwrap();
        assert_eq!(out["query"], json!("anthropic"));
        assert_eq!(out["limit"], json!(5));
    }

    #[test]
    fn whitespace_transform_collapses_and_pads() {
        let base = json!({ "query": "two   words" });
        let out = transform_args(&base, &Relation::WhitespaceInsensitive).unwrap();
        assert_eq!(out["query"], json!(" two words "));
    }

    #[test]
    fn noop_filter_transform_appends_to_the_named_array() {
        let base = json!({ "filters": ["lang:en"] });
        let relation = Relation::NoopFilter {
            arg: "filters".to_string(),
            value: json!("*"),
        };
        let out = transform_args(&base, &relation).unwrap();
        assert_eq!(out["filters"], json!(["lang:en", "*"]));
    }

    #[test]
    fn noop_filter_transform_creates_the_array_when_absent() {
        let base = json!({ "query": "x" });
        let relation = Relation::NoopFilter {
            arg: "filters".to_string(),
            value: json!("*"),
        };
        let out = transform_args(&base, &relation).unwrap();
        assert_eq!(out["filters"], json!(["*"]));
    }

    #[test]
    fn filter_transform_errors_when_argument_is_not_an_array() {
        let base = json!({ "filters": "not-an-array" });
        let relation = Relation::SubsetUnderFilter {
            arg: "filters".to_string(),
            value: json!("lang:en"),
        };
        assert_eq!(
            transform_args(&base, &relation),
            Err(MetamorphicError::ArgNotArray {
                arg: "filters".to_string()
            })
        );
    }

    #[test]
    fn monotone_transform_raises_an_integer_argument() {
        let base = json!({ "limit": 10 });
        let out = transform_args(
            &base,
            &Relation::Monotone {
                arg: "limit".into(),
            },
        )
        .unwrap();
        assert_eq!(out["limit"], json!(21));
    }

    #[test]
    fn monotone_transform_errors_on_a_non_numeric_argument() {
        let base = json!({ "limit": "ten" });
        assert_eq!(
            transform_args(
                &base,
                &Relation::Monotone {
                    arg: "limit".into()
                }
            ),
            Err(MetamorphicError::ArgNotNumber {
                arg: "limit".to_string()
            })
        );
    }

    #[test]
    fn transform_errors_when_arguments_are_not_an_object() {
        assert_eq!(
            transform_args(&json!("scalar"), &Relation::Idempotent),
            Err(MetamorphicError::NotAnObject { found: "string" })
        );
    }

    #[test]
    fn equal_relation_holds_regardless_of_object_key_order() {
        let base = json!({ "a": 1, "b": 2 });
        let followup = json!({ "b": 2, "a": 1 });
        let outcome = evaluate(&Relation::Idempotent, &base, &followup);
        assert!(outcome.holds, "{}", outcome.detail);
    }

    #[test]
    fn equal_relation_is_violated_when_results_differ() {
        let base = content(json!([1, 2, 3]));
        let followup = content(json!([3, 2, 1]));
        let outcome = evaluate(&Relation::ArgOrderInsensitive, &base, &followup);
        assert!(!outcome.holds);
        assert!(!outcome.detail.is_empty());
    }

    #[test]
    fn nonshrink_relation_holds_when_the_set_does_not_shrink() {
        let base = content(json!([1, 2]));
        let followup = content(json!([1, 2, 3]));
        let relation = Relation::NoopFilter {
            arg: "filters".into(),
            value: json!("*"),
        };
        assert!(evaluate(&relation, &base, &followup).holds);
    }

    #[test]
    fn nonshrink_relation_is_violated_when_the_set_shrinks() {
        let base = content(json!([1, 2, 3]));
        let followup = content(json!([1]));
        let relation = Relation::Monotone {
            arg: "limit".into(),
        };
        let outcome = evaluate(&relation, &base, &followup);
        assert!(!outcome.holds);
        assert!(outcome.detail.contains("shrank"));
    }

    #[test]
    fn subset_relation_holds_when_filtered_result_is_contained() {
        let base = content(json!(["a", "b", "c"]));
        let followup = content(json!(["a", "c"]));
        let relation = Relation::SubsetUnderFilter {
            arg: "filters".into(),
            value: json!("kind:doc"),
        };
        assert!(evaluate(&relation, &base, &followup).holds);
    }

    #[test]
    fn subset_relation_is_violated_when_a_new_item_appears() {
        let base = content(json!(["a", "b"]));
        let followup = content(json!(["a", "z"]));
        let relation = Relation::SubsetUnderFilter {
            arg: "filters".into(),
            value: json!("kind:doc"),
        };
        let outcome = evaluate(&relation, &base, &followup);
        assert!(!outcome.holds);
        assert!(outcome.detail.contains("absent"));
    }

    #[test]
    fn report_counts_relations_and_violations() {
        let pairs = vec![
            (
                Relation::Idempotent,
                content(json!([1])),
                content(json!([1])),
            ),
            (
                Relation::ArgOrderInsensitive,
                content(json!([1])),
                content(json!([2])),
            ),
        ];
        let report = MetamorphicReport::build(&pairs);
        assert_eq!(report.relations_checked(), 2);
        assert_eq!(report.violations(), 1);
        assert!(!report.is_clean());
    }

    #[test]
    fn gate_fails_on_any_violation() {
        let pairs = vec![(
            Relation::Idempotent,
            content(json!([1])),
            content(json!([2])),
        )];
        let report = MetamorphicReport::build(&pairs);
        assert!(!MetamorphicGate.passes(&report));
    }

    #[test]
    fn summary_resolves_metamorphic_targets() {
        let pairs = vec![
            (
                Relation::Idempotent,
                content(json!([1])),
                content(json!([2])),
            ),
            (
                Relation::CaseInsensitive,
                content(json!([1])),
                content(json!([1])),
            ),
        ];
        let report = MetamorphicReport::build(&pairs);
        let summary = MetamorphicSummary::from_report(&report, &MetamorphicGate);
        assert_eq!(
            summary.resolve_target("metamorphic.relations_checked"),
            Some(2.0)
        );
        assert_eq!(summary.resolve_target("metamorphic.violations"), Some(1.0));
        assert_eq!(summary.resolve_target("metamorphic.gate_passed"), Some(0.0));
        assert!(summary.resolve_target("metamorphic.unknown").is_none());
    }

    #[test]
    fn relations_deserialize_the_design_note_yaml_forms() {
        let bare: Relation = serde_json::from_value(json!("idempotent")).unwrap();
        assert_eq!(bare, Relation::Idempotent);

        let tagged: Relation =
            serde_json::from_value(json!({ "noop_filter": { "arg": "filters", "value": "*" } }))
                .unwrap();
        assert_eq!(
            tagged,
            Relation::NoopFilter {
                arg: "filters".to_string(),
                value: json!("*")
            }
        );

        let monotone: Relation =
            serde_json::from_value(json!({ "monotone": { "arg": "limit" } })).unwrap();
        assert_eq!(
            monotone,
            Relation::Monotone {
                arg: "limit".into()
            }
        );
    }

    #[test]
    fn evaluation_is_deterministic() {
        let base = content(json!([1, 2, 3]));
        let followup = content(json!([1, 2]));
        let relation = Relation::Monotone {
            arg: "limit".into(),
        };
        let first = evaluate(&relation, &base, &followup);
        let second = evaluate(&relation, &base, &followup);
        assert_eq!(first, second);
    }

    #[test]
    fn duplicate_insensitive_appends_a_copy_of_the_first_element() {
        let base = json!({ "tags": ["a", "b"] });
        let out = transform_args(
            &base,
            &Relation::DuplicateInsensitive { arg: "tags".into() },
        )
        .unwrap();
        assert_eq!(out["tags"], json!(["a", "b", "a"]));
    }

    #[test]
    fn duplicate_insensitive_errors_when_the_argument_is_missing() {
        let base = json!({ "query": "x" });
        assert_eq!(
            transform_args(
                &base,
                &Relation::DuplicateInsensitive { arg: "tags".into() }
            ),
            Err(MetamorphicError::ArgMissing {
                arg: "tags".to_string()
            })
        );
    }

    #[test]
    fn symmetric_args_swaps_the_two_named_arguments() {
        let base = json!({ "from": "NYC", "to": "SFO", "date": "today" });
        let out = transform_args(
            &base,
            &Relation::SymmetricArgs {
                arg_a: "from".into(),
                arg_b: "to".into(),
            },
        )
        .unwrap();
        assert_eq!(out["from"], json!("SFO"));
        assert_eq!(out["to"], json!("NYC"));
        // An unrelated argument is left untouched.
        assert_eq!(out["date"], json!("today"));
    }

    #[test]
    fn symmetric_args_errors_when_a_side_is_missing() {
        let base = json!({ "from": "NYC" });
        assert_eq!(
            transform_args(
                &base,
                &Relation::SymmetricArgs {
                    arg_a: "from".into(),
                    arg_b: "to".into()
                }
            ),
            Err(MetamorphicError::ArgMissing {
                arg: "to".to_string()
            })
        );
    }

    #[test]
    fn limit_superset_raises_the_argument_like_monotone() {
        let base = json!({ "limit": 5 });
        let out = transform_args(
            &base,
            &Relation::LimitSuperset {
                arg: "limit".into(),
            },
        )
        .unwrap();
        assert_eq!(out["limit"], json!(11));
    }

    #[test]
    fn limit_superset_holds_when_every_base_item_survives() {
        let base = content(json!(["a", "b"]));
        let followup = content(json!(["a", "b", "c"]));
        let relation = Relation::LimitSuperset {
            arg: "limit".into(),
        };
        assert!(evaluate(&relation, &base, &followup).holds);
    }

    #[test]
    fn limit_superset_is_violated_when_a_base_item_drops() {
        // The broadened call returned more items but lost one the base had:
        // that is a stricter failure than a count-only shrink.
        let base = content(json!(["a", "b"]));
        let followup = content(json!(["a", "c", "d"]));
        let relation = Relation::LimitSuperset {
            arg: "limit".into(),
        };
        let outcome = evaluate(&relation, &base, &followup);
        assert!(!outcome.holds);
        assert!(outcome.detail.contains("absent"));
    }

    #[test]
    fn substitution_insensitive_replaces_the_substring() {
        let base = json!({ "query": "find a flick" });
        let out = transform_args(
            &base,
            &Relation::SubstitutionInsensitive {
                arg: "query".into(),
                from: "flick".into(),
                to: "movie".into(),
            },
        )
        .unwrap();
        assert_eq!(out["query"], json!("find a movie"));
    }

    #[test]
    fn substitution_insensitive_errors_on_a_non_string_argument() {
        let base = json!({ "limit": 5 });
        assert_eq!(
            transform_args(
                &base,
                &Relation::SubstitutionInsensitive {
                    arg: "limit".into(),
                    from: "a".into(),
                    to: "b".into()
                }
            ),
            Err(MetamorphicError::ArgNotString {
                arg: "limit".to_string()
            })
        );
    }

    #[test]
    fn new_relations_deserialize_their_yaml_forms() {
        let dup: Relation =
            serde_json::from_value(json!({ "duplicate_insensitive": { "arg": "tags" } })).unwrap();
        assert_eq!(dup, Relation::DuplicateInsensitive { arg: "tags".into() });

        let sym: Relation =
            serde_json::from_value(json!({ "symmetric_args": { "arg_a": "from", "arg_b": "to" } }))
                .unwrap();
        assert_eq!(
            sym,
            Relation::SymmetricArgs {
                arg_a: "from".into(),
                arg_b: "to".into()
            }
        );

        let sup: Relation =
            serde_json::from_value(json!({ "limit_superset": { "arg": "limit" } })).unwrap();
        assert_eq!(
            sup,
            Relation::LimitSuperset {
                arg: "limit".into()
            }
        );

        let subst: Relation = serde_json::from_value(
            json!({ "substitution_insensitive": { "arg": "query", "from": "flick", "to": "movie" } }),
        )
        .unwrap();
        assert_eq!(
            subst,
            Relation::SubstitutionInsensitive {
                arg: "query".into(),
                from: "flick".into(),
                to: "movie".into()
            }
        );
    }
}
