//! Energy binding — the joules a reconstruction cost, tied to *that* reconstruction.
//!
//! # Why this exists
//!
//! C2PA 2.4 added a standard `c2pa.environmental-sustainability` assertion carrying
//! `energy_kwh` (per ISO/IEC TR 20226:2025) alongside carbon and water figures. The
//! dominant signed-provenance container in media now has a slot for joules.
//!
//! What it does not have is anything behind that slot. The figure is **self-declared**,
//! its `measurementMethod` is **optional**, and — the part that matters most — it is not
//! bound to any reproducible output. A manifest can assert a number about an asset, and
//! a validator that checks every signature in the manifest still learns nothing about
//! where the number came from or what it was measured over.
//!
//! This module fills the slot rather than replacing it. It emits the standard assertion
//! for interoperability, and carries a companion assertion with the three things the
//! standard one omits.
//!
//! # What the companion assertion adds
//!
//! - **A binding to the reconstruction.** The figure carries the SHA-256 of the decoded
//!   pixels it was measured over — the same digest
//!   [`crate::reconstruction_binding::ReconstructionBinding`] commits to. This is the
//!   whole point: because a WAI reconstruction is byte-exact and reproducible on every
//!   architecture, a verifier can re-decode, confirm it holds the same pixels the figure
//!   is about, and be left with the measurement as the unverifiable part — the joules
//!   and the fields that describe how they were obtained (energy-binding §3). An
//!   energy claim about an output nobody can reproduce is not checkable at all; an
//!   energy claim about an output anyone can reproduce is checkable except for its
//!   measurement, and knowing precisely which fields those are is the difference.
//! - **An acquisition class.** `OnChipCounter` / `CalibratedInstrument` /
//!   `ModelBased` / `Estimator` (and, read-only, the legacy `HwShunt`) — the family's
//!   ladder, stated rather than implied. A counter reading and a modelled constant are
//!   not the same claim, and the standard assertion has no way to tell them apart. The
//!   two hardware classes carry their declared uncertainty — and the counter's
//!   filtering state or the instrument's calibration reference — because a
//!   self-reported counter and a calibrated meter are not the same claim either
//!   ([`crate::energy_class`]).
//! - **A coverage statement.** Whether the figure accounts for the whole reconstruction
//!   or part of it. A partial reading is not wrong; presenting it as a total is.
//!
//! # On units
//!
//! The truth carried here is **integer microjoules**. The standard assertion wants
//! `energy_kwh`, and one microjoule is 2.8e-13 kWh, so a whole image decode lands
//! around 1e-9 kWh — eleven orders of magnitude below the unit's natural scale, where
//! the trailing digits are an artifact of the conversion rather than of the
//! measurement. So `energy_kwh` is emitted as a *derived* interop value and labelled as
//! such, while the integer stays authoritative. A verifier should read the integer.

use sha2::{Digest, Sha256};

/// How the energy figure was acquired — the family's provenance ladder. The one
/// definition lives in [`crate::energy_class`]; the name is kept here for the C2PA
/// surface.
pub use crate::energy_class::EnergyProvenance as Acquisition;
use crate::energy_class::{AcquisitionError, CounterFiltering, Uncertainty};

/// How much of the reconstruction the figure accounts for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Coverage {
    /// The figure covers the whole reconstruction.
    Whole,
    /// It covers part of it; the remainder is unmeasured, which is not the same as zero.
    Partial,
}

impl Coverage {
    /// The wire label.
    pub fn label(self) -> &'static str {
        match self {
            Coverage::Whole => "Whole",
            Coverage::Partial => "Partial",
        }
    }

    /// Parse a wire label.
    pub fn from_label(s: &str) -> Option<Coverage> {
        match s {
            "Whole" => Some(Coverage::Whole),
            "Partial" => Some(Coverage::Partial),
            _ => None,
        }
    }
}

/// Errors from constructing a binding.
#[derive(Debug, PartialEq, Eq)]
pub enum EnergyBindingError {
    /// A measured figure of zero is a failed measurement, not free work.
    ZeroMeasurement,
    /// The work unit must be recomputable and non-zero to express a rate.
    ZeroWork,
    /// A figure must name the protocol it was obtained under.
    NoMethod,
    /// The acquisition class failed its evidence rules — most importantly, a hardware
    /// class with no declared uncertainty.
    Acquisition(AcquisitionError),
    /// A new binding may not use the legacy `HwShunt` label. It is accepted when
    /// *read* ([`EnergyBinding::from_json`]) so existing manifests keep validating, and
    /// refused when *minted*.
    LegacyClass,
    /// An assertion body that is not the shape this crate emits.
    Malformed(&'static str),
}

impl core::fmt::Display for EnergyBindingError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            EnergyBindingError::ZeroMeasurement => {
                write!(f, "a measured figure of 0 uJ is a failed measurement, not free work")
            }
            EnergyBindingError::ZeroWork => write!(f, "work unit must be non-zero"),
            EnergyBindingError::NoMethod => {
                write!(f, "a figure must name the acquisition protocol it was obtained under")
            }
            EnergyBindingError::Acquisition(e) => write!(f, "acquisition class: {e}"),
            EnergyBindingError::LegacyClass => write!(
                f,
                "HwShunt is a legacy label: bind as OnChipCounter or CalibratedInstrument \
                 with a declared uncertainty"
            ),
            EnergyBindingError::Malformed(w) => write!(f, "malformed energy binding: {w}"),
        }
    }
}

impl std::error::Error for EnergyBindingError {}

/// The joules a reconstruction cost, bound to the reconstruction itself.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnergyBinding {
    /// SHA-256 of the canonical decoded pixels this figure was measured over — the same
    /// digest the reconstruction binding commits to.
    pub reconstruction_hash: [u8; 32],
    /// The measured energy, integer microjoules. Authoritative; `energy_kwh` in the
    /// standard assertion is derived from this.
    pub microjoules: u64,
    /// How the figure was acquired.
    pub acquisition: Acquisition,
    /// How much of the reconstruction it accounts for.
    pub coverage: Coverage,
    /// The recomputable work unit the rate is expressed against (for an image, decoded
    /// subpixels: `width * height * 3`). A verifier derives this from the reconstruction
    /// descriptor rather than trusting it.
    pub work_units: u64,
    /// The published protocol the figure was obtained under — for WAI decodes,
    /// `extensions/energy-measurement.md`. Required: an unattributed number is not a
    /// measurement, and the standard assertion's optional `measurementMethod` is exactly
    /// the field everyone leaves empty.
    pub method: String,
}

impl EnergyBinding {
    /// The companion assertion's label (reverse-domain, as C2PA requires for third-party
    /// assertions). Carried *alongside* `c2pa.environmental-sustainability`, never
    /// instead of it.
    pub const C2PA_LABEL: &'static str = "science.transaction.wai.energy.reconstruction";

    /// The standard C2PA 2.4 assertion this one accompanies.
    pub const C2PA_STANDARD_LABEL: &'static str = "c2pa.environmental-sustainability";

    /// Bind a measured figure to a reconstruction.
    ///
    /// Rejects a zero measurement rather than recording it: a decode that measured 0 µJ
    /// did not consume nothing, it failed to measure, and the honest artifact in that
    /// case is no energy assertion at all. Rejects a hardware class without its
    /// declared uncertainty or with one below the `0.5/√3` µJ that rounding the figure
    /// to whole microjoules adds (`microjoules × relativePpm < 288 676`,
    /// `energy_class::check_resolution`), and the legacy `HwShunt` label, which new
    /// bindings may not use.
    pub fn bind(
        reconstruction_hash: [u8; 32],
        microjoules: u64,
        acquisition: Acquisition,
        coverage: Coverage,
        work_units: u64,
        method: impl Into<String>,
    ) -> Result<Self, EnergyBindingError> {
        if microjoules == 0 {
            return Err(EnergyBindingError::ZeroMeasurement);
        }
        if work_units == 0 {
            return Err(EnergyBindingError::ZeroWork);
        }
        let method = method.into();
        if method.trim().is_empty() {
            return Err(EnergyBindingError::NoMethod);
        }
        if acquisition.is_legacy() {
            return Err(EnergyBindingError::LegacyClass);
        }
        acquisition.validate().map_err(EnergyBindingError::Acquisition)?;
        crate::energy_class::check_resolution(microjoules, &acquisition).map_err(EnergyBindingError::Acquisition)?;
        Ok(EnergyBinding {
            reconstruction_hash,
            microjoules,
            acquisition,
            coverage,
            work_units,
            method,
        })
    }

    /// Bind directly to decoded pixels, hashing them the same way the reconstruction
    /// binding does, so the two assertions in one manifest necessarily agree.
    pub fn bind_pixels(
        rgb: &[u8],
        microjoules: u64,
        acquisition: Acquisition,
        coverage: Coverage,
        method: impl Into<String>,
    ) -> Result<Self, EnergyBindingError> {
        let hash: [u8; 32] = Sha256::digest(rgb).into();
        Self::bind(hash, microjoules, acquisition, coverage, rgb.len() as u64, method)
    }

    /// Whether this figure describes the pixels a verifier just decoded.
    ///
    /// This is the check that makes the assertion worth carrying: re-decode, hash, and
    /// confirm the energy claim is about *these* bytes and not some other run.
    pub fn describes(&self, rgb: &[u8]) -> bool {
        <[u8; 32]>::from(Sha256::digest(rgb)) == self.reconstruction_hash
    }

    /// Microjoules per thousand work units — the comparable rate, with its acquisition
    /// class, because a rate without one cannot be compared to another.
    pub fn uj_per_kilo_unit(&self) -> (f64, Acquisition) {
        (self.microjoules as f64 * 1000.0 / self.work_units as f64, self.acquisition.clone())
    }

    /// The derived `energy_kwh` for the standard assertion.
    ///
    /// Lossy by construction — a microjoule-scale decode lands around 1e-13 kWh, where
    /// the trailing digits come from the conversion rather than the measurement. Emitted
    /// for interoperability; [`Self::microjoules`] remains authoritative.
    pub fn energy_kwh_derived(&self) -> f64 {
        self.microjoules as f64 / 3.6e12
    }

    /// Lowercase-hex of the reconstruction digest.
    pub fn hash_hex(&self) -> String {
        let mut s = String::with_capacity(64);
        for b in self.reconstruction_hash {
            use std::fmt::Write;
            let _ = write!(s, "{b:02x}");
        }
        s
    }

    /// The standard `c2pa.environmental-sustainability` assertion body, for interop.
    ///
    /// `measurementMethod` is always populated — it is optional in the standard, and an
    /// unattributed figure is the failure this module exists to avoid. Carbon and water
    /// are deliberately absent: this measures energy, and inventing the other two from a
    /// grid-average factor would be a modelled figure wearing a measured one's clothes.
    pub fn to_standard_assertion_json(&self) -> String {
        format!(
            "{{\"energy_kwh\":{:e},\"measurementMethod\":{}}}",
            self.energy_kwh_derived(),
            json_str(&self.method)
        )
    }

    /// The companion assertion body: everything the standard one cannot express.
    ///
    /// The hardware classes add their evidence directly after `acquisition`:
    /// `uncertainty` (`relativePpm`, `windowUs`) always, then `counterFiltering` for
    /// `OnChipCounter` or `calibrationRef` for `CalibratedInstrument`. The other classes
    /// add nothing, so a body for them is byte-identical to one written before the
    /// hardware class was refined.
    pub fn to_json(&self) -> String {
        let evidence = match &self.acquisition {
            Acquisition::OnChipCounter { uncertainty, filtering } => format!(
                ",{},\"counterFiltering\":\"{}\"",
                uncertainty_json(uncertainty),
                filtering.label()
            ),
            Acquisition::CalibratedInstrument { uncertainty, calibration_ref } => format!(
                ",{},\"calibrationRef\":{}",
                uncertainty_json(uncertainty),
                json_str(calibration_ref)
            ),
            _ => String::new(),
        };
        format!(
            "{{\"reconstructionHash\":\"{}\",\"alg\":\"sha256\",\"microjoules\":{},\
             \"acquisition\":\"{}\"{},\"coverage\":\"{}\",\"workUnits\":{},\"method\":{}}}",
            self.hash_hex(),
            self.microjoules,
            self.acquisition.label(),
            evidence,
            self.coverage.label(),
            self.work_units,
            json_str(&self.method)
        )
    }

    /// Read a companion assertion body back into a binding, applying every rule a
    /// verifier must apply rather than trusting the producer:
    ///
    /// - the legacy `HwShunt` label **is accepted** (it is what manifests signed before
    ///   the hardware class was refined say) and reads as the weakest hardware class;
    /// - a refined hardware class **without** its declared uncertainty is refused, as is
    ///   a calibrated instrument without a calibration reference, and evidence attached
    ///   to a class that carries none;
    /// - zero energy, zero work and an empty method are refused exactly as
    ///   [`EnergyBinding::bind`] refuses them.
    ///
    /// Unknown keys are ignored, so a later revision can add fields.
    pub fn from_json(v: &serde_json::Value) -> Result<EnergyBinding, EnergyBindingError> {
        use serde_json::Value;
        let m = EnergyBindingError::Malformed;
        let o = v.as_object().ok_or(m("not an object"))?;
        let str_of = |k: &'static str| o.get(k).and_then(Value::as_str);
        let u64_of = |k: &'static str| o.get(k).and_then(Value::as_u64);

        if str_of("alg") != Some("sha256") {
            return Err(m("alg must be sha256"));
        }
        let hex = str_of("reconstructionHash").ok_or(m("missing reconstructionHash"))?;
        let reconstruction_hash = parse_hash(hex).ok_or(m("reconstructionHash is not 32 hex bytes"))?;
        let microjoules = u64_of("microjoules").ok_or(m("missing microjoules"))?;
        let work_units = u64_of("workUnits").ok_or(m("missing workUnits"))?;
        let coverage = str_of("coverage")
            .and_then(Coverage::from_label)
            .ok_or(m("missing or unknown coverage"))?;
        let method = str_of("method").ok_or(m("missing method"))?.to_string();

        let label = str_of("acquisition").ok_or(m("missing acquisition"))?;
        let uncertainty = match o.get("uncertainty") {
            None => None,
            Some(u) => {
                let ppm = u.get("relativePpm").and_then(Value::as_u64).ok_or(m("uncertainty.relativePpm"))?;
                let window_us = u.get("windowUs").and_then(Value::as_u64).ok_or(m("uncertainty.windowUs"))?;
                let relative_ppm = u32::try_from(ppm)
                    .map_err(|_| EnergyBindingError::Acquisition(AcquisitionError::UncertaintyOutOfRange))?;
                Some(Uncertainty { relative_ppm, window_us })
            }
        };
        let filtering = match o.get("counterFiltering") {
            None => None,
            Some(f) => Some(
                f.as_str()
                    .and_then(CounterFiltering::from_label)
                    .ok_or(m("unknown counterFiltering"))?,
            ),
        };
        let calibration_ref = match o.get("calibrationRef") {
            None => None,
            Some(r) => Some(r.as_str().ok_or(m("calibrationRef must be a string"))?),
        };
        let acquisition = Acquisition::from_parts(label, uncertainty, filtering, calibration_ref)
            .map_err(EnergyBindingError::Acquisition)?;

        if microjoules == 0 {
            return Err(EnergyBindingError::ZeroMeasurement);
        }
        crate::energy_class::check_resolution(microjoules, &acquisition).map_err(EnergyBindingError::Acquisition)?;
        if work_units == 0 {
            return Err(EnergyBindingError::ZeroWork);
        }
        if method.trim().is_empty() {
            return Err(EnergyBindingError::NoMethod);
        }
        Ok(EnergyBinding { reconstruction_hash, microjoules, acquisition, coverage, work_units, method })
    }
}

fn uncertainty_json(u: &Uncertainty) -> String {
    format!(
        "\"uncertainty\":{{\"relativePpm\":{},\"windowUs\":{}}}",
        u.relative_ppm, u.window_us
    )
}

fn parse_hash(hex: &str) -> Option<[u8; 32]> {
    if hex.len() != 64 || !hex.is_ascii() {
        return None;
    }
    let mut out = [0u8; 32];
    for (i, b) in out.iter_mut().enumerate() {
        *b = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).ok()?;
    }
    Some(out)
}

fn json_str(s: &str) -> String {
    let mut o = String::with_capacity(s.len() + 2);
    o.push('"');
    for c in s.chars() {
        match c {
            '"' => o.push_str("\\\""),
            '\\' => o.push_str("\\\\"),
            '\n' => o.push_str("\\n"),
            // Every other control character must be escaped too, or the body is
            // not JSON and a validator cannot read the assertion at all.
            c if (c as u32) < 0x20 => {
                use std::fmt::Write;
                let _ = write!(o, "\\u{:04x}", c as u32);
            }
            c => o.push(c),
        }
    }
    o.push('"');
    o
}

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

    const METHOD: &str = "https://transaction.science/wai/extensions/energy-measurement";

    fn pixels() -> Vec<u8> {
        (0..(4 * 4 * 3u32)).map(|i| (i % 251) as u8).collect()
    }

    use crate::energy_class::test_on_chip as chip;

    /// The point of the module: the figure is about pixels a verifier can reproduce.
    #[test]
    fn the_figure_is_bound_to_the_pixels_it_was_measured_over() {
        let rgb = pixels();
        let b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD)
            .unwrap();

        assert!(b.describes(&rgb), "must describe the pixels it was bound to");

        // A different reconstruction is a different claim.
        let mut other = rgb.clone();
        other[0] ^= 1;
        assert!(!b.describes(&other), "must not vouch for pixels it never saw");

        // And it agrees with the reconstruction binding's own digest, so two
        // assertions in one manifest cannot disagree about which decode is meant.
        use crate::reconstruction_binding::{ReconstructionBinding, ReconstructionDescriptor};
        let rb = ReconstructionBinding::bind(
            &rgb,
            ReconstructionDescriptor {
                decoder_id: 0,
                profile: "p".into(),
                level: "l".into(),
                width: 4,
                height: 4,
                format: "RGB8".into(),
                color_space: "bt709".into(),
            },
        );
        assert_eq!(rb.hash_hex(), b.hash_hex(), "both assertions must name the same decode");
    }

    /// A zero measurement is a failed measurement, not free work — so it cannot be
    /// recorded as an energy claim at all.
    #[test]
    fn a_zero_measurement_is_refused() {
        let rgb = pixels();
        assert_eq!(
            EnergyBinding::bind_pixels(&rgb, 0, chip(), Coverage::Whole, METHOD),
            Err(EnergyBindingError::ZeroMeasurement)
        );
    }

    /// The standard assertion leaves `measurementMethod` optional, which is exactly the
    /// field that ends up empty. Here it cannot be.
    #[test]
    fn a_figure_must_name_its_method() {
        let rgb = pixels();
        assert_eq!(
            EnergyBinding::bind_pixels(&rgb, 10, Acquisition::Estimator, Coverage::Whole, "  "),
            Err(EnergyBindingError::NoMethod)
        );
    }

    /// Acquisition and coverage survive into the wire, because a bare number is not
    /// comparable to another bare number.
    #[test]
    fn the_wire_carries_what_the_standard_assertion_cannot() {
        let rgb = pixels();
        let b = EnergyBinding::bind_pixels(&rgb, 2_469, Acquisition::ModelBased, Coverage::Partial, METHOD)
            .unwrap();
        let j = b.to_json();
        assert!(j.contains("\"acquisition\":\"ModelBased\""));
        assert!(j.contains("\"coverage\":\"Partial\""));
        assert!(j.contains("\"microjoules\":2469"));
        assert!(j.contains("reconstructionHash"));

        // The standard assertion always names its method, and carries no carbon or
        // water figure this module did not measure.
        let s = b.to_standard_assertion_json();
        assert!(s.contains("measurementMethod"));
        assert!(!s.contains("carbon"), "must not invent a carbon figure: {s}");
        assert!(!s.contains("water"), "must not invent a water figure: {s}");
    }

    /// kWh is a derived interop value; the integer is the truth. Round-tripping is
    /// lossy at this scale and the docs say so — this pins that it really is.
    #[test]
    fn kwh_is_derived_and_lossy_while_the_integer_is_exact() {
        let rgb = pixels();
        let b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD)
            .unwrap();
        assert_eq!(b.microjoules, 10_565, "the integer is carried exactly");

        let kwh = b.energy_kwh_derived();
        let back = (kwh * 3.6e12).round() as u64;
        assert_eq!(back, 10_565, "this magnitude survives the round trip");
        // 10_565 uJ is 2.93e-9 kWh. Asserted against computed arithmetic rather than
        // an eyeballed exponent — the first draft of this test guessed 1e-11 and was
        // wrong by two orders of magnitude, which is the entire hazard of the unit.
        assert!((kwh - 10_565.0 / 3.6e12).abs() < 1e-20);
        assert!(kwh < 1e-8 && kwh > 1e-10, "an image decode sits around 1e-9 kWh, got {kwh:e}");
    }

    /// The rate is only meaningful with its acquisition class attached.
    #[test]
    fn a_rate_carries_its_acquisition_class() {
        let rgb = pixels(); // 48 bytes of work
        let b = EnergyBinding::bind_pixels(&rgb, 4_800, chip(), Coverage::Whole, METHOD)
            .unwrap();
        let (rate, acq) = b.uj_per_kilo_unit();
        assert_eq!(acq, chip());
        assert!((rate - 100_000.0).abs() < 1e-6, "4800 uJ over 48 units = 100 uJ/unit");
    }


    /// The body an emitter wrote for `HwShunt` before the hardware class was refined,
    /// captured from that emitter and pinned byte for byte.
    const LEGACY_HWSHUNT_BODY: &str = "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"HwShunt\",\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}";

    /// A manifest signed with the legacy label keeps reading: the body parses, reads as
    /// the unrefined hardware class, and re-renders to exactly the bytes that were
    /// signed. Minting a *new* binding with it is refused.
    #[test]
    #[allow(deprecated)] // reading a legacy HwShunt body is the point
    fn legacy_assertion_bodies_still_parse_and_are_byte_stable() {
        let v: serde_json::Value = serde_json::from_str(LEGACY_HWSHUNT_BODY).unwrap();
        let b = EnergyBinding::from_json(&v).expect("a legacy body must still parse");
        assert_eq!(b.acquisition, Acquisition::HwShunt);
        assert!(b.acquisition.is_legacy());
        assert!(b.describes(&pixels()), "and still describes the decode it was measured over");
        assert_eq!(b.to_json(), LEGACY_HWSHUNT_BODY, "legacy rendering is unchanged");

        assert_eq!(
            EnergyBinding::bind_pixels(&pixels(), 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD),
            Err(EnergyBindingError::LegacyClass),
            "new bindings must say which kind of hardware evidence they are"
        );
    }

    /// Both refined classes are emitted with their evidence and read back to the same
    /// binding.
    #[test]
    fn refined_classes_are_emitted_with_their_evidence_and_round_trip() {
        let rgb = pixels();
        let chip_b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD).unwrap();
        let j = chip_b.to_json();
        assert!(j.contains("\"acquisition\":\"OnChipCounter\""), "{j}");
        assert!(j.contains("\"uncertainty\":{\"relativePpm\":30000,\"windowUs\":2000000}"), "{j}");
        assert!(j.contains("\"counterFiltering\":\"Unknown\""), "{j}");

        let cal = Acquisition::calibrated_instrument(
            Uncertainty::new(5_000, 60_000_000).unwrap(),
            "https://lab.example/certificates/0412",
        )
        .unwrap();
        let cal_b = EnergyBinding::bind_pixels(&rgb, 10_565, cal, Coverage::Partial, METHOD).unwrap();
        let j2 = cal_b.to_json();
        assert!(j2.contains("\"acquisition\":\"CalibratedInstrument\""), "{j2}");
        assert!(j2.contains("\"calibrationRef\":\"https://lab.example/certificates/0412\""), "{j2}");
        assert!(!j2.contains("counterFiltering"), "an instrument has no counter filter: {j2}");
        // The whole bodies, pinned as the refined classes first emitted them, so a
        // member that moves, or a byte of evidence that changes, fails here and not
        // only a member that disappears. Never edit these to make a test pass.
        assert_eq!(j, "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"OnChipCounter\",\"uncertainty\":{\"relativePpm\":30000,\"windowUs\":2000000},\"counterFiltering\":\"Unknown\",\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}");
        assert_eq!(j2, "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"CalibratedInstrument\",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":60000000},\"calibrationRef\":\"https://lab.example/certificates/0412\",\"coverage\":\"Partial\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}");

        for b in [chip_b, cal_b] {
            let v: serde_json::Value = serde_json::from_str(&b.to_json()).expect("valid JSON");
            assert_eq!(EnergyBinding::from_json(&v), Ok(b));
        }
    }

    /// The rule this change exists for: a hardware class with no declared uncertainty
    /// is refused, whether minted here or read from someone else's manifest.
    #[test]
    fn a_refined_class_without_an_uncertainty_is_rejected() {
        let rgb = pixels();
        let undeclared = Acquisition::OnChipCounter {
            uncertainty: Uncertainty { relative_ppm: 0, window_us: 1_000_000 },
            filtering: CounterFiltering::Unknown,
        };
        assert_eq!(
            EnergyBinding::bind_pixels(&rgb, 10, undeclared, Coverage::Whole, METHOD),
            Err(EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty))
        );

        let body = |extra: &str, acq: &str| {
            let s = format!(
                "{{\"reconstructionHash\":\"{}\",\"alg\":\"sha256\",\"microjoules\":10565,\
                 \"acquisition\":\"{acq}\"{extra},\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"m\"}}",
                "4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3"
            );
            serde_json::from_str::<serde_json::Value>(&s).unwrap()
        };
        let refused = |extra: &str, acq: &str| EnergyBinding::from_json(&body(extra, acq)).unwrap_err();

        assert_eq!(
            refused("", "OnChipCounter"),
            EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty)
        );
        assert_eq!(
            refused(",\"calibrationRef\":\"c\"", "CalibratedInstrument"),
            EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty)
        );
        assert_eq!(
            refused(",\"uncertainty\":{\"relativePpm\":0,\"windowUs\":1}", "OnChipCounter"),
            EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty)
        );
        assert_eq!(
            refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":0}", "OnChipCounter"),
            EnergyBindingError::Acquisition(AcquisitionError::NoWindow)
        );
        assert_eq!(
            refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "CalibratedInstrument"),
            EnergyBindingError::Acquisition(AcquisitionError::NoCalibrationRef)
        );
        assert_eq!(
            refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "HwShunt"),
            EnergyBindingError::Acquisition(AcquisitionError::UnexpectedEvidence),
            "legacy plus half the new evidence is ambiguous"
        );
        assert_eq!(
            refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1},\"counterFiltering\":\"Maybe\"", "OnChipCounter"),
            EnergyBindingError::Malformed("unknown counterFiltering")
        );
        // A present uncertainty with an unstated filtering state reads as Unknown,
        // never as Off.
        let ok = EnergyBinding::from_json(&body(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "OnChipCounter")).unwrap();
        assert!(matches!(ok.acquisition, Acquisition::OnChipCounter { filtering: CounterFiltering::Unknown, .. }));
    }

    /// A calibration reference is free text from the producer; it must not be able to
    /// break the JSON it is carried in.
    #[test]
    fn a_calibration_reference_cannot_break_the_body() {
        let cal = Acquisition::calibrated_instrument(
            Uncertainty::new(5_000, 1_000_000).unwrap(),
            "cert \"7\"\t\r\u{1}",
        )
        .unwrap();
        let b = EnergyBinding::bind_pixels(&pixels(), 10_565, cal, Coverage::Whole, METHOD).unwrap();
        let v: serde_json::Value = serde_json::from_str(&b.to_json()).expect("still valid JSON");
        assert_eq!(EnergyBinding::from_json(&v), Ok(b));
    }

    /// A declaration below the `0.5/√3` µJ the figure's rounding adds is refused,
    /// bound or parsed: `microjoules × relativePpm` must reach 288 676.
    #[test]
    fn a_declaration_below_the_figures_resolution_is_refused() {
        let at = |ppm| {
            Acquisition::on_chip_counter(Uncertainty::new(ppm, 1_000_000).unwrap(), CounterFiltering::Unknown)
                .unwrap()
        };
        let below = || EnergyBindingError::Acquisition(AcquisitionError::BelowResolution);
        // 1 µJ at 0.5 % would state 0.005 µJ, a hundredth of its own rounding.
        assert_eq!(EnergyBinding::bind_pixels(&pixels(), 1, at(5_000), Coverage::Whole, METHOD), Err(below()));
        assert!(EnergyBinding::bind_pixels(&pixels(), 1, at(288_676), Coverage::Whole, METHOD).is_ok());
        // 10,565 µJ needs at least 28 ppm (27 × 10,565 = 285,255).
        let b = EnergyBinding::bind_pixels(&pixels(), 10_565, at(28), Coverage::Whole, METHOD).unwrap();
        let v: serde_json::Value = serde_json::from_str(&b.to_json()).unwrap();
        assert_eq!(EnergyBinding::from_json(&v), Ok(b));
        let v: serde_json::Value =
            serde_json::from_str(&v.to_string().replace("\"relativePpm\":28", "\"relativePpm\":27")).unwrap();
        assert_eq!(EnergyBinding::from_json(&v), Err(below()));
    }
}
