//! Deterministic fuzz harnesses for the reduction (the Agent C hand-off gates).
//!
//! The QUICKSTART gates this crate on two properties:
//!
//! 1. **Reduction determinism** — "the determinism property holds across 10,000
//!    randomized envelope sets": any permutation of an operation set reduces to
//!    *byte-identical* materialized state ([`run_reduction_determinism_fuzz`]).
//!    This is v0 acceptance criteria 1 (convergence) and 5 (reduction
//!    determinism), exercised at scale.
//! 2. **Equivocation order-independence** — "the equivocation harness produces
//!    `OperationSlot::Equivocated` for any duplicate-id-with-different-bytes
//!    scenario regardless of arrival order" ([`run_equivocation_fuzz`]). This is
//!    v0 acceptance criterion 3 (Pass 10's order-independence fix).
//!
//! Both harnesses are themselves deterministic: they draw from a seeded
//! SplitMix64 (the determinism crate's, reused — no `rand`, no platform
//! entropy), so a failing iteration reproduces exactly from its seed. The
//! generated sets deliberately reuse a small object-id space so deletes,
//! respellings, and inserts interact (tombstones, already-applied, conflicts),
//! and they occasionally inject equivocation and HLC-monotonicity anomalies so
//! those paths are exercised for permutation-invariance too.

use epiphany_core::{
    EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId,
    RepeatStructureId, ReplicaId, SlurId, StaffId, StaffInstanceId, TranspositionInterval,
    TypedObjectId, VoiceId,
};
use epiphany_determinism::fuzz::SplitMix64;

use crate::causal::CausalContext;
use crate::envelope::OperationEnvelope;
use crate::opset::OperationSet;
use crate::payload::OperationKindTag;
use crate::payload::{
    CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, CreateStaffInstanceOp,
    CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp,
    DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, DeleteStaffInstanceOp,
    DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp,
    ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp,
    SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp,
    SetUserSystemBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation,
};
use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId;
use crate::valuegen;
use crate::{EnvelopeHash, IntegrityAnomalyKind, OperationEffect};
use epiphany_determinism::{CanonicalDecode, CanonicalEncode};

/// Number of replicas the generator draws authors from.
const REPLICAS: u64 = 3;
/// Size of the shared object-id space (events, pitches), so operations
/// genuinely interact during reduction.
const ID_SPACE: u64 = 6;

/// A tiny extension trait for readable bounded draws.
trait Draw {
    fn below(&mut self, n: u64) -> u64;
    fn chance(&mut self, one_in: u64) -> bool;
}
impl Draw for SplitMix64 {
    #[inline]
    fn below(&mut self, n: u64) -> u64 {
        if n == 0 {
            0
        } else {
            self.next_u64() % n
        }
    }
    #[inline]
    fn chance(&mut self, one_in: u64) -> bool {
        self.below(one_in.max(1)) == 0
    }
}

/// In-place Fisher–Yates shuffle driven by the seeded generator.
fn shuffle<T>(items: &mut [T], rng: &mut SplitMix64) {
    for i in (1..items.len()).rev() {
        let j = (rng.next_u64() % (i as u64 + 1)) as usize;
        items.swap(i, j);
    }
}

fn event(n: u64) -> EventId {
    EventId::new(ReplicaId(7), n)
}
fn pitch(n: u64) -> PitchId {
    PitchId::new(ReplicaId(7), n)
}

/// Generates a random payload over the shared id space.
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
    let kind = match rng.below(28) {
        0 => {
            let voice = VoiceId::new(ReplicaId(7), rng.below(3));
            let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
            let pitches = if rng.chance(2) {
                vec![pitch(rng.below(ID_SPACE))]
            } else {
                vec![]
            };
            OperationKind::InsertEvent(InsertEventOp {
                staff_instance: StaffInstanceId::new(ReplicaId(7), 0),
                event: valuegen::insert_event_value(
                    event(rng.below(ID_SPACE)),
                    voice,
                    position,
                    MusicalDuration::whole(),
                    &pitches,
                ),
            })
        }
        1 => OperationKind::DeleteEvent(DeleteEventOp {
            event: event(rng.below(ID_SPACE)),
            tuplet_compensation: TupletCompensation::NotInTuplet,
        }),
        2 => OperationKind::RespellPitch(RespellPitchOp {
            pitch: pitch(rng.below(ID_SPACE)),
            spelling: valuegen::spelling(rng.below(4) as u8 + 1),
        }),
        3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
            structure: CrossCuttingValue::Slur(valuegen::slur(
                SlurId::new(ReplicaId(7), rng.below(ID_SPACE)),
                event(rng.below(ID_SPACE)),
                event(rng.below(ID_SPACE)),
            )),
        }),
        4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
            region: RegionId::new(ReplicaId(7), 0),
            anchor: valuegen::region_start_anchor(
                RegionId::new(ReplicaId(7), 0),
                MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
            ),
            present: rng.chance(2),
        }),
        // Group 1 (M2): event & pitch leaf-field ops over the shared id space.
        5 => OperationKind::ModifyEvent(ModifyEventOp {
            event: valuegen::insert_event_value(
                event(rng.below(ID_SPACE)),
                VoiceId::new(ReplicaId(7), rng.below(3)),
                MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
                MusicalDuration::whole(),
                &[pitch(rng.below(ID_SPACE))],
            ),
        }),
        6 => OperationKind::Transpose(TransposeOp {
            targets: vec![pitch(rng.below(ID_SPACE))],
            chromatic_steps: rng.below(5) as i32 - 2,
        }),
        7 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
            event: event(rng.below(ID_SPACE)),
            pitch: valuegen::identified_pitch(pitch(rng.below(ID_SPACE))),
        }),
        8 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
            pitch: pitch(rng.below(ID_SPACE)),
        }),
        9 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
            pitch: pitch(rng.below(ID_SPACE)),
            value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
        }),
        // Group 2 (M2): cross-cutting CRUD over the shared id space.
        10 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
            structure: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))),
        }),
        11 => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
            structure: CrossCuttingValue::Slur(valuegen::slur(
                SlurId::new(ReplicaId(7), rng.below(ID_SPACE)),
                event(rng.below(ID_SPACE)),
                event(rng.below(ID_SPACE)),
            )),
        }),
        // Group 3 (M2c): structural container CRUD over the shared id space.
        12 => OperationKind::CreateRegion(CreateRegionOp {
            region: valuegen::region(RegionId::new(ReplicaId(7), rng.below(3))),
        }),
        13 => OperationKind::DeleteRegion(DeleteRegionOp {
            region: RegionId::new(ReplicaId(7), rng.below(3)),
        }),
        14 => OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
            region: RegionId::new(ReplicaId(7), rng.below(3)),
            instance: valuegen::staff_instance(
                StaffInstanceId::new(ReplicaId(7), rng.below(3)),
                StaffId::new(ReplicaId(7), 0),
            ),
        }),
        15 => OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
            staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)),
        }),
        16 => OperationKind::CreateVoice(CreateVoiceOp {
            staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)),
            voice: valuegen::voice(VoiceId::new(ReplicaId(7), rng.below(3))),
        }),
        17 => OperationKind::DeleteVoice(DeleteVoiceOp {
            voice: VoiceId::new(ReplicaId(7), rng.below(3)),
        }),
        // Group 4 (M2d): score settings over the shared id space.
        18 => OperationKind::SetMetadata(SetMetadataOp {
            metadata: valuegen::score_metadata(rng.below(3) as u8),
        }),
        19 => OperationKind::SetMetricGrid(SetMetricGridOp {
            region: RegionId::new(ReplicaId(7), rng.below(3)),
            grid: rng.chance(2).then(valuegen::metric_grid),
        }),
        20 => OperationKind::SetUserPageBreak(SetUserPageBreakOp {
            region: RegionId::new(ReplicaId(7), 0),
            anchor: valuegen::region_start_anchor(
                RegionId::new(ReplicaId(7), 0),
                MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
            ),
            present: rng.chance(2),
        }),
        // Phase-3 first tranche: staff mint, meter/tempo overwrites, layout
        // advisory — over the same shared id space so mints, re-carries,
        // overwrites, and removals genuinely interact.
        21 => OperationKind::CreateStaff(CreateStaffOp {
            staff: valuegen::staff(
                StaffId::new(ReplicaId(7), rng.below(3)),
                epiphany_core::InstrumentId::new(ReplicaId(7), rng.below(2)),
            ),
        }),
        22 => {
            let region = RegionId::new(ReplicaId(7), rng.below(3));
            OperationKind::SetTimeSignature(SetTimeSignatureOp {
                region,
                anchor: valuegen::region_start_anchor(
                    region,
                    MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 4)),
                ),
                time_signature: (!rng.chance(3)).then(|| {
                    valuegen::time_signature(
                        epiphany_core::TimeSignatureId::new(ReplicaId(7), rng.below(3)),
                        rng.below(3) as u16 + 2,
                    )
                }),
            })
        }
        23 => {
            let region = RegionId::new(ReplicaId(7), rng.below(3));
            let at = rng.below(3) as i32 * 4;
            OperationKind::SetTempoSegment(SetTempoSegmentOp {
                region: rng.chance(2).then_some(region),
                start: valuegen::region_start_anchor(
                    region,
                    MusicalPosition(RationalTime::from_int(at)),
                ),
                segment: (!rng.chance(3)).then(|| {
                    valuegen::tempo_segment(
                        region,
                        MusicalPosition(RationalTime::from_int(at)),
                        60.0 + rng.below(4) as f64 * 30.0,
                    )
                }),
            })
        }
        // Repeat authoring (schema-major-2 revision) over the shared
        // event-id space.
        25 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
            repeat: if rng.chance(2) {
                valuegen::repeat_structure(
                    RepeatStructureId::new(ReplicaId(7), rng.below(3)),
                    event(rng.below(ID_SPACE)),
                    event(rng.below(ID_SPACE)),
                )
            } else {
                valuegen::volta_repeat(
                    RepeatStructureId::new(ReplicaId(7), rng.below(3)),
                    event(rng.below(ID_SPACE)),
                    event(rng.below(ID_SPACE)),
                )
            },
        }),
        26 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
            repeat: RepeatStructureId::new(ReplicaId(7), rng.below(3)),
        }),
        // Push 4a. The frozen `Transpose` (arm 6) keeps its own coverage: it
        // must reduce correctly forever, and only a generator will ever author
        // one again.
        27 => OperationKind::TransposeInterval(TransposeIntervalOp {
            targets: (0..1 + rng.below(2))
                .map(|_| pitch(rng.below(ID_SPACE)))
                .collect(),
            interval: TranspositionInterval {
                diatonic_steps: rng.below(5) as i32 - 2,
                chromatic_steps: rng.below(9) as i32 - 4,
            },
        }),
        _ => OperationKind::SetStaffLayout(SetStaffLayoutOp {
            staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)),
            instrument_override: None,
            staff_lines_override: rng
                .chance(2)
                .then(epiphany_core::StaffLineConfiguration::default),
            visible: rng.chance(2),
        }),
    };
    OperationPayload::Primitive(kind)
}

/// Generates a random, mostly-well-formed operation set. Per-replica stamps are
/// monotonic by construction (so most sets are anomaly-free), but the generator
/// occasionally injects equivocation (a duplicate id with mutated bytes) and an
/// HLC-monotonicity anomaly, so those paths are exercised for permutation
/// invariance too.
pub fn gen_envelope_set(rng: &mut SplitMix64, n: usize) -> Vec<OperationEnvelope> {
    let mut counters = [0u64; (REPLICAS + 1) as usize];
    let mut clocks = [0i64; (REPLICAS + 1) as usize];
    let mut stamps = vec![Vec::<(i64, u32)>::new(); (REPLICAS + 1) as usize];
    let mut envs = Vec::with_capacity(n);

    for _ in 0..n {
        let replica = 1 + rng.below(REPLICAS);
        let r = replica as usize;
        let counter = counters[r];
        let id = OperationId::new(ReplicaId(replica), counter);

        // Causal context contains prior history only. Track the maximum stamp
        // among selected predecessors so the new HLC can strictly outrank it.
        let mut ctx = CausalContext::new();
        let mut pred_max = (0i64, 0u32);
        if counter > 0 {
            ctx = ctx.with_seen(ReplicaId(replica), counter - 1);
            pred_max = pred_max.max(stamps[r][(counter - 1) as usize]);
        }
        for rr in 1..counters.len() {
            if rr == r {
                continue;
            }
            let seen = counters[rr];
            if seen > 0 && rng.chance(2) {
                let high = rng.below(seen);
                ctx = ctx.with_seen(ReplicaId(rr as u64), high);
                pred_max = pred_max.max(stamps[rr][high as usize]);
            }
        }

        clocks[r] += rng.below(3) as i64;
        let previous = stamps[r].last().copied().unwrap_or((0, 0));
        let physical = clocks[r].max(previous.0).max(pred_max.0);
        let logical = if physical == previous.0 && physical == pred_max.0 {
            previous.1.max(pred_max.1) + 1
        } else if physical == previous.0 {
            previous.1 + 1
        } else if physical == pred_max.0 {
            pred_max.1 + 1
        } else {
            0
        };
        stamps[r].push((physical, logical));
        counters[r] += 1;

        envs.push(OperationEnvelope {
            id,
            author: AuthorId(replica as u128),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(epiphany_core::WallClockTime(physical), logical),
                id,
            ),
            causal_context: ctx,
            transaction: None,
            payload: gen_payload(rng),
        });
    }

    // Occasionally inject an HLC-monotonicity anomaly: a fresh op on some
    // replica whose physical time is below an earlier one.
    if !envs.is_empty() && rng.chance(4) {
        let replica = 1 + rng.below(REPLICAS);
        let r = replica as usize;
        let counter = counters[r];
        let id = OperationId::new(ReplicaId(replica), counter);
        envs.push(OperationEnvelope {
            id,
            author: AuthorId(replica as u128),
            // physical -1 is below every generated (non-negative) clock value.
            stamp: OperationStamp::new(
                HybridLogicalClock::new(epiphany_core::WallClockTime(0), 0),
                id,
            ),
            causal_context: CausalContext::new(),
            payload: gen_payload(rng),
            transaction: None,
        });
        // The earlier op must outrank it; bump an existing op's clock high.
        if let Some(first) = envs
            .iter_mut()
            .find(|e| e.id.replica == ReplicaId(replica) && e.id.counter < counter)
        {
            first.stamp.hlc.physical_time = epiphany_core::WallClockTime(1_000_000);
        }
    }

    // Occasionally inject equivocation: a duplicate id with mutated payload.
    if !envs.is_empty() && rng.chance(4) {
        let victim = &envs[rng.below(envs.len() as u64) as usize];
        let mut twin = victim.clone();
        twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
            pitch: pitch(rng.below(ID_SPACE)),
            spelling: valuegen::spelling(6),
        }));
        if twin.envelope_hash() != victim.envelope_hash() {
            envs.push(twin);
        }
    }

    envs
}

/// Reduces an envelope set accepted in the given order, returning the canonical
/// materialized bytes.
fn reduce_in_order(envs: &[OperationEnvelope]) -> Vec<u8> {
    let mut set = OperationSet::new();
    set.accept_all(envs.iter().cloned());
    set.reduce().canonical_bytes()
}

/// Runs `iters` reduction-determinism iterations from `seed`. Each iteration
/// generates a random operation set and asserts that several random
/// *acceptance orders* reduce to byte-identical materialized state. Panics on
/// the first violation (the hand-off gate's failure condition).
pub fn run_reduction_determinism_fuzz(iters: u64, seed: u64) {
    let mut rng = SplitMix64::new(seed);
    for _ in 0..iters {
        let n = 1 + rng.below(14) as usize;
        let base = gen_envelope_set(&mut rng, n);
        let reference = reduce_in_order(&base);
        for _ in 0..3 {
            let mut perm = base.clone();
            shuffle(&mut perm, &mut rng);
            let got = reduce_in_order(&perm);
            assert_eq!(
                got, reference,
                "reduction is not permutation-invariant (n = {n})"
            );
        }
    }
}

/// Runs `iters` equivocation iterations from `seed`. Each iteration builds two
/// distinct canonical envelopes under one `OperationId`, accepts them (with a
/// few unrelated envelopes) in a random order, and asserts the slot is
/// `Equivocated`, the operation contributes no effect, and an
/// `OperationSlotEquivocated` anomaly is recorded — regardless of arrival order.
pub fn run_equivocation_fuzz(iters: u64, seed: u64) {
    let mut rng = SplitMix64::new(seed);
    for _ in 0..iters {
        let id = OperationId::new(ReplicaId(1 + rng.below(REPLICAS)), rng.below(5));

        let mk = |rng: &mut SplitMix64, spelling: u8| OperationEnvelope {
            id,
            author: AuthorId(0),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
                id,
            ),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
                pitch: pitch(0),
                spelling: valuegen::spelling(spelling),
            })),
        };
        let a = mk(&mut rng, 1);
        let b = mk(&mut rng, 2); // distinct canonical bytes (different spelling)
        debug_assert_ne!(a.envelope_hash(), b.envelope_hash());

        // A few unrelated, well-formed envelopes to vary the surrounding set.
        let noise_count = rng.below(4) as usize;
        let noise = gen_envelope_set(&mut rng, noise_count)
            .into_iter()
            .filter(|e| e.id != id)
            .collect::<Vec<_>>();

        let mut items = vec![a.clone(), b.clone()];
        items.extend(noise);
        shuffle(&mut items, &mut rng);

        let mut set = OperationSet::new();
        set.accept_all(items);

        let slot = set.slot(id).expect("slot exists for the equivocating id");
        assert!(
            slot.is_equivocated(),
            "duplicate id with different bytes must equivocate regardless of order"
        );

        let state = set.reduce();
        assert!(
            state.effects.iter().all(|(e, _)| *e != id),
            "an equivocated operation must produce no canonical effect"
        );
        assert!(
            state.anomalies.iter().any(|an| matches!(
                an.kind,
                IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
            )),
            "an equivocated slot must record an OperationSlotEquivocated anomaly"
        );
    }
}

/// Runs `iters` equivocation-*resolution* iterations from `seed` (the
/// `ResolveEquivocation` sibling of [`run_equivocation_fuzz`]). Each iteration
/// builds two distinct canonical envelopes under one `OperationId` plus a
/// `ResolveEquivocation`, embeds them in random noise, and asserts across four
/// random acceptance orders:
///
/// * with a **valid** resolve (`chosen` names a real candidate): the resolved
///   slot contributes an effect at its own id, no `OperationSlotEquivocated`
///   anomaly is recorded for it, the resolve itself applies, and every
///   permutation reduces to byte-identical [`crate::MaterializedState`];
/// * with an **invalid** resolve (`chosen` names no candidate): behavior is
///   exactly today's unresolved equivocation — the slot contributes nothing,
///   the anomaly is recorded, the resolve is a precondition no-op — and the
///   permutation invariance still holds.
pub fn run_equivocation_resolution_fuzz(iters: u64, seed: u64) {
    let mut rng = SplitMix64::new(seed);
    for _ in 0..iters {
        let id = OperationId::new(ReplicaId(1 + rng.below(REPLICAS)), rng.below(5));

        let mk = |rng: &mut SplitMix64, spelling: u8| OperationEnvelope {
            id,
            author: AuthorId(0),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
                id,
            ),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
                pitch: pitch(0),
                spelling: valuegen::spelling(spelling),
            })),
        };
        let a = mk(&mut rng, 1);
        let b = mk(&mut rng, 2); // distinct canonical bytes (different spelling)
        debug_assert_ne!(a.envelope_hash(), b.envelope_hash());

        // Valid two-thirds of the time; otherwise a hash naming no candidate.
        let valid = !rng.chance(3);
        let chosen = if !valid {
            EnvelopeHash([0xEE; 32])
        } else if rng.chance(2) {
            a.envelope_hash()
        } else {
            b.envelope_hash()
        };
        // The resolve lives on a replica the noise generator never draws
        // (noise uses 1..=REPLICAS), so its own slot can neither equivocate
        // nor land in a quarantined segment.
        let resolve_id = OperationId::new(ReplicaId(REPLICAS + 2), 0);
        let resolve = OperationEnvelope {
            id: resolve_id,
            author: AuthorId(0),
            stamp: OperationStamp::new(
                HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
                resolve_id,
            ),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: OperationPayload::ResolveEquivocation(
                crate::payload::ResolveEquivocationPayload { target: id, chosen },
            ),
        };

        let noise_count = rng.below(4) as usize;
        let noise = gen_envelope_set(&mut rng, noise_count)
            .into_iter()
            .filter(|e| e.id != id)
            .collect::<Vec<_>>();

        let mut items = vec![a.clone(), b.clone(), resolve.clone()];
        items.extend(noise);

        let mut reference: Option<Vec<u8>> = None;
        for _ in 0..4 {
            shuffle(&mut items, &mut rng);
            let mut set = OperationSet::new();
            set.accept_all(items.iter().cloned());
            let state = set.reduce();

            let slot_contributes = state.effects.iter().any(|(e, _)| *e == id);
            let anomalous = state.anomalies.iter().any(|an| matches!(
                an.kind,
                IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
            ));
            let resolve_effect = state
                .effects
                .iter()
                .find(|(e, _)| *e == resolve_id)
                .map(|(_, eff)| eff);
            if valid {
                assert!(
                    slot_contributes,
                    "a resolved slot must contribute its chosen candidate's effect"
                );
                assert!(
                    !anomalous,
                    "a resolved slot must record no OperationSlotEquivocated anomaly"
                );
                assert_eq!(
                    resolve_effect,
                    Some(&OperationEffect::Applied),
                    "the governing resolve must apply"
                );
            } else {
                assert!(
                    !slot_contributes,
                    "an unresolved equivocated slot must contribute nothing"
                );
                assert!(
                    anomalous,
                    "an unresolved equivocated slot must record its anomaly"
                );
                assert!(
                    matches!(resolve_effect, Some(OperationEffect::NoOp { .. })),
                    "an invalid resolve must be a precondition no-op, got {resolve_effect:?}"
                );
            }

            let bytes = state.canonical_bytes();
            match &reference {
                None => reference = Some(bytes),
                Some(reference) => assert_eq!(
                    &bytes, reference,
                    "equivocation resolution is not permutation-invariant"
                ),
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Decode fuzzing (P2 of the decode-hardening track; P1 covered `epiphany-core`).
//
// The operation layer exposes exactly two byte-decode surfaces:
// `MaterializedState::decode_canonical` and `OperationKindTag::decode_canonical`.
// Operation *payloads* have no decoder — `OperationKind` is encode-only — so
// there is nothing here that could accept a duplicate `TransposeInterval`
// target. When such a decoder lands it inherits the wire table's `seq^⇑` rule:
// reject a duplicate, never normalize it away.
//
// The properties, exactly as in P1: an adversarial byte string must never panic
// a decoder, and an *accepted* string must re-encode to itself. Canonical decode
// is injective, which is what content-addressing rests on.
// ---------------------------------------------------------------------------

/// How much of the decode surface a fuzz run actually reached. A harness that
/// never gets a decoder to say `Ok` proves nothing about injectivity, so the
/// run asserts on these rather than merely on the absence of a panic.
#[derive(Default, Debug, PartialEq, Eq)]
pub struct DecodeFuzzCoverage {
    pub states_accepted: u64,
    pub states_rejected: u64,
    pub tags_accepted: u64,
    pub tags_rejected: u64,
}

struct DecodeCorpus {
    states: Vec<Vec<u8>>,
    tags: Vec<Vec<u8>>,
}

/// Builds the pool of valid canonical byte strings to mutate.
///
/// Corpus depth is load-bearing. A small envelope set reduces to a state with no
/// conflicts, anomalies, pending operations, or spellings — and those branches
/// carry every canonical-order check the decoder makes. A corpus without them
/// leaves the decoder's interesting half unreached, and a green run proves
/// nothing.
///
/// So the corpus is *drawn until covered* rather than drawn once and hoped over:
/// a fixed set of sizes is seed-dependent (measured: 6 of 12 seeds failed to
/// produce all four structures). Each round adds one state per size; the loop
/// stops as soon as every structure has appeared. The final assertion is then a
/// property of the harness, not of the seed.
fn build_decode_corpus(rng: &mut SplitMix64) -> DecodeCorpus {
    const MAX_ROUNDS: usize = 12;
    let mut states = Vec::new();
    let (mut conflicts, mut anomalies, mut pending, mut spellings) = (false, false, false, false);

    for round in 0..MAX_ROUNDS {
        for n in [0usize, 1, 8, 200, 400] {
            // Skip the trivial sizes after the first round: they only ever add
            // shallow states, and one of each is enough to cover the empty and
            // near-empty encodings.
            if round > 0 && n < 200 {
                continue;
            }
            let envelopes = gen_envelope_set(rng, n);
            let mut set = OperationSet::new();
            set.accept_all(envelopes);
            let state = set.reduce();
            conflicts |= !state.conflicts.records().is_empty();
            anomalies |= !state.anomalies.is_empty();
            pending |= !state.pending.is_empty();
            spellings |= !state.spellings.is_empty();
            states.push(state.canonical_bytes());
        }
        if conflicts && anomalies && pending && spellings {
            break;
        }
    }
    assert!(
        conflicts && anomalies && pending && spellings,
        "decode corpus never reached every structure in {MAX_ROUNDS} rounds \
         (conflicts={conflicts}, anomalies={anomalies}, pending={pending}, \
         spellings={spellings}); the decoder's canonical-order checks live in \
         exactly those branches"
    );

    // EVERY tag, from the production vocabulary — not a hand-picked five. A tag
    // named nowhere is a tag whose decoder is never exercised, which is how
    // `TransposeInterval` encoded to a byte its own decoder rejected.
    let tags = OperationKindTag::PAYLOAD_FREE
        .iter()
        .copied()
        .chain(std::iter::once(OperationKindTag::Registered(
            crate::OperationKindRegistryId(0x0123_4567_89AB_CDEF),
        )))
        .map(|t| t.to_canonical_bytes())
        .collect();
    DecodeCorpus { states, tags }
}

fn pick<'a>(rng: &mut SplitMix64, pool: &'a [Vec<u8>]) -> &'a [u8] {
    &pool[(rng.next_u64() as usize) % pool.len()]
}

fn random_bytes(rng: &mut SplitMix64, n: usize) -> Vec<u8> {
    (0..n).map(|_| rng.next_u64() as u8).collect()
}

fn substitute(rng: &mut SplitMix64, bytes: &mut [u8], k: usize) {
    if bytes.is_empty() {
        return;
    }
    for _ in 0..k {
        let i = (rng.next_u64() as usize) % bytes.len();
        bytes[i] = rng.next_u64() as u8;
    }
}

/// Overwrites a random 4-byte window with a fresh, often-extreme `u32`: the
/// count/length-prefix attack a `Vec` decoder trusts for its element count.
fn corrupt_length_prefix(rng: &mut SplitMix64, bytes: &mut [u8]) {
    if bytes.len() < 4 {
        return;
    }
    let i = (rng.next_u64() as usize) % (bytes.len() - 3);
    let v: u32 = match rng.next_u64() % 3 {
        0 => u32::MAX,
        1 => (rng.next_u64() as u32) | 0x8000_0000,
        _ => rng.next_u64() as u32,
    };
    bytes[i..i + 4].copy_from_slice(&v.to_le_bytes());
}

/// One adversarial `MaterializedState` input. Strategy 1 is *unmutated* valid
/// bytes — a live check that the harness's own corpus round-trips.
fn gen_state_input(rng: &mut SplitMix64, corpus: &DecodeCorpus) -> Vec<u8> {
    match rng.next_u64() % 7 {
        0 => {
            let n = (rng.next_u64() % 512) as usize;
            random_bytes(rng, n)
        }
        1 => pick(rng, &corpus.states).to_vec(),
        2 => {
            let mut b = pick(rng, &corpus.states).to_vec();
            let k = 1 + (rng.next_u64() % 4) as usize;
            substitute(rng, &mut b, k);
            b
        }
        3 => {
            let mut b = pick(rng, &corpus.states).to_vec();
            let t = (rng.next_u64() as usize) % (b.len() + 1);
            b.truncate(t);
            b
        }
        4 => {
            let mut b = pick(rng, &corpus.states).to_vec();
            let n = 1 + (rng.next_u64() % 16) as usize;
            let tail = random_bytes(rng, n);
            b.extend_from_slice(&tail);
            b
        }
        5 => {
            let mut b = pick(rng, &corpus.states).to_vec();
            corrupt_length_prefix(rng, &mut b);
            b
        }
        // A valid `OperationKindTag` standing where a state is expected: a
        // structurally plausible, wrong-type payload.
        _ => pick(rng, &corpus.tags).to_vec(),
    }
}

/// One adversarial `OperationKindTag` input.
fn gen_tag_input(rng: &mut SplitMix64, corpus: &DecodeCorpus) -> Vec<u8> {
    match rng.next_u64() % 5 {
        0 => pick(rng, &corpus.tags).to_vec(),
        1 => {
            let mut b = pick(rng, &corpus.tags).to_vec();
            substitute(rng, &mut b, 1);
            b
        }
        2 => {
            let mut b = pick(rng, &corpus.tags).to_vec();
            let n = 1 + (rng.next_u64() % 4) as usize;
            let tail = random_bytes(rng, n);
            b.extend_from_slice(&tail);
            b
        }
        3 => {
            let mut b = pick(rng, &corpus.tags).to_vec();
            let t = (rng.next_u64() as usize) % (b.len() + 1);
            b.truncate(t);
            b
        }
        _ => {
            let n = (rng.next_u64() % 24) as usize;
            random_bytes(rng, n)
        }
    }
}

/// Runs `iters` adversarial byte-decode iterations from `seed` against the
/// operation layer's two decoders, returning what it reached. A panic, or an
/// accepted byte string that does not re-encode to itself, fails the run;
/// `seed` reproduces it exactly.
pub fn run_decode_fuzz(iters: u64, seed: u64) -> DecodeFuzzCoverage {
    let mut rng = SplitMix64::new(seed);
    let corpus = build_decode_corpus(&mut rng);
    let mut cov = DecodeFuzzCoverage::default();

    // The corpus must decode. Obvious, and it was not checked: an *unmutated*
    // valid byte string that a decoder rejects was silently tallied as a
    // rejection, like any garbage input. `OperationKindTag::TransposeInterval`
    // encoded to `[30]` and would not read back for two commits, right here in
    // the corpus, and this fuzzer reported green (Push 5 / P4).
    for bytes in &corpus.states {
        let state = crate::MaterializedState::decode_canonical(bytes)
            .expect("a valid materialized state must decode");
        assert_eq!(state.canonical_bytes(), *bytes);
    }
    for bytes in &corpus.tags {
        let tag = OperationKindTag::decode_canonical(bytes)
            .expect("a valid operation-kind tag must decode");
        assert_eq!(tag.to_canonical_bytes(), *bytes);
    }

    for _ in 0..iters {
        let bytes = gen_state_input(&mut rng, &corpus);
        match crate::MaterializedState::decode_canonical(&bytes) {
            Ok(state) => {
                cov.states_accepted += 1;
                assert_eq!(
                    state.canonical_bytes(),
                    bytes,
                    "the materialized-state decoder accepted a non-canonical byte string"
                );
            }
            Err(_) => cov.states_rejected += 1,
        }

        let tag_bytes = gen_tag_input(&mut rng, &corpus);
        match OperationKindTag::decode_canonical(&tag_bytes) {
            Ok(tag) => {
                cov.tags_accepted += 1;
                assert_eq!(
                    tag.to_canonical_bytes(),
                    tag_bytes,
                    "the OperationKindTag decoder accepted a non-canonical byte string"
                );
            }
            Err(_) => cov.tags_rejected += 1,
        }
    }
    cov
}

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

    #[test]
    fn reduction_determinism_smoke() {
        run_reduction_determinism_fuzz(500, 0xC0FFEE);
    }

    #[test]
    fn equivocation_smoke() {
        run_equivocation_fuzz(500, 0x1234_5678);
    }

    #[test]
    fn equivocation_resolution_smoke() {
        run_equivocation_resolution_fuzz(500, 0x9E50_1AE5);
    }

    #[test]
    fn generator_is_deterministic() {
        let mut a = SplitMix64::new(99);
        let mut b = SplitMix64::new(99);
        let sa = gen_envelope_set(&mut a, 10);
        let sb = gen_envelope_set(&mut b, 10);
        assert_eq!(sa, sb);
    }

    #[test]
    fn clean_generated_histories_respect_causal_stamps() {
        let mut rng = SplitMix64::new(0xCA05_A117);
        let mut checked = 0;
        for _ in 0..2_000 {
            let set = gen_envelope_set(&mut rng, 24);
            let mut accepted = OperationSet::new();
            accepted.accept_all(set);
            let singles = accepted.single_envelopes();
            if !crate::anomaly::detect_replica_anomalies(&singles).is_empty() {
                continue;
            }
            for successor in &singles {
                for predecessor in &singles {
                    if predecessor.id != successor.id
                        && successor.causal_context.covers(predecessor.id)
                    {
                        assert!(
                            predecessor.stamp.reduction_tuple() < successor.stamp.reduction_tuple(),
                            "causal predecessor did not have a lower stamp"
                        );
                    }
                }
            }
            checked += 1;
        }
        assert!(checked > 1_000, "too few clean generated histories checked");
    }

    /// Two deterministic smoke seeds over the decode surface. A deeper sweep
    /// runs the same entry point with a larger budget; a failure reproduces
    /// exactly from its seed.
    ///
    /// The coverage assertions are the point. A decode fuzzer that never gets a
    /// decoder to say `Ok` proves nothing about injectivity — it only proves the
    /// absence of a panic — so the run fails if it did not reach the accept path
    /// on both surfaces.
    #[test]
    fn decode_fuzz_smoke_seed_a() {
        let cov = run_decode_fuzz(20_000, 0x0DEC_0DE0_F022_1234);
        assert!(cov.states_accepted > 1_000, "{cov:?}");
        assert!(cov.states_rejected > 1_000, "{cov:?}");
        assert!(cov.tags_accepted > 1_000, "{cov:?}");
        assert!(cov.tags_rejected > 1_000, "{cov:?}");
    }

    #[test]
    fn decode_fuzz_smoke_seed_b() {
        let cov = run_decode_fuzz(20_000, 0xF0FA_11BA_C0DE_5EED);
        assert!(cov.states_accepted > 1_000, "{cov:?}");
        assert!(cov.tags_accepted > 1_000, "{cov:?}");
    }

    /// `objects` is a `BTreeMap` with **no per-site order check**: the decoder
    /// re-sorts it silently. Only the whole-state re-encode-and-compare guard
    /// can reject an out-of-order encoding of it, so this is the deterministic
    /// lock on that guard — the fuzzer's 2M-input sweep of the same property
    /// finds it too, but a seeded sweep is not a proof.
    ///
    /// Without the guard the decoder would be non-injective: two distinct byte
    /// strings would decode to one value, which is what content-addressing
    /// forbids.
    #[test]
    fn an_out_of_order_objects_map_is_rejected_by_the_whole_state_guard() {
        use crate::ObjectState;
        use epiphany_core::EventId;

        let lo = TypedObjectId::Event(EventId::new(ReplicaId(1), 1));
        let hi = TypedObjectId::Event(EventId::new(ReplicaId(1), 2));
        assert!(lo < hi);

        let empty = crate::MaterializedState::default().canonical_bytes();
        let mut state = crate::MaterializedState::default();
        state.objects.insert(lo, ObjectState::Live);
        state.objects.insert(hi, ObjectState::Live);
        let bytes = state.canonical_bytes();
        assert!(crate::MaterializedState::decode_canonical(&bytes).is_ok());

        // The two encodings agree until the objects count, and differ in length
        // by exactly the two entries.
        let count_at = empty
            .iter()
            .zip(bytes.iter())
            .position(|(a, b)| a != b)
            .expect("the objects count differs");
        let entry = (bytes.len() - empty.len()) / 2;
        let first = count_at + 4;
        let second = first + entry;

        // Swap the two entries: structurally valid, canonically wrong.
        let mut swapped = bytes.clone();
        swapped[first..second].copy_from_slice(&bytes[second..second + entry]);
        swapped[second..second + entry].copy_from_slice(&bytes[first..second]);
        assert_ne!(swapped, bytes, "the swap changed the bytes");

        assert_eq!(
            crate::MaterializedState::decode_canonical(&swapped),
            Err(crate::MaterializedDecodeError::NonCanonical),
            "an out-of-order objects map must be rejected, never silently re-sorted"
        );
    }

    /// **The guard is not complete for order-preserving fields.**
    ///
    /// `MaterializedState`'s decode has two layers. The whole-state
    /// re-encode-and-compare guard catches every field the decoder *normalizes*
    /// — the `BTreeMap`s re-sort and de-duplicate, so a non-canonical encoding
    /// of them cannot survive a round trip. It is blind to `Vec` fields, whose
    /// order the decoder preserves: a reordered `anomalies` or `pending` list
    /// re-encodes to exactly the bytes it came from.
    ///
    /// Those fields therefore need per-site canonical-order checks, and those
    /// checks are invisible to an injectivity fuzzer — verified by removing them
    /// and watching a 40K-input sweep stay green. The deterministic tests here
    /// are the only thing that locks them.
    ///
    /// (`effects` is a `Vec` with no order check by design: its canonical order
    /// is *reduction* order, which a decoder cannot recompute. Two orderings are
    /// two different states, so injectivity is not at stake.)
    #[test]
    fn an_out_of_order_anomaly_list_is_rejected() {
        use crate::{IntegrityAnomaly, IntegrityAnomalyKind};

        let a = IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated {
            operation_id: OperationId::new(ReplicaId(1), 1),
        });
        let b = IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated {
            operation_id: OperationId::new(ReplicaId(1), 2),
        });
        let (lo, hi) = if a.id < b.id { (a, b) } else { (b, a) };

        let empty = crate::MaterializedState::default().canonical_bytes();
        let state = crate::MaterializedState {
            anomalies: vec![lo.clone(), hi.clone()],
            ..Default::default()
        };
        let bytes = state.canonical_bytes();
        assert!(crate::MaterializedState::decode_canonical(&bytes).is_ok());

        // The anomalies are equal-length, length-prefixed entries, so swapping
        // them is a pure permutation of the byte string.
        let count_at = empty
            .iter()
            .zip(bytes.iter())
            .position(|(x, y)| x != y)
            .expect("the anomaly count differs");
        let entry = (bytes.len() - empty.len()) / 2;
        let first = count_at + 4;
        let second = first + entry;

        let mut swapped = bytes.clone();
        swapped[first..second].copy_from_slice(&bytes[second..second + entry]);
        swapped[second..second + entry].copy_from_slice(&bytes[first..second]);
        assert_ne!(swapped, bytes);

        // The decoder preserves `Vec` order, so `swapped` re-encodes to itself:
        // the whole-state guard cannot see this. Only the per-site check can.
        assert_eq!(
            crate::MaterializedState::decode_canonical(&swapped),
            Err(crate::MaterializedDecodeError::NonCanonical),
            "an out-of-order anomaly register must be rejected"
        );
    }

    /// The corpus must reach the decoder's interesting half regardless of seed.
    /// A fixed list of envelope-set sizes does not: measured, 6 of 12 seeds
    /// failed to produce all four structures, so `build_decode_corpus` draws
    /// until covered. This pins that.
    #[test]
    fn the_decode_corpus_is_covered_for_every_seed() {
        for seed in 1u64..=12 {
            let _ = build_decode_corpus(&mut SplitMix64::new(seed));
        }
    }

    /// The `pending` list carries its own per-site canonical-order check, so a
    /// reordered one is rejected even without the whole-state guard. This locks
    /// that check specifically (verified: it still passes when the guard is
    /// removed).
    #[test]
    fn a_reordered_pending_list_is_rejected() {
        use crate::PendingReason;
        let mut state = crate::MaterializedState::default();
        let lo = OperationId::new(ReplicaId(1), 1);
        let hi = OperationId::new(ReplicaId(1), 2);
        state.pending = vec![(lo, PendingReason::MissingCausalPredecessor { missing: lo })];
        let canonical = state.canonical_bytes();
        assert!(crate::MaterializedState::decode_canonical(&canonical).is_ok());

        // Two entries in canonical order decode; the same two reversed do not.
        state.pending = vec![
            (lo, PendingReason::MissingCausalPredecessor { missing: lo }),
            (hi, PendingReason::MissingCausalPredecessor { missing: lo }),
        ];
        let ok_bytes = state.canonical_bytes();
        assert!(crate::MaterializedState::decode_canonical(&ok_bytes).is_ok());

        state.pending = vec![
            (hi, PendingReason::MissingCausalPredecessor { missing: lo }),
            (lo, PendingReason::MissingCausalPredecessor { missing: lo }),
        ];
        let bad_bytes = state.canonical_bytes();
        assert_ne!(ok_bytes, bad_bytes, "the two orders differ on the wire");
        assert_eq!(
            crate::MaterializedState::decode_canonical(&bad_bytes),
            Err(crate::MaterializedDecodeError::NonCanonical),
            "a non-canonical pending order must be rejected, never re-sorted"
        );
    }
}
