//! Operation effects and the re-anchoring vocabulary (Chapter 6 §"Operation
//! Effects", §"Re-Anchoring").
//!
//! Every operation in the canonical set produces exactly one
//! [`OperationEffect`] under reduction, and those effects are *visible graph
//! facts* recorded in the materialized state's effect log — "part of canonical
//! state, not implementation detail" (Chapter 6 §6.3.2). Two replicas reducing
//! the same operation set in the canonical order produce the same effect for
//! each operation, including identical [`NoOpReason`] values.
//!
//! The failure reason on a no-op is a *typed* [`PreconditionFailureReason`],
//! never a free-form string: canonical effects must not contain free-form text,
//! since two implementations could otherwise produce divergent canonical state
//! while agreeing semantically (Chapter 6 §6.3.1).

use epiphany_core::{OperationId, TypedObjectId, VoiceId};
use epiphany_determinism::CanonicalEncode;

use crate::conflict::ConflictId;
use crate::encode::{push_canon, push_seq, push_tag};
use crate::support::{
    ExtensionPreconditionId, PreconditionFailureRegistryId, ReanchorReasonRegistryId,
    RepairKindRegistryId,
};

/// The deterministic effect of an operation under canonical reduction
/// (Chapter 6 §6.3.2). Recorded as part of materialized state.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum OperationEffect {
    /// Applied cleanly with no compensating changes.
    Applied,
    /// Applied with deterministic compensating changes (re-anchoring,
    /// attachment migration, voice promotion, …).
    AppliedWithRepair { repairs: Vec<RepairRecord> },
    /// Could not apply cleanly; a conflict record was created.
    Conflicted { conflict: ConflictId },
    /// The target was tombstoned; preserved in the operation set, no graph
    /// effect beyond the recorded effect.
    ///
    /// Reserved: the current reducer expresses this outcome as
    /// [`NoOpReason::TargetTombstoned`] instead; no reduction path produces
    /// this variant yet. It stays in the vocabulary (and its discriminant
    /// stays pinned) because the spec's effect table names it.
    TombstonedTarget { target: TypedObjectId },
    /// Reduces to no effect; the reason is recorded and is canonical.
    NoOp { reason: NoOpReason },
}

impl OperationEffect {
    fn discriminant(&self) -> u8 {
        match self {
            OperationEffect::Applied => 0,
            OperationEffect::AppliedWithRepair { .. } => 1,
            OperationEffect::Conflicted { .. } => 2,
            OperationEffect::TombstonedTarget { .. } => 3,
            OperationEffect::NoOp { .. } => 4,
        }
    }
}

impl CanonicalEncode for OperationEffect {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
        match self {
            OperationEffect::Applied => {}
            OperationEffect::AppliedWithRepair { repairs } => push_seq(out, repairs),
            OperationEffect::Conflicted { conflict } => push_canon(out, conflict),
            OperationEffect::TombstonedTarget { target } => push_canon(out, target),
            OperationEffect::NoOp { reason } => reason.encode_canonical(out),
        }
    }
}

/// Why an operation reduced to no effect (Chapter 6 §6.3.2).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum NoOpReason {
    /// The target was tombstoned by a causally-prior operation and the kind has
    /// no compensating repair rule.
    TargetTombstoned,
    /// Duplicates a causally-prior operation's effect.
    AlreadyApplied,
    /// A later operation in canonical order subsumed this one's effect.
    ///
    /// Reserved: no reduction path produces this yet — Pass 11 item 2.2
    /// decided the winner carries `Conflicted` while the superseded loser
    /// keeps `Applied`. Retagging losers with this reason is an open
    /// disposition (see the item's rationale in the ratification log).
    SupersededByLaterOperation { superseder: OperationId },
    /// An invariant precondition satisfied at authoring time fails under
    /// concurrent reduction; the intent is not preserved.
    PreconditionFailedUnderReduction { reason: PreconditionFailureReason },
    /// Belonged to a transaction whose other members failed.
    TransactionConflict,
}

impl NoOpReason {
    fn discriminant(&self) -> u8 {
        match self {
            NoOpReason::TargetTombstoned => 0,
            NoOpReason::AlreadyApplied => 1,
            NoOpReason::SupersededByLaterOperation { .. } => 2,
            NoOpReason::PreconditionFailedUnderReduction { .. } => 3,
            NoOpReason::TransactionConflict => 4,
        }
    }
}

impl CanonicalEncode for NoOpReason {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
        match self {
            NoOpReason::TargetTombstoned
            | NoOpReason::AlreadyApplied
            | NoOpReason::TransactionConflict => {}
            NoOpReason::SupersededByLaterOperation { superseder } => push_canon(out, superseder),
            NoOpReason::PreconditionFailedUnderReduction { reason } => reason.encode_canonical(out),
        }
    }
}

/// A typed precondition-failure reason (Chapter 6 §6.3.2). Never free-form
/// text — canonical effects must be byte-reproducible across implementations.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PreconditionFailureReason {
    /// The target object did not exist in the working state at reduction.
    TargetMissing,
    /// The target was tombstoned by a causally-prior operation, and a
    /// repair-on-tombstone attempt itself failed precondition.
    TargetTombstoned,
    /// The operation requires a region time model different from the one in
    /// effect at the target position.
    WrongRegionTimeModel,
    /// A declared tuplet compensation is invalid against the current structure.
    TupletCompensationInvalid,
    /// An event duration the operation specifies is invalid in the target
    /// voice or region.
    EventDurationInvalid,
    /// The target position falls outside the region declared by the envelope,
    /// or the region does not exist.
    ///
    /// Reserved: producing this requires resolving positions against region
    /// extents, which is the deferred P11-C5 resolved-position machinery.
    PositionOutsideRegion,
    /// A pitch-space or tuning-context precondition failed.
    ///
    /// Produced by `TransposeInterval` when a target position is not `Cmn`, so
    /// the interval's diatonic component has no nominal to move, or when a CMN
    /// target's enclosing chromatic structure cannot be established
    /// (operation_catalog §TransposeInterval). Detecting the former reads only
    /// the position discriminant. The latter intentionally fails closed until
    /// Push 4b replaces built-in identifier recognition with structural
    /// pitch-space resolution.
    PitchSpaceMismatch,
    /// The operation targeted a voice that does not exist or is tombstoned.
    VoiceMissing,
    /// A structural-container delete (Group 3) targeted a container that still
    /// has live children (an empty-only delete; the caller deletes contents
    /// first).
    ContainerNotEmpty,
    /// A `SetTempoSegment` write whose *resulting* tempo map would be malformed
    /// — segments out of order or overlapping, a non-constant shape missing its
    /// end data, or a carried segment whose own start disagrees with the
    /// operation's start key (operation_catalog §"Meter and Tempo Overwrites").
    TempoMapMalformed,
    /// An extension-declared precondition failed.
    ExtensionPrecondition(ExtensionPreconditionId),
    /// A registered precondition code from a versioned registry.
    Registered(PreconditionFailureRegistryId),
    /// A modify would rewrite the intrinsic content of a `SYSTEM_DERIVED`-
    /// namespace object in place, invalidating its content derivation
    /// (Pass 12, P12-K3; core spec Ch5 §System-Derived Identifiers). The
    /// sanctioned path is minting a replacement object.
    SystemDerivedContentImmutable,
    /// A create re-carried a live id with *differing* content
    /// (operation_catalog §CreateStaff): the target is not missing, its
    /// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing`
    /// misnomer at the value-retaining re-create sites).
    RecreateContentMismatch,
    /// A `TransposeInterval` target's `AcousticRealization::AbsoluteHz`
    /// overrides the tuning system (Push 4a; operation_catalog
    /// §TransposeInterval). Moving its scale position would move the notehead
    /// without moving the sound, so the operation refuses instead.
    AcousticRealizationPinned,
    /// A `TransposeInterval` would drive a target's `alteration` or `octave`
    /// past its `i8` bound (Push 4a). The frozen `Transpose` saturates here
    /// and reports success; this refuses.
    TranspositionOutOfRange,
    /// A `CreateMeasure` (or a prospective preservation check) found a
    /// resolving `time_signature` that disagrees with the effective grid's
    /// active signature (genesis tranche G3b, contract pin 8b) — distinct
    /// from the resolution failure, which is `TargetMissing`.
    MeasureMeterMismatch,
    /// A `CreateMeasure`'s carried `start` is comparable to the current last
    /// measure's start (contract pin 6) and is not strictly after it (genesis
    /// tranche G3b, contract pin 8b).
    MeasureOutOfOrder,
    /// A `CreateMeasure` ordering or boundary-distance check could not be
    /// verified: the two starts are not comparable (contract pin 6), or the
    /// delta between them is not computable (contract pin 6b) (genesis
    /// tranche G3b, contract pin 8b).
    MeasureOrderUnverifiable,
}

impl PreconditionFailureReason {
    fn discriminant(&self) -> u8 {
        match self {
            PreconditionFailureReason::TargetMissing => 0,
            PreconditionFailureReason::TargetTombstoned => 1,
            PreconditionFailureReason::WrongRegionTimeModel => 2,
            PreconditionFailureReason::TupletCompensationInvalid => 3,
            PreconditionFailureReason::EventDurationInvalid => 4,
            PreconditionFailureReason::PositionOutsideRegion => 5,
            PreconditionFailureReason::PitchSpaceMismatch => 6,
            PreconditionFailureReason::VoiceMissing => 7,
            PreconditionFailureReason::ExtensionPrecondition(_) => 8,
            PreconditionFailureReason::Registered(_) => 9,
            // Additive (Group 3); keeps the ratified 0..=9 discriminants stable.
            PreconditionFailureReason::ContainerNotEmpty => 10,
            // Additive (Phase-3 tranche, SetTempoSegment); appended past 10.
            PreconditionFailureReason::TempoMapMalformed => 11,
            // Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11.
            PreconditionFailureReason::SystemDerivedContentImmutable => 12,
            PreconditionFailureReason::RecreateContentMismatch => 13,
            // Additive (Push 4a, TransposeInterval); appended past 13.
            PreconditionFailureReason::AcousticRealizationPinned => 14,
            PreconditionFailureReason::TranspositionOutOfRange => 15,
            // Additive (Genesis tranche G3b); appended past 15.
            PreconditionFailureReason::MeasureMeterMismatch => 16,
            PreconditionFailureReason::MeasureOutOfOrder => 17,
            PreconditionFailureReason::MeasureOrderUnverifiable => 18,
        }
    }
}

impl PreconditionFailureReason {
    /// The G-minor schema-minor epoch this variant requires
    /// (`spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4, pin 1's ratified table), or
    /// `None` for a baseline (0..=9) variant — the distinct "no additive
    /// requirement" sentinel pin 3 requires, never `0`. Exhaustive with no
    /// wildcard arm (pin 2): a future variant cannot compile without an
    /// epoch assignment.
    pub fn introduced_minor(&self) -> Option<u16> {
        match self {
            PreconditionFailureReason::TargetMissing
            | PreconditionFailureReason::TargetTombstoned
            | PreconditionFailureReason::WrongRegionTimeModel
            | PreconditionFailureReason::TupletCompensationInvalid
            | PreconditionFailureReason::EventDurationInvalid
            | PreconditionFailureReason::PositionOutsideRegion
            | PreconditionFailureReason::PitchSpaceMismatch
            | PreconditionFailureReason::VoiceMissing
            | PreconditionFailureReason::ExtensionPrecondition(_)
            | PreconditionFailureReason::Registered(_) => None,
            // Minor 2 (M2c).
            PreconditionFailureReason::ContainerNotEmpty => Some(2),
            // Minor 4 (Phase-3 first tranche).
            PreconditionFailureReason::TempoMapMalformed => Some(4),
            // Minor 5 (Pass-12 G-pass).
            PreconditionFailureReason::SystemDerivedContentImmutable => Some(5),
            PreconditionFailureReason::RecreateContentMismatch => Some(5),
            // Minor 7 (Push 4a).
            PreconditionFailureReason::AcousticRealizationPinned => Some(7),
            PreconditionFailureReason::TranspositionOutOfRange => Some(7),
            // Minor 12 (Genesis tranche G3b).
            PreconditionFailureReason::MeasureMeterMismatch => Some(12),
            PreconditionFailureReason::MeasureOutOfOrder => Some(12),
            PreconditionFailureReason::MeasureOrderUnverifiable => Some(12),
        }
    }
}

impl CanonicalEncode for PreconditionFailureReason {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
        match self {
            PreconditionFailureReason::ExtensionPrecondition(id) => push_canon(out, id),
            PreconditionFailureReason::Registered(id) => push_canon(out, id),
            _ => {}
        }
    }
}

/// A deterministic compensating change made during reduction, recorded as part
/// of canonical state (Chapter 6 §6.3.2).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct RepairRecord {
    /// The kind of repair performed.
    pub kind: RepairKind,
    /// The object affected by this repair.
    pub target: TypedObjectId,
}

impl CanonicalEncode for RepairRecord {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        self.kind.encode_canonical(out);
        push_canon(out, &self.target);
    }
}

/// The kind of repair a reduction performed to preserve graph invariants in the
/// presence of a tombstoning or migrating operation (Chapter 6 §6.3.2).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RepairKind {
    /// A reference was re-anchored to a surviving target per the re-anchoring
    /// rule table (Chapter 6 §6.5).
    Reanchored {
        from: TypedObjectId,
        to: TypedObjectId,
        reason: ReanchorReason,
    },
    /// A spanner or beam lost members but enough remained to survive.
    SpannerTruncated { removed_members: Vec<TypedObjectId> },
    /// A user-content reference was orphaned (target lost, reference kept).
    Orphaned,
    /// A reference whose existence required its target was cascade-deleted.
    CascadeDeleted,
    /// An attachment transitioned to tombstoned-target state.
    AttachmentTombstoned,
    /// A voice was promoted to a system-derived identity on concurrent
    /// insertion collision (Chapter 6 §6.10 InsertEvent).
    VoicePromoted { from: VoiceId, to: VoiceId },
    /// A tuplet was compensated per the operation's declared compensation.
    TupletCompensated {
        compensation_kind: TupletCompensationKind,
    },
    /// A registered extension-defined repair kind.
    Registered(RepairKindRegistryId),
}

impl RepairKind {
    fn discriminant(&self) -> u8 {
        match self {
            RepairKind::Reanchored { .. } => 0,
            RepairKind::SpannerTruncated { .. } => 1,
            RepairKind::Orphaned => 2,
            RepairKind::CascadeDeleted => 3,
            RepairKind::AttachmentTombstoned => 4,
            RepairKind::VoicePromoted { .. } => 5,
            RepairKind::TupletCompensated { .. } => 6,
            RepairKind::Registered(_) => 7,
        }
    }
}

impl CanonicalEncode for RepairKind {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
        match self {
            RepairKind::Reanchored { from, to, reason } => {
                push_canon(out, from);
                push_canon(out, to);
                reason.encode_canonical(out);
            }
            RepairKind::SpannerTruncated { removed_members } => push_seq(out, removed_members),
            RepairKind::Orphaned
            | RepairKind::CascadeDeleted
            | RepairKind::AttachmentTombstoned => {}
            RepairKind::VoicePromoted { from, to } => {
                push_canon(out, from);
                push_canon(out, to);
            }
            RepairKind::TupletCompensated { compensation_kind } => {
                compensation_kind.encode_canonical(out)
            }
            RepairKind::Registered(id) => push_canon(out, id),
        }
    }
}

/// The reason a reference was re-anchored to a new target (Chapter 6 §6.5).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ReanchorReason {
    SameVoiceNearer,
    SameStaffInstanceNearer,
    SameStaffNearer,
    SameRegionNearer,
    ExplicitFallback,
    DeclaredByExtension(ReanchorReasonRegistryId),
    /// A rank-4 (same-canvas) proximity survivor (Pass 12, P12-C4).
    /// Semantically the next rung after `SameRegionNearer`; its wire
    /// discriminant is 6 because `DeclaredByExtension` already owned 5
    /// when it was appended.
    SameCanvasNearer,
}

impl ReanchorReason {
    fn discriminant(&self) -> u8 {
        match self {
            ReanchorReason::SameVoiceNearer => 0,
            ReanchorReason::SameStaffInstanceNearer => 1,
            ReanchorReason::SameStaffNearer => 2,
            ReanchorReason::SameRegionNearer => 3,
            ReanchorReason::ExplicitFallback => 4,
            ReanchorReason::DeclaredByExtension(_) => 5,
            // Additive (Pass-12 G-pass, P12-C4); appended past 5.
            ReanchorReason::SameCanvasNearer => 6,
        }
    }
}

impl ReanchorReason {
    /// The G-minor schema-minor epoch this variant requires, or `None` for a
    /// baseline (0..=5) variant. See
    /// [`PreconditionFailureReason::introduced_minor`] for the sentinel and
    /// exhaustiveness discipline this mirrors.
    pub fn introduced_minor(&self) -> Option<u16> {
        match self {
            ReanchorReason::SameVoiceNearer
            | ReanchorReason::SameStaffInstanceNearer
            | ReanchorReason::SameStaffNearer
            | ReanchorReason::SameRegionNearer
            | ReanchorReason::ExplicitFallback
            | ReanchorReason::DeclaredByExtension(_) => None,
            // Minor 5 (Pass-12 G-pass, P12-C4).
            ReanchorReason::SameCanvasNearer => Some(5),
        }
    }
}

impl CanonicalEncode for ReanchorReason {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
        if let ReanchorReason::DeclaredByExtension(id) = self {
            push_canon(out, id);
        }
    }
}

/// Which tuplet compensation a [`RepairKind::TupletCompensated`] applied
/// (Chapter 6 §6.10 DeleteEvent). Mirrors the operation's declared
/// [`crate::TupletCompensation`] choice.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum TupletCompensationKind {
    ReplaceWithRest,
    RewriteTuplets,
    CascadeDeleteTuplets,
}

impl TupletCompensationKind {
    fn discriminant(&self) -> u8 {
        match self {
            TupletCompensationKind::ReplaceWithRest => 0,
            TupletCompensationKind::RewriteTuplets => 1,
            TupletCompensationKind::CascadeDeleteTuplets => 2,
        }
    }
}

impl CanonicalEncode for TupletCompensationKind {
    #[inline]
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_tag(out, self.discriminant());
    }
}

/// The result of the re-anchor function for one referencing object
/// (Chapter 6 §6.5). The reduction maps each result to a [`RepairKind`] (or a
/// conflict) on the triggering operation's effect.
///
/// Reserved: the current reducer constructs [`RepairKind`] values directly
/// rather than routing through this intermediate; it becomes load-bearing when
/// the full "nearest surviving anchor" ordering (P11-C5) lands.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ReanchorResult {
    /// The reference is replaced with a reference to a new target.
    Reanchored {
        new_target: TypedObjectId,
        reason: ReanchorReason,
    },
    /// The reference is preserved but its target is tombstoned.
    TombstonedTarget,
    /// The referencing object is marked orphaned but retained.
    Orphaned,
    /// Re-anchoring cannot be performed deterministically; a conflict is
    /// recorded.
    Conflicted { conflict: ConflictId },
    /// The referencing object is cascade-deleted (tombstoned).
    CascadeDeleted,
}

#[cfg(test)]
mod tests {
    use super::*;
    use epiphany_core::{EventId, ReplicaId};

    #[test]
    fn distinct_effects_encode_distinctly() {
        let a = OperationEffect::Applied;
        let b = OperationEffect::NoOp {
            reason: NoOpReason::AlreadyApplied,
        };
        let c = OperationEffect::TombstonedTarget {
            target: TypedObjectId::Event(EventId::from_raw(1)),
        };
        assert_ne!(a.to_canonical_bytes(), b.to_canonical_bytes());
        assert_ne!(a.to_canonical_bytes(), c.to_canonical_bytes());
        assert_ne!(b.to_canonical_bytes(), c.to_canonical_bytes());
    }

    #[test]
    fn typed_precondition_reasons_are_distinguishable() {
        let a = NoOpReason::PreconditionFailedUnderReduction {
            reason: PreconditionFailureReason::VoiceMissing,
        };
        let b = NoOpReason::PreconditionFailedUnderReduction {
            reason: PreconditionFailureReason::TargetMissing,
        };
        assert_ne!(a.to_canonical_bytes(), b.to_canonical_bytes());
    }

    #[test]
    fn pass12_appended_discriminants_are_locked() {
        // Pass-12 G-pass appends (binary_format 0.4.0): the wire bytes are
        // the appended discriminants, nothing prior moved.
        assert_eq!(
            PreconditionFailureReason::SystemDerivedContentImmutable.to_canonical_bytes(),
            vec![12]
        );
        assert_eq!(
            PreconditionFailureReason::RecreateContentMismatch.to_canonical_bytes(),
            vec![13]
        );
        assert_eq!(
            ReanchorReason::SameCanvasNearer.to_canonical_bytes(),
            vec![6]
        );
        assert_eq!(
            PreconditionFailureReason::TempoMapMalformed.to_canonical_bytes(),
            vec![11]
        );
        assert_eq!(
            ReanchorReason::ExplicitFallback.to_canonical_bytes(),
            vec![4]
        );
    }

    /// (M5/M6/M7, M8/M9/M10) Genesis tranche G3b: the three new
    /// `PreconditionFailureReason` variants sit at discriminants 16, 17, 18
    /// (contract pin 8b), each at epoch `Some(12)`.
    ///
    /// **Mutation M5/M6/M7:** move any one discriminant to 19 (unused, so it
    /// compiles); must fail. **Mutation M8/M9/M10:** move any one
    /// `introduced_minor()` to `Some(11)`; must fail.
    #[test]
    fn genesis_g3b_reasons_are_16_17_18_at_epoch_12() {
        assert_eq!(
            PreconditionFailureReason::MeasureMeterMismatch.to_canonical_bytes(),
            vec![16]
        );
        assert_eq!(
            PreconditionFailureReason::MeasureOutOfOrder.to_canonical_bytes(),
            vec![17]
        );
        assert_eq!(
            PreconditionFailureReason::MeasureOrderUnverifiable.to_canonical_bytes(),
            vec![18]
        );
        assert_eq!(
            PreconditionFailureReason::MeasureMeterMismatch.introduced_minor(),
            Some(12)
        );
        assert_eq!(
            PreconditionFailureReason::MeasureOutOfOrder.introduced_minor(),
            Some(12)
        );
        assert_eq!(
            PreconditionFailureReason::MeasureOrderUnverifiable.introduced_minor(),
            Some(12)
        );
    }

    #[test]
    fn voice_promotion_repair_round_trips_shape() {
        let r = RepairRecord {
            kind: RepairKind::VoicePromoted {
                from: VoiceId::new(ReplicaId(1), 2),
                to: VoiceId::new(ReplicaId::SYSTEM_DERIVED, 99),
            },
            target: TypedObjectId::Voice(VoiceId::new(ReplicaId(1), 2)),
        };
        // Encoding is stable.
        assert_eq!(r.to_canonical_bytes(), r.to_canonical_bytes());
    }
}
