//! Canonical pane split-tree schema and validation.
//!
//! This module defines a host-agnostic pane tree model intended to be shared
//! by terminal and web adapters. It focuses on:
//!
//! - Deterministic node identifiers suitable for replay/diff.
//! - Explicit parent/child relationships for split trees.
//! - Canonical serialization snapshots with forward-compatible extension bags.
//! - Strict validation that rejects malformed trees.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

use ftui_core::geometry::{Rect, Sides};
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};

/// Current pane tree schema version.
pub const PANE_TREE_SCHEMA_VERSION: u16 = 1;

/// Current schema version for semantic pane interaction events.
///
/// Versioning policy:
/// - Additive metadata can be carried in `extensions` without a version bump.
/// - Breaking field/semantic changes must bump this version.
pub const PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION: u16 = 1;

/// Current schema version for semantic pane replay traces.
pub const PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION: u16 = 1;

/// Stable identifier for pane nodes.
///
/// `0` is reserved/invalid so IDs are always non-zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PaneId(u64);

impl PaneId {
    /// Lowest valid pane ID.
    pub const MIN: Self = Self(1);

    /// Create a new pane ID, rejecting 0.
    pub fn new(raw: u64) -> Result<Self, PaneModelError> {
        if raw == 0 {
            return Err(PaneModelError::ZeroPaneId);
        }
        Ok(Self(raw))
    }

    /// Get the raw numeric value.
    #[must_use]
    pub const fn get(self) -> u64 {
        self.0
    }

    /// Return the next ID, or an error on overflow.
    pub fn checked_next(self) -> Result<Self, PaneModelError> {
        let Some(next) = self.0.checked_add(1) else {
            return Err(PaneModelError::PaneIdOverflow { current: self });
        };
        Self::new(next)
    }
}

impl Default for PaneId {
    fn default() -> Self {
        Self::MIN
    }
}

/// Orientation of a split node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitAxis {
    Horizontal,
    Vertical,
}

/// Ratio between split children, stored in reduced form.
///
/// Interpreted as weight pair `first:second` (not a direct fraction).
/// Example: `3:2` assigns `3 / (3 + 2)` of available space to the first child.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSplitRatio {
    numerator: u32,
    denominator: u32,
}

impl PaneSplitRatio {
    /// Create and normalize a ratio.
    pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneModelError> {
        if numerator == 0 || denominator == 0 {
            return Err(PaneModelError::InvalidSplitRatio {
                numerator,
                denominator,
            });
        }
        let gcd = gcd_u32(numerator, denominator);
        Ok(Self {
            numerator: numerator / gcd,
            denominator: denominator / gcd,
        })
    }

    /// Numerator (always > 0).
    #[must_use]
    pub const fn numerator(self) -> u32 {
        if self.numerator == 0 {
            1
        } else {
            self.numerator
        }
    }

    /// Denominator (always > 0).
    #[must_use]
    pub const fn denominator(self) -> u32 {
        if self.denominator == 0 {
            1
        } else {
            self.denominator
        }
    }
}

impl Default for PaneSplitRatio {
    fn default() -> Self {
        Self {
            numerator: 1,
            denominator: 1,
        }
    }
}

/// Per-node size bounds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneConstraints {
    pub min_width: u16,
    pub min_height: u16,
    pub max_width: Option<u16>,
    pub max_height: Option<u16>,
    pub collapsible: bool,
    #[serde(default)]
    pub margin: Option<u16>,
    #[serde(default)]
    pub padding: Option<u16>,
}

impl PaneConstraints {
    /// Validate constraints for a given node.
    pub fn validate(self, node_id: PaneId) -> Result<(), PaneModelError> {
        if let Some(max_width) = self.max_width
            && max_width < self.min_width
        {
            return Err(PaneModelError::InvalidConstraint {
                node_id,
                axis: "width",
                min: self.min_width,
                max: max_width,
            });
        }
        if let Some(max_height) = self.max_height
            && max_height < self.min_height
        {
            return Err(PaneModelError::InvalidConstraint {
                node_id,
                axis: "height",
                min: self.min_height,
                max: max_height,
            });
        }
        Ok(())
    }
}

impl Default for PaneConstraints {
    fn default() -> Self {
        Self {
            min_width: 1,
            min_height: 1,
            max_width: None,
            max_height: None,
            collapsible: false,
            margin: Some(PANE_DEFAULT_MARGIN_CELLS),
            padding: Some(PANE_DEFAULT_PADDING_CELLS),
        }
    }
}

/// Leaf payload for pane content identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneLeaf {
    /// Host-provided stable surface key (for replay/diff mapping).
    pub surface_key: String,
    /// Forward-compatible extension bag.
    #[serde(
        default,
        rename = "leaf_extensions",
        skip_serializing_if = "BTreeMap::is_empty"
    )]
    pub extensions: BTreeMap<String, String>,
}

impl PaneLeaf {
    /// Build a leaf with a stable surface key.
    #[must_use]
    pub fn new(surface_key: impl Into<String>) -> Self {
        Self {
            surface_key: surface_key.into(),
            extensions: BTreeMap::new(),
        }
    }
}

/// Split payload with child references.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSplit {
    pub axis: SplitAxis,
    pub ratio: PaneSplitRatio,
    pub first: PaneId,
    pub second: PaneId,
}

/// Node payload variant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PaneNodeKind {
    Leaf(PaneLeaf),
    Split(PaneSplit),
}

/// Serializable node record in the canonical schema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneNodeRecord {
    pub id: PaneId,
    #[serde(default)]
    pub parent: Option<PaneId>,
    #[serde(default)]
    pub constraints: PaneConstraints,
    #[serde(flatten)]
    pub kind: PaneNodeKind,
    /// Forward-compatible extension bag.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub extensions: BTreeMap<String, String>,
}

impl PaneNodeRecord {
    /// Construct a leaf node record.
    #[must_use]
    pub fn leaf(id: PaneId, parent: Option<PaneId>, leaf: PaneLeaf) -> Self {
        Self {
            id,
            parent,
            constraints: PaneConstraints::default(),
            kind: PaneNodeKind::Leaf(leaf),
            extensions: BTreeMap::new(),
        }
    }

    /// Construct a split node record.
    #[must_use]
    pub fn split(id: PaneId, parent: Option<PaneId>, split: PaneSplit) -> Self {
        Self {
            id,
            parent,
            constraints: PaneConstraints::default(),
            kind: PaneNodeKind::Split(split),
            extensions: BTreeMap::new(),
        }
    }
}

/// Canonical serialized pane tree shape.
///
/// The extension maps are reserved for forward-compatible fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneTreeSnapshot {
    #[serde(default = "default_schema_version")]
    pub schema_version: u16,
    pub root: PaneId,
    pub next_id: PaneId,
    pub nodes: Vec<PaneNodeRecord>,
    #[serde(default)]
    pub extensions: BTreeMap<String, String>,
}

fn default_schema_version() -> u16 {
    PANE_TREE_SCHEMA_VERSION
}

impl PaneTreeSnapshot {
    /// Canonicalize node ordering by ID for deterministic serialization.
    pub fn canonicalize(&mut self) {
        self.nodes.sort_by_key(|node| node.id);
    }

    /// Deterministic hash for diagnostics over serialized tree state.
    #[must_use]
    pub fn state_hash(&self) -> u64 {
        snapshot_state_hash(self)
    }

    /// Inspect invariants and emit a structured diagnostics report.
    #[must_use]
    pub fn invariant_report(&self) -> PaneInvariantReport {
        build_invariant_report(self)
    }

    /// Attempt deterministic safe repairs for recoverable invariant issues.
    ///
    /// Safety guardrail: any unrepairable error in the pre-repair report causes
    /// this method to fail without modifying topology.
    pub fn repair_safe(self) -> Result<PaneRepairOutcome, PaneRepairError> {
        repair_snapshot_safe(self)
    }
}

/// Severity for one invariant finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneInvariantSeverity {
    Error,
    Warning,
}

/// Stable code for invariant findings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneInvariantCode {
    UnsupportedSchemaVersion,
    DuplicateNodeId,
    MissingRoot,
    RootHasParent,
    MissingParent,
    MissingChild,
    MultipleParents,
    ParentMismatch,
    SelfReferentialSplit,
    DuplicateSplitChildren,
    InvalidSplitRatio,
    InvalidConstraint,
    CycleDetected,
    UnreachableNode,
    NextIdNotGreaterThanExisting,
}

/// One actionable invariant finding.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInvariantIssue {
    pub code: PaneInvariantCode,
    pub severity: PaneInvariantSeverity,
    pub repairable: bool,
    pub node_id: Option<PaneId>,
    pub related_node: Option<PaneId>,
    pub message: String,
}

/// Structured invariant report over a pane tree snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInvariantReport {
    pub snapshot_hash: u64,
    pub issues: Vec<PaneInvariantIssue>,
}

impl PaneInvariantReport {
    /// Return true if any error-level finding exists.
    #[must_use]
    pub fn has_errors(&self) -> bool {
        self.issues
            .iter()
            .any(|issue| issue.severity == PaneInvariantSeverity::Error)
    }

    /// Return true if any unrepairable error-level finding exists.
    #[must_use]
    pub fn has_unrepairable_errors(&self) -> bool {
        self.issues
            .iter()
            .any(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
    }
}

/// One deterministic repair action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum PaneRepairAction {
    ReparentNode {
        node_id: PaneId,
        before_parent: Option<PaneId>,
        after_parent: Option<PaneId>,
    },
    NormalizeRatio {
        node_id: PaneId,
        before_numerator: u32,
        before_denominator: u32,
        after_numerator: u32,
        after_denominator: u32,
    },
    RemoveOrphanNode {
        node_id: PaneId,
    },
    BumpNextId {
        before: PaneId,
        after: PaneId,
    },
}

/// Outcome from successful safe repair pass.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneRepairOutcome {
    pub before_hash: u64,
    pub after_hash: u64,
    pub report_before: PaneInvariantReport,
    pub report_after: PaneInvariantReport,
    pub actions: Vec<PaneRepairAction>,
    pub tree: PaneTree,
}

/// Failure reason for safe repair.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneRepairFailure {
    UnsafeIssuesPresent { codes: Vec<PaneInvariantCode> },
    ValidationFailed { error: PaneModelError },
}

impl fmt::Display for PaneRepairFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsafeIssuesPresent { codes } => {
                write!(f, "snapshot contains unsafe invariant issues: {codes:?}")
            }
            Self::ValidationFailed { error } => {
                write!(f, "repaired snapshot failed validation: {error}")
            }
        }
    }
}

impl std::error::Error for PaneRepairFailure {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        if let Self::ValidationFailed { error } = self {
            return Some(error);
        }
        None
    }
}

/// Error payload for repair attempts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneRepairError {
    pub before_hash: u64,
    pub report: PaneInvariantReport,
    pub reason: PaneRepairFailure,
}

impl fmt::Display for PaneRepairError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "pane repair failed: {} (before_hash={:#x}, issues={})",
            self.reason,
            self.before_hash,
            self.report.issues.len()
        )
    }
}

impl std::error::Error for PaneRepairError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.reason)
    }
}

/// Concrete layout result for a solved pane tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneLayout {
    pub area: Rect,
    rects: BTreeMap<PaneId, Rect>,
}

impl PaneLayout {
    /// Lookup rectangle for a specific pane node.
    #[must_use]
    pub fn rect(&self, node_id: PaneId) -> Option<Rect> {
        self.rects.get(&node_id).copied()
    }

    /// Iterate all solved rectangles in deterministic ID order.
    pub fn iter(&self) -> impl Iterator<Item = (PaneId, Rect)> + '_ {
        self.rects.iter().map(|(node_id, rect)| (*node_id, *rect))
    }

    /// Classify pointer hit-test against any edge/corner grip for a pane rect.
    #[must_use]
    pub fn classify_resize_grip(
        &self,
        node_id: PaneId,
        pointer: PanePointerPosition,
        inset_cells: f64,
    ) -> Option<PaneResizeGrip> {
        let rect = self.rect(node_id)?;
        classify_resize_grip(rect, pointer, inset_cells)
    }

    /// Default visual pane rectangle with baseline margin and padding applied.
    ///
    /// This provides Tailwind-like breathing room around pane content by
    /// default while remaining deterministic and constraint-safe.
    #[must_use]
    pub fn visual_rect(&self, node_id: PaneId) -> Option<Rect> {
        let rect = self.rect(node_id)?;
        let with_margin = rect.inner(Sides::all(PANE_DEFAULT_MARGIN_CELLS));
        let with_padding = with_margin.inner(Sides::all(PANE_DEFAULT_PADDING_CELLS));
        if with_padding.width == 0 || with_padding.height == 0 {
            Some(with_margin)
        } else {
            Some(with_padding)
        }
    }

    /// Visual pane rectangle with custom margin/padding from constraints.
    #[must_use]
    pub fn visual_rect_with_constraints(
        &self,
        node_id: PaneId,
        constraints: &PaneConstraints,
    ) -> Option<Rect> {
        let rect = self.rect(node_id)?;
        let margin = constraints.margin.unwrap_or(PANE_DEFAULT_MARGIN_CELLS);
        let padding = constraints.padding.unwrap_or(PANE_DEFAULT_PADDING_CELLS);
        let with_margin = rect.inner(Sides::all(margin));
        let with_padding = with_margin.inner(Sides::all(padding));
        if with_padding.width == 0 || with_padding.height == 0 {
            Some(with_margin)
        } else {
            Some(with_padding)
        }
    }

    /// Compute the outer bounding box of a pane cluster in layout space.
    #[must_use]
    pub fn cluster_bounds(&self, nodes: &BTreeSet<PaneId>) -> Option<Rect> {
        if nodes.is_empty() {
            return None;
        }
        let mut min_x: Option<u16> = None;
        let mut min_y: Option<u16> = None;
        let mut max_x: Option<u16> = None;
        let mut max_y: Option<u16> = None;

        for node_id in nodes {
            let rect = self.rect(*node_id)?;
            min_x = Some(min_x.map_or(rect.x, |v| v.min(rect.x)));
            min_y = Some(min_y.map_or(rect.y, |v| v.min(rect.y)));
            let right = rect.x.saturating_add(rect.width);
            let bottom = rect.y.saturating_add(rect.height);
            max_x = Some(max_x.map_or(right, |v| v.max(right)));
            max_y = Some(max_y.map_or(bottom, |v| v.max(bottom)));
        }

        let left = min_x?;
        let top = min_y?;
        let right = max_x?;
        let bottom = max_y?;
        Some(Rect::new(
            left,
            top,
            right.saturating_sub(left).max(1),
            bottom.saturating_sub(top).max(1),
        ))
    }
}

/// Default radius for magnetic docking attraction in cell units.
pub const PANE_MAGNETIC_FIELD_CELLS: f64 = 6.0;

/// Default inset from pane edges used to classify edge/corner grips.
pub const PANE_EDGE_GRIP_INSET_CELLS: f64 = 1.5;

/// Default pane margin in cell units.
pub const PANE_DEFAULT_MARGIN_CELLS: u16 = 1;

/// Default pane padding in cell units.
pub const PANE_DEFAULT_PADDING_CELLS: u16 = 1;

/// Docking zones for magnetic insertion previews.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneDockZone {
    Left,
    Right,
    Top,
    Bottom,
    Center,
}

/// One magnetic docking preview candidate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaneDockPreview {
    pub target: PaneId,
    pub zone: PaneDockZone,
    /// Distance-weighted score; higher means stronger attraction.
    pub score: f64,
    /// Ghost rectangle to visualize the insertion/drop target.
    pub ghost_rect: Rect,
}

/// Resize grip classification for any-edge / any-corner interaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneResizeGrip {
    Left,
    Right,
    Top,
    Bottom,
    TopLeft,
    TopRight,
    BottomLeft,
    BottomRight,
}

impl PaneResizeGrip {
    #[must_use]
    const fn horizontal_edge(self) -> Option<bool> {
        match self {
            Self::Left | Self::TopLeft | Self::BottomLeft => Some(false),
            Self::Right | Self::TopRight | Self::BottomRight => Some(true),
            Self::Top | Self::Bottom => None,
        }
    }

    #[must_use]
    const fn vertical_edge(self) -> Option<bool> {
        match self {
            Self::Top | Self::TopLeft | Self::TopRight => Some(false),
            Self::Bottom | Self::BottomLeft | Self::BottomRight => Some(true),
            Self::Left | Self::Right => None,
        }
    }
}

/// Pointer motion summary used by pressure-sensitive policies.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaneMotionVector {
    pub delta_x: i32,
    pub delta_y: i32,
    /// Cells per second.
    pub speed: f64,
    /// Number of direction sign flips observed in this gesture window.
    pub direction_changes: u16,
}

impl PaneMotionVector {
    #[must_use]
    pub fn from_delta(delta_x: i32, delta_y: i32, elapsed_ms: u32, direction_changes: u16) -> Self {
        let elapsed = f64::from(elapsed_ms.max(1)) / 1_000.0;
        let dx = f64::from(delta_x);
        let dy = f64::from(delta_y);
        let distance = (dx * dx + dy * dy).sqrt();
        Self {
            delta_x,
            delta_y,
            speed: distance / elapsed,
            direction_changes,
        }
    }
}

/// Inertial throw profile used after drag release.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PaneInertialThrow {
    pub velocity_x: f64,
    pub velocity_y: f64,
    /// Exponential velocity damping per second. Higher means quicker settle.
    pub damping: f64,
    /// Projection horizon used for target preview/landing selection.
    pub horizon_ms: u16,
}

impl PaneInertialThrow {
    #[must_use]
    pub fn from_motion(motion: PaneMotionVector) -> Self {
        let dx = f64::from(motion.delta_x);
        let dy = f64::from(motion.delta_y);
        let magnitude = (dx * dx + dy * dy).sqrt();
        let direction_x = if magnitude <= f64::EPSILON {
            0.0
        } else {
            dx / magnitude
        };
        let direction_y = if magnitude <= f64::EPSILON {
            0.0
        } else {
            dy / magnitude
        };
        let speed = motion.speed.clamp(0.0, 220.0);
        let speed_curve = (speed / 220.0).clamp(0.0, 1.0).powf(0.72);
        let noise_penalty = (f64::from(motion.direction_changes) / 10.0).clamp(0.0, 1.0);
        let coherence = (1.0 - 0.55 * noise_penalty).clamp(0.35, 1.0);
        let projected_velocity = (10.0 + speed * 0.55) * coherence;
        Self {
            velocity_x: direction_x * projected_velocity,
            velocity_y: direction_y * projected_velocity,
            damping: (9.2 - speed_curve * 4.0 + noise_penalty * 2.4).clamp(4.8, 10.5),
            horizon_ms: (140.0 + speed_curve * 220.0).round().clamp(120.0, 380.0) as u16,
        }
    }

    #[must_use]
    pub fn projected_pointer(self, start: PanePointerPosition) -> PanePointerPosition {
        let dt = f64::from(self.horizon_ms) / 1_000.0;
        let attenuation = (-self.damping * dt).exp();
        let gain = if self.damping <= f64::EPSILON {
            dt
        } else {
            (1.0 - attenuation) / self.damping
        };
        let projected_x = f64::from(start.x) + self.velocity_x * gain;
        let projected_y = f64::from(start.y) + self.velocity_y * gain;
        PanePointerPosition::new(round_f64_to_i32(projected_x), round_f64_to_i32(projected_y))
    }
}

/// Full affordance emphasis, in basis points (10_000 = 100%).
pub const PANE_AFFORDANCE_EMPHASIS_FULL_BPS: u16 = 10_000;

/// Deterministic micro-animation policy for pane affordances (bd-18yus).
///
/// Pane affordances (splitter handles, focus emphasis, snap previews) gain a
/// subtle emphasis ramp when they activate — a hover fade-in, a slow active
/// pulse — so a state change reads as motion rather than a hard cut. The policy
/// is host-agnostic and reports only a bounded emphasis weight in basis points
/// (`[0, 10_000]`); hosts map that to blend weight / brightness themselves.
///
/// Design properties:
///
/// - **Deterministic.** Emphasis is a pure function of an integer phase tick
///   supplied by the caller (a frame counter / animation clock), never a wall
///   clock, and uses integer-only math — so replays and snapshots are
///   byte-stable across platforms with no floating-point drift.
/// - **Reduced-motion safe.** When [`reduced_motion`](Self::reduced_motion) is
///   set, every ramp collapses to a step function: emphasis jumps to its
///   terminal value on the first frame. No semantic cue is lost — the affordance
///   still changes state — only the in-between motion is removed, satisfying the
///   reduced-motion behavior-parity requirement.
/// - **Bounded & cheap.** Each query is constant-time, allocation-free integer
///   arithmetic (a couple of multiplies and a divide), so per-affordance,
///   per-frame overhead is negligible even with many splitters on screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneAffordanceMotion {
    /// When set, all ramps collapse to instantaneous steps (no in-between
    /// frames), preserving the state change without motion.
    pub reduced_motion: bool,
    /// Frames over which a hover emphasis fades in (≈100 ms at 60 fps by
    /// default). Zero means "instant".
    pub fade_in_frames: u16,
    /// Period of the active-state pulse in frames (≈0.8 s at 60 fps by
    /// default). Zero disables the pulse (steady full emphasis).
    pub pulse_period_frames: u16,
    /// Low point of the active pulse in basis points; the pulse oscillates
    /// between this floor and full emphasis.
    pub pulse_floor_bps: u16,
}

impl Default for PaneAffordanceMotion {
    fn default() -> Self {
        Self {
            reduced_motion: false,
            fade_in_frames: 6,
            pulse_period_frames: 48,
            pulse_floor_bps: 8_000,
        }
    }
}

impl PaneAffordanceMotion {
    /// The reduced-motion preset: identical timing fields, but every ramp is
    /// stepped. Use this when the host reports a reduced-motion preference.
    #[must_use]
    pub fn reduced() -> Self {
        Self {
            reduced_motion: true,
            ..Self::default()
        }
    }

    /// Apply a reduced-motion preference to this policy, returning the adjusted
    /// copy. Lets a host derive the effective policy from a preference flag.
    #[must_use]
    pub fn with_reduced_motion(mut self, reduced_motion: bool) -> Self {
        self.reduced_motion = reduced_motion;
        self
    }

    /// Hover emphasis ramp in basis points.
    ///
    /// `elapsed_frames` counts frames since the affordance entered the hovered
    /// state. The ramp is a quadratic ease-out from 0 to full over
    /// [`fade_in_frames`](Self::fade_in_frames). Under reduced motion (or a zero
    /// fade) it returns full emphasis immediately.
    #[must_use]
    pub fn hover_emphasis_bps(&self, elapsed_frames: u16) -> u16 {
        if self.reduced_motion || self.fade_in_frames == 0 {
            return PANE_AFFORDANCE_EMPHASIS_FULL_BPS;
        }
        affordance_ease_out_bps(elapsed_frames.min(self.fade_in_frames), self.fade_in_frames)
    }

    /// Active (dragging) emphasis pulse in basis points.
    ///
    /// `phase_frames` is a free-running frame counter; the pulse smoothly
    /// ping-pongs between [`pulse_floor_bps`](Self::pulse_floor_bps) and full
    /// emphasis over [`pulse_period_frames`](Self::pulse_period_frames). Under
    /// reduced motion (or a zero period) it returns steady full emphasis.
    #[must_use]
    pub fn active_pulse_bps(&self, phase_frames: u64) -> u16 {
        if self.reduced_motion || self.pulse_period_frames == 0 {
            return PANE_AFFORDANCE_EMPHASIS_FULL_BPS;
        }
        let period = self.pulse_period_frames;
        let half = period / 2;
        let p = (phase_frames % u64::from(period)) as u16;
        // Smooth ping-pong: ease up to the peak at the midpoint, then back down.
        let ramp = if p < half {
            affordance_ease_out_bps(p, half)
        } else {
            affordance_ease_out_bps(period - p, period - half)
        };
        let span = PANE_AFFORDANCE_EMPHASIS_FULL_BPS - self.pulse_floor_bps;
        self.pulse_floor_bps + ((u32::from(ramp) * u32::from(span)) / 10_000) as u16
    }
}

/// Integer quadratic ease-out from 0 to 10_000 bps over `total` frames.
///
/// `ease_out(x) = 1 - (1 - x)^2` with `x = t / total`, evaluated in integer
/// space so results are identical on every platform.
fn affordance_ease_out_bps(t: u16, total: u16) -> u16 {
    if total == 0 {
        return PANE_AFFORDANCE_EMPHASIS_FULL_BPS;
    }
    let t = t.min(total);
    let remaining = u64::from(total - t);
    let total2 = u64::from(total) * u64::from(total);
    let drop = (10_000u64 * remaining * remaining) / total2;
    (10_000 - drop) as u16
}

/// Dynamic snap aggressiveness derived from drag pressure cues.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanePressureSnapProfile {
    /// Relative snap strength (0..=10_000). Higher means stronger canonical snap.
    pub strength_bps: u16,
    /// Effective hysteresis window used for sticky docking/snap.
    pub hysteresis_bps: u16,
}

impl PanePressureSnapProfile {
    /// Compute pressure profile from gesture speed and direction noise.
    ///
    /// Slow/stable drags reduce snap force for precision; fast drags with
    /// consistent direction increase snap force for canonical layouts.
    #[must_use]
    pub fn from_motion(motion: PaneMotionVector) -> Self {
        let abs_dx = f64::from(motion.delta_x.unsigned_abs());
        let abs_dy = f64::from(motion.delta_y.unsigned_abs());
        let axis_dominance = (abs_dx.max(abs_dy) / (abs_dx + abs_dy).max(1.0)).clamp(0.5, 1.0);
        let speed_factor = (motion.speed / 70.0).clamp(0.0, 1.0).powf(0.78);
        let noise_penalty = (f64::from(motion.direction_changes) / 7.0).clamp(0.0, 1.0);
        let confidence =
            (speed_factor * (0.65 + axis_dominance * 0.35) * (1.0 - noise_penalty * 0.72))
                .clamp(0.0, 1.0);
        let strength = (1_500.0 + confidence.powf(0.85) * 8_500.0).round() as u16;
        let hysteresis = (60.0 + confidence * 500.0).round() as u16;
        Self {
            strength_bps: strength.min(10_000),
            hysteresis_bps: hysteresis.min(2_000),
        }
    }

    #[must_use]
    pub fn apply_to_tuning(self, base: PaneSnapTuning) -> PaneSnapTuning {
        let scaled_step = ((u32::from(base.step_bps) * (11_000 - u32::from(self.strength_bps)))
            / 10_000)
            .clamp(100, 10_000);
        PaneSnapTuning {
            step_bps: scaled_step as u16,
            hysteresis_bps: self.hysteresis_bps.max(base.hysteresis_bps),
        }
    }
}

/// Result of planning a side/corner resize from one pointer sample.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneEdgeResizePlan {
    pub leaf: PaneId,
    pub grip: PaneResizeGrip,
    pub operations: Vec<PaneOperation>,
}

/// Planned pane move with organic reflow semantics.
#[derive(Debug, Clone, PartialEq)]
pub struct PaneReflowMovePlan {
    pub source: PaneId,
    pub pointer: PanePointerPosition,
    pub projected_pointer: PanePointerPosition,
    pub preview: PaneDockPreview,
    pub snap_profile: PanePressureSnapProfile,
    pub operations: Vec<PaneOperation>,
}

/// Errors while deriving edge/corner resize plans.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneEdgeResizePlanError {
    MissingLeaf { leaf: PaneId },
    NodeNotLeaf { node: PaneId },
    MissingLayoutRect { node: PaneId },
    NoAxisSplit { leaf: PaneId, axis: SplitAxis },
    InvalidRatio { numerator: u32, denominator: u32 },
}

impl fmt::Display for PaneEdgeResizePlanError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingLeaf { leaf } => write!(f, "pane leaf {} not found", leaf.get()),
            Self::NodeNotLeaf { node } => write!(f, "node {} is not a leaf", node.get()),
            Self::MissingLayoutRect { node } => {
                write!(f, "layout missing rectangle for node {}", node.get())
            }
            Self::NoAxisSplit { leaf, axis } => {
                write!(
                    f,
                    "no ancestor split on {axis:?} axis for leaf {}",
                    leaf.get()
                )
            }
            Self::InvalidRatio {
                numerator,
                denominator,
            } => write!(
                f,
                "invalid planned ratio {numerator}/{denominator} for edge resize"
            ),
        }
    }
}

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

/// Errors while planning a directly-addressed splitter resize/nudge.
///
/// Unlike [`PaneEdgeResizePlanError`] (which is keyed by a leaf and walks to its
/// nearest ancestor split), these errors describe a [`PaneResizeTarget`] that
/// names a split node directly — the form produced by host splitter hit-testing
/// and the [`PaneDragResizeMachine`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneSplitterResizePlanError {
    /// The target split id is absent from the tree.
    MissingSplit { split: PaneId },
    /// The target node exists but is a leaf, not a split.
    NotASplit { node: PaneId },
    /// The split exists but its axis disagrees with the target axis.
    AxisMismatch {
        split: PaneId,
        expected: SplitAxis,
        actual: SplitAxis,
    },
    /// The current layout has no rectangle for the target split.
    MissingLayoutRect { node: PaneId },
    /// The derived ratio could not be constructed.
    InvalidRatio { numerator: u32, denominator: u32 },
}

impl fmt::Display for PaneSplitterResizePlanError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingSplit { split } => {
                write!(f, "splitter target {} not found", split.get())
            }
            Self::NotASplit { node } => write!(f, "node {} is a leaf, not a split", node.get()),
            Self::AxisMismatch {
                split,
                expected,
                actual,
            } => write!(
                f,
                "split {} has axis {actual:?} but target requested {expected:?}",
                split.get()
            ),
            Self::MissingLayoutRect { node } => {
                write!(f, "layout missing rectangle for split {}", node.get())
            }
            Self::InvalidRatio {
                numerator,
                denominator,
            } => write!(
                f,
                "invalid planned ratio {numerator}/{denominator} for splitter resize"
            ),
        }
    }
}

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

/// Errors while planning reflow moves and docking previews.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneReflowPlanError {
    MissingSource { source: PaneId },
    NoDockTarget,
    SourceCannotMoveRoot { source: PaneId },
    InvalidRatio { numerator: u32, denominator: u32 },
}

impl fmt::Display for PaneReflowPlanError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingSource { source } => write!(f, "source node {} not found", source.get()),
            Self::NoDockTarget => write!(f, "no magnetic docking target available"),
            Self::SourceCannotMoveRoot { source } => {
                write!(
                    f,
                    "source node {} is root and cannot be reflow-moved",
                    source.get()
                )
            }
            Self::InvalidRatio {
                numerator,
                denominator,
            } => write!(f, "invalid reflow ratio {numerator}/{denominator}"),
        }
    }
}

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

/// Multi-pane selection state for group interactions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PaneSelectionState {
    pub anchor: Option<PaneId>,
    pub selected: BTreeSet<PaneId>,
}

/// Planned group transform preserving the internal cluster.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneGroupTransformPlan {
    pub members: Vec<PaneId>,
    pub operations: Vec<PaneOperation>,
}

impl PaneSelectionState {
    /// Toggle selection with shift-like additive semantics.
    pub fn shift_toggle(&mut self, pane_id: PaneId) {
        if self.selected.contains(&pane_id) {
            let _ = self.selected.remove(&pane_id);
            if self.anchor == Some(pane_id) {
                self.anchor = self.selected.iter().next().copied();
            }
        } else {
            let _ = self.selected.insert(pane_id);
            if self.anchor.is_none() {
                self.anchor = Some(pane_id);
            }
        }
    }

    #[must_use]
    pub fn as_sorted_vec(&self) -> Vec<PaneId> {
        self.selected.iter().copied().collect()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.selected.is_empty()
    }
}

/// High-level adaptive layout topology modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneLayoutIntelligenceMode {
    Focus,
    Compare,
    Monitor,
    Compact,
}

/// One persistent timeline event for deterministic undo/redo/replay.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimelineEntry {
    pub sequence: u64,
    pub operation_id: u64,
    pub operation: PaneOperation,
    pub before_hash: u64,
    pub after_hash: u64,
}

/// One replay checkpoint in the interaction timeline.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimelineCheckpoint {
    pub applied_len: usize,
    pub snapshot: PaneTreeSnapshot,
}

/// Replay diagnostics for the currently selected timeline cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimelineReplayDiagnostics {
    pub entry_count: usize,
    pub cursor: usize,
    pub checkpoint_count: usize,
    pub checkpoint_interval: usize,
    pub checkpoint_hit: bool,
    pub replay_start_idx: usize,
    pub replay_depth: usize,
}

/// Auditable checkpoint-spacing decision derived from measured replay costs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimelineCheckpointDecision {
    pub checkpoint_interval: usize,
    pub estimated_snapshot_cost_ns: u128,
    pub estimated_replay_step_cost_ns: u128,
    pub estimated_replay_depth_ns: u128,
}

/// Deterministic retained-state telemetry for pane timeline memory analysis.
///
/// Byte counts are conservative shallow estimates plus explicitly tracked
/// payload bytes. They are intended for comparing timeline strategies and
/// retention policies, not for replacing allocator-level profiling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimelineRetentionDiagnostics {
    pub entry_count: usize,
    pub cursor: usize,
    pub redo_entry_count: usize,
    pub checkpoint_count: usize,
    pub checkpoint_interval: usize,
    pub max_entries: usize,
    pub baseline_present: bool,
    pub retained_snapshot_count: usize,
    pub baseline_node_count: usize,
    pub checkpoint_node_count: usize,
    pub retained_snapshot_node_count: usize,
    pub retained_leaf_payload_bytes: usize,
    pub retained_extension_entry_count: usize,
    pub retained_extension_payload_bytes: usize,
    pub retained_operation_payload_bytes: usize,
    pub estimated_entry_struct_bytes: usize,
    pub estimated_checkpoint_struct_bytes: usize,
    pub estimated_snapshot_struct_bytes: usize,
    pub estimated_total_retained_bytes: usize,
}

/// Persistent interaction timeline with undo/redo cursor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInteractionTimeline {
    /// Baseline tree before first recorded mutation.
    pub baseline: Option<PaneTreeSnapshot>,
    /// Full operation history in deterministic order.
    pub entries: Vec<PaneInteractionTimelineEntry>,
    /// Number of entries currently applied (<= entries.len()).
    pub cursor: usize,
    /// Deterministic replay checkpoints keyed by applied_len.
    pub checkpoints: Vec<PaneInteractionTimelineCheckpoint>,
    /// Entry spacing used when materializing checkpoints.
    pub checkpoint_interval: usize,
    /// Maximum retained history entries. A value of 0 disables pruning.
    #[serde(default = "default_pane_timeline_max_entries")]
    pub max_entries: usize,
}

const DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL: usize = 16;
const DEFAULT_PANE_TIMELINE_MAX_ENTRIES: usize = 4096;

fn default_pane_timeline_max_entries() -> usize {
    DEFAULT_PANE_TIMELINE_MAX_ENTRIES
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaneValidationStrategy {
    FullTree,
    LocalClosure,
}

/// Validation mode for pane operation application.
///
/// `Adaptive` lets each operation's [`PaneOperationFamily`] pick the cheapest
/// sound validator (local-closure for `Local`, whole-tree for `Structural`).
/// `AlwaysFull` forces the conservative whole-tree validator regardless of
/// family, which is the easy-to-force baseline used for diagnosis, rollback,
/// rollout, and as the differential oracle for the certified fast paths.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum PaneValidationMode {
    #[default]
    Adaptive,
    AlwaysFull,
}

impl Default for PaneInteractionTimeline {
    fn default() -> Self {
        Self {
            baseline: None,
            entries: Vec::new(),
            cursor: 0,
            checkpoints: Vec::new(),
            checkpoint_interval: DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL,
            max_entries: DEFAULT_PANE_TIMELINE_MAX_ENTRIES,
        }
    }
}

/// Timeline replay/undo/redo failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneInteractionTimelineError {
    MissingBaseline,
    BaselineInvalid { source: PaneModelError },
    ApplyFailed { source: PaneOperationError },
}

impl fmt::Display for PaneInteractionTimelineError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingBaseline => write!(f, "timeline baseline is not set"),
            Self::BaselineInvalid { source } => {
                write!(f, "failed to restore timeline baseline: {source}")
            }
            Self::ApplyFailed { source } => write!(f, "timeline replay operation failed: {source}"),
        }
    }
}

impl std::error::Error for PaneInteractionTimelineError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::BaselineInvalid { source } => Some(source),
            Self::ApplyFailed { source } => Some(source),
            Self::MissingBaseline => None,
        }
    }
}

/// Placement of an incoming node relative to an existing node inside a split.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanePlacement {
    ExistingFirst,
    IncomingFirst,
}

impl PanePlacement {
    fn ordered(self, existing: PaneId, incoming: PaneId) -> (PaneId, PaneId) {
        match self {
            Self::ExistingFirst => (existing, incoming),
            Self::IncomingFirst => (incoming, existing),
        }
    }
}

/// Pointer button for pane interaction events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanePointerButton {
    Primary,
    Secondary,
    Middle,
}

/// Normalized interaction position in pane-local coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanePointerPosition {
    pub x: i32,
    pub y: i32,
}

impl PanePointerPosition {
    #[must_use]
    pub const fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }
}

/// Snapshot of active modifiers captured with one semantic event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneModifierSnapshot {
    pub shift: bool,
    pub alt: bool,
    pub ctrl: bool,
    pub meta: bool,
}

impl PaneModifierSnapshot {
    #[must_use]
    pub const fn none() -> Self {
        Self {
            shift: false,
            alt: false,
            ctrl: false,
            meta: false,
        }
    }
}

impl Default for PaneModifierSnapshot {
    fn default() -> Self {
        Self::none()
    }
}

/// Canonical resize target for semantic pane input events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneResizeTarget {
    pub split_id: PaneId,
    pub axis: SplitAxis,
}

/// Direction for semantic resize commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneResizeDirection {
    Increase,
    Decrease,
}

/// Canonical cancel reasons for pane interaction state machines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneCancelReason {
    EscapeKey,
    PointerCancel,
    FocusLost,
    Blur,
    Programmatic,
    ContextLost,
    RenderStalled,
}

/// Versioned semantic pane interaction event kind.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum PaneSemanticInputEventKind {
    PointerDown {
        target: PaneResizeTarget,
        pointer_id: u32,
        button: PanePointerButton,
        position: PanePointerPosition,
    },
    PointerMove {
        target: PaneResizeTarget,
        pointer_id: u32,
        position: PanePointerPosition,
        delta_x: i32,
        delta_y: i32,
    },
    PointerUp {
        target: PaneResizeTarget,
        pointer_id: u32,
        button: PanePointerButton,
        position: PanePointerPosition,
    },
    WheelNudge {
        target: PaneResizeTarget,
        lines: i16,
    },
    KeyboardResize {
        target: PaneResizeTarget,
        direction: PaneResizeDirection,
        units: u16,
    },
    Cancel {
        target: Option<PaneResizeTarget>,
        reason: PaneCancelReason,
    },
    Blur {
        target: Option<PaneResizeTarget>,
    },
}

/// Versioned semantic pane interaction event consumed by pane-core and emitted
/// by host adapters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticInputEvent {
    #[serde(default = "default_pane_semantic_input_event_schema_version")]
    pub schema_version: u16,
    pub sequence: u64,
    #[serde(default)]
    pub modifiers: PaneModifierSnapshot,
    #[serde(flatten)]
    pub kind: PaneSemanticInputEventKind,
    #[serde(default)]
    pub extensions: BTreeMap<String, String>,
}

fn default_pane_semantic_input_event_schema_version() -> u16 {
    PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
}

impl PaneSemanticInputEvent {
    /// Build a schema-versioned semantic pane input event.
    #[must_use]
    pub fn new(sequence: u64, kind: PaneSemanticInputEventKind) -> Self {
        Self {
            schema_version: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
            sequence,
            modifiers: PaneModifierSnapshot::default(),
            kind,
            extensions: BTreeMap::new(),
        }
    }

    /// Validate event invariants required for deterministic replay.
    pub fn validate(&self) -> Result<(), PaneSemanticInputEventError> {
        if self.schema_version != PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION {
            return Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
                version: self.schema_version,
                expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
            });
        }
        if self.sequence == 0 {
            return Err(PaneSemanticInputEventError::ZeroSequence);
        }

        match self.kind {
            PaneSemanticInputEventKind::PointerDown { pointer_id, .. }
            | PaneSemanticInputEventKind::PointerMove { pointer_id, .. }
            | PaneSemanticInputEventKind::PointerUp { pointer_id, .. } => {
                if pointer_id == 0 {
                    return Err(PaneSemanticInputEventError::ZeroPointerId);
                }
            }
            PaneSemanticInputEventKind::WheelNudge { lines, .. } => {
                if lines == 0 {
                    return Err(PaneSemanticInputEventError::ZeroWheelLines);
                }
            }
            PaneSemanticInputEventKind::KeyboardResize { units, .. } => {
                if units == 0 {
                    return Err(PaneSemanticInputEventError::ZeroResizeUnits);
                }
            }
            PaneSemanticInputEventKind::Cancel { .. } | PaneSemanticInputEventKind::Blur { .. } => {
            }
        }

        Ok(())
    }
}

/// Validation failures for semantic pane input events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneSemanticInputEventError {
    UnsupportedSchemaVersion { version: u16, expected: u16 },
    ZeroSequence,
    ZeroPointerId,
    ZeroWheelLines,
    ZeroResizeUnits,
}

impl fmt::Display for PaneSemanticInputEventError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsupportedSchemaVersion { version, expected } => write!(
                f,
                "unsupported pane semantic input schema version {version} (expected {expected})"
            ),
            Self::ZeroSequence => write!(f, "semantic pane input event sequence must be non-zero"),
            Self::ZeroPointerId => {
                write!(
                    f,
                    "semantic pane pointer events require non-zero pointer_id"
                )
            }
            Self::ZeroWheelLines => write!(f, "semantic pane wheel nudge must be non-zero"),
            Self::ZeroResizeUnits => {
                write!(f, "semantic pane keyboard resize units must be non-zero")
            }
        }
    }
}

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

/// Metadata carried alongside semantic replay traces.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticInputTraceMetadata {
    #[serde(default = "default_pane_semantic_input_trace_schema_version")]
    pub schema_version: u16,
    pub seed: u64,
    pub start_unix_ms: u64,
    #[serde(default)]
    pub host: String,
    pub checksum: u64,
}

fn default_pane_semantic_input_trace_schema_version() -> u16 {
    PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION
}

/// Canonical replay trace for semantic pane input streams.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticInputTrace {
    pub metadata: PaneSemanticInputTraceMetadata,
    #[serde(default)]
    pub events: Vec<PaneSemanticInputEvent>,
}

impl PaneSemanticInputTrace {
    /// Build a canonical semantic input trace and compute its checksum.
    pub fn new(
        seed: u64,
        start_unix_ms: u64,
        host: impl Into<String>,
        events: Vec<PaneSemanticInputEvent>,
    ) -> Result<Self, PaneSemanticInputTraceError> {
        let mut trace = Self {
            metadata: PaneSemanticInputTraceMetadata {
                schema_version: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
                seed,
                start_unix_ms,
                host: host.into(),
                checksum: 0,
            },
            events,
        };
        trace.metadata.checksum = trace.recompute_checksum();
        trace.validate()?;
        Ok(trace)
    }

    /// Deterministically recompute the checksum over trace payload fields.
    #[must_use]
    pub fn recompute_checksum(&self) -> u64 {
        pane_semantic_input_trace_checksum_payload(&self.metadata, &self.events)
    }

    /// Validate schema/version, event ordering, and checksum invariants.
    pub fn validate(&self) -> Result<(), PaneSemanticInputTraceError> {
        if self.metadata.schema_version != PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION {
            return Err(PaneSemanticInputTraceError::UnsupportedSchemaVersion {
                version: self.metadata.schema_version,
                expected: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
            });
        }
        if self.events.is_empty() {
            return Err(PaneSemanticInputTraceError::EmptyEvents);
        }

        let mut previous_sequence = 0_u64;
        for (index, event) in self.events.iter().enumerate() {
            event
                .validate()
                .map_err(|source| PaneSemanticInputTraceError::InvalidEvent { index, source })?;

            if index > 0 && event.sequence <= previous_sequence {
                return Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
                    index,
                    previous: previous_sequence,
                    current: event.sequence,
                });
            }
            previous_sequence = event.sequence;
        }

        let computed = self.recompute_checksum();
        if self.metadata.checksum != computed {
            return Err(PaneSemanticInputTraceError::ChecksumMismatch {
                recorded: self.metadata.checksum,
                computed,
            });
        }

        Ok(())
    }

    /// Replay a semantic trace through a drag/resize machine.
    pub fn replay(
        &self,
        machine: &mut PaneDragResizeMachine,
    ) -> Result<PaneSemanticReplayOutcome, PaneSemanticReplayError> {
        self.validate()
            .map_err(PaneSemanticReplayError::InvalidTrace)?;

        let mut transitions = Vec::with_capacity(self.events.len());
        for event in &self.events {
            let transition = machine
                .apply_event(event)
                .map_err(PaneSemanticReplayError::Machine)?;
            transitions.push(transition);
        }

        Ok(PaneSemanticReplayOutcome {
            trace_checksum: self.metadata.checksum,
            transitions,
            final_state: machine.state(),
        })
    }
}

/// Validation failures for semantic replay trace payloads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneSemanticInputTraceError {
    UnsupportedSchemaVersion {
        version: u16,
        expected: u16,
    },
    EmptyEvents,
    SequenceOutOfOrder {
        index: usize,
        previous: u64,
        current: u64,
    },
    InvalidEvent {
        index: usize,
        source: PaneSemanticInputEventError,
    },
    ChecksumMismatch {
        recorded: u64,
        computed: u64,
    },
}

impl fmt::Display for PaneSemanticInputTraceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsupportedSchemaVersion { version, expected } => write!(
                f,
                "unsupported pane semantic input trace schema version {version} (expected {expected})"
            ),
            Self::EmptyEvents => write!(
                f,
                "semantic pane input trace must contain at least one event"
            ),
            Self::SequenceOutOfOrder {
                index,
                previous,
                current,
            } => write!(
                f,
                "semantic pane input trace sequence out of order at index {index} ({current} <= {previous})"
            ),
            Self::InvalidEvent { index, source } => {
                write!(
                    f,
                    "semantic pane input trace contains invalid event at index {index}: {source}"
                )
            }
            Self::ChecksumMismatch { recorded, computed } => write!(
                f,
                "semantic pane input trace checksum mismatch (recorded={recorded:#x}, computed={computed:#x})"
            ),
        }
    }
}

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

/// Replay output from running one trace through a pane interaction machine.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticReplayOutcome {
    pub trace_checksum: u64,
    pub transitions: Vec<PaneDragResizeTransition>,
    pub final_state: PaneDragResizeState,
}

/// Classification for replay conformance differences.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneSemanticReplayDiffKind {
    TransitionMismatch,
    MissingExpectedTransition,
    UnexpectedTransition,
    FinalStateMismatch,
}

/// One structured replay conformance difference artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticReplayDiffArtifact {
    pub kind: PaneSemanticReplayDiffKind,
    pub index: Option<usize>,
    pub expected_transition: Option<PaneDragResizeTransition>,
    pub actual_transition: Option<PaneDragResizeTransition>,
    pub expected_final_state: Option<PaneDragResizeState>,
    pub actual_final_state: Option<PaneDragResizeState>,
}

/// Conformance comparison output for replay fixtures.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticReplayConformanceArtifact {
    pub trace_checksum: u64,
    pub passed: bool,
    pub diffs: Vec<PaneSemanticReplayDiffArtifact>,
}

impl PaneSemanticReplayConformanceArtifact {
    /// Compare replay output against expected transitions/final state.
    #[must_use]
    pub fn compare(
        outcome: &PaneSemanticReplayOutcome,
        expected_transitions: &[PaneDragResizeTransition],
        expected_final_state: PaneDragResizeState,
    ) -> Self {
        let mut diffs = Vec::new();
        let max_len = expected_transitions.len().max(outcome.transitions.len());

        for index in 0..max_len {
            let expected = expected_transitions.get(index);
            let actual = outcome.transitions.get(index);

            match (expected, actual) {
                (Some(expected_transition), Some(actual_transition))
                    if expected_transition != actual_transition =>
                {
                    diffs.push(PaneSemanticReplayDiffArtifact {
                        kind: PaneSemanticReplayDiffKind::TransitionMismatch,
                        index: Some(index),
                        expected_transition: Some(expected_transition.clone()),
                        actual_transition: Some(actual_transition.clone()),
                        expected_final_state: None,
                        actual_final_state: None,
                    });
                }
                (Some(expected_transition), None) => {
                    diffs.push(PaneSemanticReplayDiffArtifact {
                        kind: PaneSemanticReplayDiffKind::MissingExpectedTransition,
                        index: Some(index),
                        expected_transition: Some(expected_transition.clone()),
                        actual_transition: None,
                        expected_final_state: None,
                        actual_final_state: None,
                    });
                }
                (None, Some(actual_transition)) => {
                    diffs.push(PaneSemanticReplayDiffArtifact {
                        kind: PaneSemanticReplayDiffKind::UnexpectedTransition,
                        index: Some(index),
                        expected_transition: None,
                        actual_transition: Some(actual_transition.clone()),
                        expected_final_state: None,
                        actual_final_state: None,
                    });
                }
                (Some(_), Some(_)) | (None, None) => {}
            }
        }

        if outcome.final_state != expected_final_state {
            diffs.push(PaneSemanticReplayDiffArtifact {
                kind: PaneSemanticReplayDiffKind::FinalStateMismatch,
                index: None,
                expected_transition: None,
                actual_transition: None,
                expected_final_state: Some(expected_final_state),
                actual_final_state: Some(outcome.final_state),
            });
        }

        Self {
            trace_checksum: outcome.trace_checksum,
            passed: diffs.is_empty(),
            diffs,
        }
    }
}

/// Golden fixture shape for replay conformance runs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSemanticReplayFixture {
    pub trace: PaneSemanticInputTrace,
    #[serde(default)]
    pub expected_transitions: Vec<PaneDragResizeTransition>,
    pub expected_final_state: PaneDragResizeState,
}

impl PaneSemanticReplayFixture {
    /// Run one replay fixture and emit structured conformance artifacts.
    pub fn run(
        &self,
        machine: &mut PaneDragResizeMachine,
    ) -> Result<PaneSemanticReplayConformanceArtifact, PaneSemanticReplayError> {
        let outcome = self.trace.replay(machine)?;
        Ok(PaneSemanticReplayConformanceArtifact::compare(
            &outcome,
            &self.expected_transitions,
            self.expected_final_state,
        ))
    }
}

/// Replay runner failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneSemanticReplayError {
    InvalidTrace(PaneSemanticInputTraceError),
    Machine(PaneDragResizeMachineError),
}

impl fmt::Display for PaneSemanticReplayError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidTrace(source) => write!(f, "invalid semantic replay trace: {source}"),
            Self::Machine(source) => write!(f, "pane drag/resize machine replay failed: {source}"),
        }
    }
}

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

fn pane_semantic_input_trace_checksum_payload(
    metadata: &PaneSemanticInputTraceMetadata,
    events: &[PaneSemanticInputEvent],
) -> u64 {
    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0001_0000_01b3;

    fn mix(hash: &mut u64, byte: u8) {
        *hash ^= u64::from(byte);
        *hash = hash.wrapping_mul(PRIME);
    }

    fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
        for byte in bytes {
            mix(hash, *byte);
        }
    }

    fn mix_u16(hash: &mut u64, value: u16) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_u32(hash: &mut u64, value: u32) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_i32(hash: &mut u64, value: i32) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_u64(hash: &mut u64, value: u64) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_i16(hash: &mut u64, value: i16) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_bool(hash: &mut u64, value: bool) {
        mix(hash, u8::from(value));
    }

    fn mix_str(hash: &mut u64, value: &str) {
        mix_u64(hash, value.len() as u64);
        mix_bytes(hash, value.as_bytes());
    }

    fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
        mix_u64(hash, extensions.len() as u64);
        for (key, value) in extensions {
            mix_str(hash, key);
            mix_str(hash, value);
        }
    }

    fn mix_target(hash: &mut u64, target: PaneResizeTarget) {
        mix_u64(hash, target.split_id.get());
        let axis = match target.axis {
            SplitAxis::Horizontal => 1,
            SplitAxis::Vertical => 2,
        };
        mix(hash, axis);
    }

    fn mix_position(hash: &mut u64, position: PanePointerPosition) {
        mix_i32(hash, position.x);
        mix_i32(hash, position.y);
    }

    fn mix_optional_target(hash: &mut u64, target: Option<PaneResizeTarget>) {
        match target {
            Some(target) => {
                mix(hash, 1);
                mix_target(hash, target);
            }
            None => mix(hash, 0),
        }
    }

    fn mix_pointer_button(hash: &mut u64, button: PanePointerButton) {
        let value = match button {
            PanePointerButton::Primary => 1,
            PanePointerButton::Secondary => 2,
            PanePointerButton::Middle => 3,
        };
        mix(hash, value);
    }

    fn mix_resize_direction(hash: &mut u64, direction: PaneResizeDirection) {
        let value = match direction {
            PaneResizeDirection::Increase => 1,
            PaneResizeDirection::Decrease => 2,
        };
        mix(hash, value);
    }

    fn mix_cancel_reason(hash: &mut u64, reason: PaneCancelReason) {
        let value = match reason {
            PaneCancelReason::EscapeKey => 1,
            PaneCancelReason::PointerCancel => 2,
            PaneCancelReason::FocusLost => 3,
            PaneCancelReason::Blur => 4,
            PaneCancelReason::Programmatic => 5,
            PaneCancelReason::ContextLost => 6,
            PaneCancelReason::RenderStalled => 7,
        };
        mix(hash, value);
    }

    let mut hash = OFFSET_BASIS;
    mix_u16(&mut hash, metadata.schema_version);
    mix_u64(&mut hash, metadata.seed);
    mix_u64(&mut hash, metadata.start_unix_ms);
    mix_str(&mut hash, &metadata.host);
    mix_u64(&mut hash, events.len() as u64);

    for event in events {
        mix_u16(&mut hash, event.schema_version);
        mix_u64(&mut hash, event.sequence);
        mix_bool(&mut hash, event.modifiers.shift);
        mix_bool(&mut hash, event.modifiers.alt);
        mix_bool(&mut hash, event.modifiers.ctrl);
        mix_bool(&mut hash, event.modifiers.meta);
        mix_extensions(&mut hash, &event.extensions);

        match event.kind {
            PaneSemanticInputEventKind::PointerDown {
                target,
                pointer_id,
                button,
                position,
            } => {
                mix(&mut hash, 1);
                mix_target(&mut hash, target);
                mix_u32(&mut hash, pointer_id);
                mix_pointer_button(&mut hash, button);
                mix_position(&mut hash, position);
            }
            PaneSemanticInputEventKind::PointerMove {
                target,
                pointer_id,
                position,
                delta_x,
                delta_y,
            } => {
                mix(&mut hash, 2);
                mix_target(&mut hash, target);
                mix_u32(&mut hash, pointer_id);
                mix_position(&mut hash, position);
                mix_i32(&mut hash, delta_x);
                mix_i32(&mut hash, delta_y);
            }
            PaneSemanticInputEventKind::PointerUp {
                target,
                pointer_id,
                button,
                position,
            } => {
                mix(&mut hash, 3);
                mix_target(&mut hash, target);
                mix_u32(&mut hash, pointer_id);
                mix_pointer_button(&mut hash, button);
                mix_position(&mut hash, position);
            }
            PaneSemanticInputEventKind::WheelNudge { target, lines } => {
                mix(&mut hash, 4);
                mix_target(&mut hash, target);
                mix_i16(&mut hash, lines);
            }
            PaneSemanticInputEventKind::KeyboardResize {
                target,
                direction,
                units,
            } => {
                mix(&mut hash, 5);
                mix_target(&mut hash, target);
                mix_resize_direction(&mut hash, direction);
                mix_u16(&mut hash, units);
            }
            PaneSemanticInputEventKind::Cancel { target, reason } => {
                mix(&mut hash, 6);
                mix_optional_target(&mut hash, target);
                mix_cancel_reason(&mut hash, reason);
            }
            PaneSemanticInputEventKind::Blur { target } => {
                mix(&mut hash, 7);
                mix_optional_target(&mut hash, target);
            }
        }
    }

    hash
}

/// Rational scale factor used for deterministic coordinate transforms.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneScaleFactor {
    numerator: u32,
    denominator: u32,
}

impl PaneScaleFactor {
    /// Identity scale (`1/1`).
    pub const ONE: Self = Self {
        numerator: 1,
        denominator: 1,
    };

    /// Build and normalize a rational scale factor.
    pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneCoordinateNormalizationError> {
        if numerator == 0 || denominator == 0 {
            return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
                field: "scale_factor",
                numerator,
                denominator,
            });
        }
        let gcd = gcd_u32(numerator, denominator);
        Ok(Self {
            numerator: numerator / gcd,
            denominator: denominator / gcd,
        })
    }

    fn validate(self, field: &'static str) -> Result<(), PaneCoordinateNormalizationError> {
        if self.numerator == 0 || self.denominator == 0 {
            return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
                field,
                numerator: self.numerator,
                denominator: self.denominator,
            });
        }
        Ok(())
    }

    #[must_use]
    pub const fn numerator(self) -> u32 {
        if self.numerator == 0 {
            1
        } else {
            self.numerator
        }
    }

    #[must_use]
    pub const fn denominator(self) -> u32 {
        if self.denominator == 0 {
            1
        } else {
            self.denominator
        }
    }
}

impl Default for PaneScaleFactor {
    fn default() -> Self {
        Self::ONE
    }
}

/// Deterministic rounding policy for coordinate normalization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PaneCoordinateRoundingPolicy {
    /// Round toward negative infinity (`floor`).
    #[default]
    TowardNegativeInfinity,
    /// Round to nearest value; exact half-way ties resolve toward negative infinity.
    NearestHalfTowardNegativeInfinity,
}

/// Input coordinate source variants accepted by pane normalization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum PaneInputCoordinate {
    /// Absolute CSS pixel coordinates.
    CssPixels { position: PanePointerPosition },
    /// Absolute device pixel coordinates.
    DevicePixels { position: PanePointerPosition },
    /// Viewport-local cell coordinates.
    Cell { position: PanePointerPosition },
}

/// Deterministic normalized coordinate payload used by pane interaction layers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneNormalizedCoordinate {
    /// Canonical global cell coordinate (viewport offset applied).
    pub global_cell: PanePointerPosition,
    /// Viewport-local cell coordinate.
    pub local_cell: PanePointerPosition,
    /// Normalized viewport-local CSS coordinate after DPR/zoom conversion.
    pub local_css: PanePointerPosition,
}

/// Coordinate normalization configuration and transform pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneCoordinateNormalizer {
    pub viewport_origin_css: PanePointerPosition,
    pub viewport_origin_cells: PanePointerPosition,
    pub cell_width_css: u16,
    pub cell_height_css: u16,
    pub dpr: PaneScaleFactor,
    pub zoom: PaneScaleFactor,
    #[serde(default)]
    pub rounding: PaneCoordinateRoundingPolicy,
}

impl PaneCoordinateNormalizer {
    /// Construct a validated coordinate normalizer.
    pub fn new(
        viewport_origin_css: PanePointerPosition,
        viewport_origin_cells: PanePointerPosition,
        cell_width_css: u16,
        cell_height_css: u16,
        dpr: PaneScaleFactor,
        zoom: PaneScaleFactor,
        rounding: PaneCoordinateRoundingPolicy,
    ) -> Result<Self, PaneCoordinateNormalizationError> {
        if cell_width_css == 0 || cell_height_css == 0 {
            return Err(PaneCoordinateNormalizationError::InvalidCellSize {
                width: cell_width_css,
                height: cell_height_css,
            });
        }
        dpr.validate("dpr")?;
        zoom.validate("zoom")?;

        Ok(Self {
            viewport_origin_css,
            viewport_origin_cells,
            cell_width_css,
            cell_height_css,
            dpr,
            zoom,
            rounding,
        })
    }

    /// Convert one raw coordinate into canonical pane cell space.
    pub fn normalize(
        &self,
        input: PaneInputCoordinate,
    ) -> Result<PaneNormalizedCoordinate, PaneCoordinateNormalizationError> {
        let (local_css_x, local_css_y) = match input {
            PaneInputCoordinate::CssPixels { position } => (
                i64::from(position.x) - i64::from(self.viewport_origin_css.x),
                i64::from(position.y) - i64::from(self.viewport_origin_css.y),
            ),
            PaneInputCoordinate::DevicePixels { position } => {
                let css_x = scale_div_round(
                    i64::from(position.x),
                    i64::from(self.dpr.denominator()),
                    i64::from(self.dpr.numerator()),
                    self.rounding,
                )?;
                let css_y = scale_div_round(
                    i64::from(position.y),
                    i64::from(self.dpr.denominator()),
                    i64::from(self.dpr.numerator()),
                    self.rounding,
                )?;
                (
                    css_x - i64::from(self.viewport_origin_css.x),
                    css_y - i64::from(self.viewport_origin_css.y),
                )
            }
            PaneInputCoordinate::Cell { position } => {
                let local_css_x = i64::from(position.x)
                    .checked_mul(i64::from(self.cell_width_css))
                    .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
                let local_css_y = i64::from(position.y)
                    .checked_mul(i64::from(self.cell_height_css))
                    .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
                let global_cell_x = i64::from(position.x) + i64::from(self.viewport_origin_cells.x);
                let global_cell_y = i64::from(position.y) + i64::from(self.viewport_origin_cells.y);

                return Ok(PaneNormalizedCoordinate {
                    global_cell: PanePointerPosition::new(
                        to_i32(global_cell_x)?,
                        to_i32(global_cell_y)?,
                    ),
                    local_cell: position,
                    local_css: PanePointerPosition::new(to_i32(local_css_x)?, to_i32(local_css_y)?),
                });
            }
        };

        let unzoomed_css_x = scale_div_round(
            local_css_x,
            i64::from(self.zoom.denominator()),
            i64::from(self.zoom.numerator()),
            self.rounding,
        )?;
        let unzoomed_css_y = scale_div_round(
            local_css_y,
            i64::from(self.zoom.denominator()),
            i64::from(self.zoom.numerator()),
            self.rounding,
        )?;

        let local_cell_x = div_round(
            unzoomed_css_x,
            i64::from(self.cell_width_css),
            self.rounding,
        )?;
        let local_cell_y = div_round(
            unzoomed_css_y,
            i64::from(self.cell_height_css),
            self.rounding,
        )?;

        let global_cell_x = local_cell_x + i64::from(self.viewport_origin_cells.x);
        let global_cell_y = local_cell_y + i64::from(self.viewport_origin_cells.y);

        Ok(PaneNormalizedCoordinate {
            global_cell: PanePointerPosition::new(to_i32(global_cell_x)?, to_i32(global_cell_y)?),
            local_cell: PanePointerPosition::new(to_i32(local_cell_x)?, to_i32(local_cell_y)?),
            local_css: PanePointerPosition::new(to_i32(unzoomed_css_x)?, to_i32(unzoomed_css_y)?),
        })
    }
}

/// Coordinate normalization failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneCoordinateNormalizationError {
    InvalidCellSize {
        width: u16,
        height: u16,
    },
    InvalidScaleFactor {
        field: &'static str,
        numerator: u32,
        denominator: u32,
    },
    CoordinateOverflow,
}

impl fmt::Display for PaneCoordinateNormalizationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidCellSize { width, height } => {
                write!(
                    f,
                    "invalid pane cell dimensions width={width} height={height} (must be > 0)"
                )
            }
            Self::InvalidScaleFactor {
                field,
                numerator,
                denominator,
            } => {
                write!(
                    f,
                    "invalid pane scale factor for {field}: {numerator}/{denominator} (must be > 0)"
                )
            }
            Self::CoordinateOverflow => {
                write!(f, "coordinate conversion overflowed representable range")
            }
        }
    }
}

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

fn scale_div_round(
    value: i64,
    numerator: i64,
    denominator: i64,
    rounding: PaneCoordinateRoundingPolicy,
) -> Result<i64, PaneCoordinateNormalizationError> {
    if denominator <= 0 {
        return Err(PaneCoordinateNormalizationError::CoordinateOverflow);
    }

    let scaled = (value as i128) * (numerator as i128);
    let den = denominator as i128;

    let floor = scaled.div_euclid(den);
    let remainder = scaled.rem_euclid(den);

    let mut result = floor;

    if remainder != 0 {
        match rounding {
            PaneCoordinateRoundingPolicy::TowardNegativeInfinity => {}
            PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity => {
                let twice_remainder = remainder * 2;
                if twice_remainder > den {
                    result += 1;
                }
                // Exact half-way ties deliberately keep the Euclidean floor,
                // which corresponds to rounding toward negative infinity.
            }
        }
    }

    result
        .try_into()
        .map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
}

fn div_round(
    value: i64,
    denominator: i64,
    rounding: PaneCoordinateRoundingPolicy,
) -> Result<i64, PaneCoordinateNormalizationError> {
    scale_div_round(value, 1, denominator, rounding)
}

fn to_i32(value: i64) -> Result<i32, PaneCoordinateNormalizationError> {
    i32::try_from(value).map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
}

/// Default move threshold (in coordinate units) for transitioning from
/// `Armed` to `Dragging`.
pub const PANE_DRAG_RESIZE_DEFAULT_THRESHOLD: u16 = 2;

/// Default minimum move distance (in coordinate units) required to emit a
/// `DragUpdated` transition while dragging.
pub const PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS: u16 = 2;

/// Default snapping interval expressed in basis points (0..=10_000).
pub const PANE_SNAP_DEFAULT_STEP_BPS: u16 = 500;

/// Default snap stickiness window in basis points.
pub const PANE_SNAP_DEFAULT_HYSTERESIS_BPS: u16 = 125;

/// Precision mode derived from modifier snapshots.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanePrecisionMode {
    Normal,
    Fine,
    Coarse,
}

/// Modifier-derived precision/axis-lock policy for drag updates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanePrecisionPolicy {
    pub mode: PanePrecisionMode,
    pub axis_lock: Option<SplitAxis>,
    pub scale: PaneScaleFactor,
}

impl PanePrecisionPolicy {
    /// Build precision policy from modifiers for a target split axis.
    #[must_use]
    pub fn from_modifiers(modifiers: PaneModifierSnapshot, target_axis: SplitAxis) -> Self {
        let mode = if modifiers.alt {
            PanePrecisionMode::Fine
        } else if modifiers.ctrl {
            PanePrecisionMode::Coarse
        } else {
            PanePrecisionMode::Normal
        };
        let axis_lock = modifiers.shift.then_some(target_axis);
        let scale = match mode {
            PanePrecisionMode::Normal => PaneScaleFactor::ONE,
            PanePrecisionMode::Fine => PaneScaleFactor {
                numerator: 1,
                denominator: 2,
            },
            PanePrecisionMode::Coarse => PaneScaleFactor {
                numerator: 2,
                denominator: 1,
            },
        };
        Self {
            mode,
            axis_lock,
            scale,
        }
    }

    /// Apply precision mode and optional axis-lock to an interaction delta.
    pub fn apply_delta(
        &self,
        raw_delta_x: i32,
        raw_delta_y: i32,
    ) -> Result<(i32, i32), PaneInteractionPolicyError> {
        let (locked_x, locked_y) = match self.axis_lock {
            Some(SplitAxis::Horizontal) => (raw_delta_x, 0),
            Some(SplitAxis::Vertical) => (0, raw_delta_y),
            None => (raw_delta_x, raw_delta_y),
        };

        let scaled_x = scale_delta_by_factor(locked_x, self.scale)?;
        let scaled_y = scale_delta_by_factor(locked_y, self.scale)?;
        Ok((scaled_x, scaled_y))
    }
}

/// Deterministic snapping policy for pane split ratios.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSnapTuning {
    pub step_bps: u16,
    pub hysteresis_bps: u16,
}

impl PaneSnapTuning {
    pub fn new(step_bps: u16, hysteresis_bps: u16) -> Result<Self, PaneInteractionPolicyError> {
        let tuning = Self {
            step_bps,
            hysteresis_bps,
        };
        tuning.validate()?;
        Ok(tuning)
    }

    pub fn validate(self) -> Result<(), PaneInteractionPolicyError> {
        if self.step_bps == 0 || self.step_bps > 10_000 {
            return Err(PaneInteractionPolicyError::InvalidSnapTuning {
                step_bps: self.step_bps,
                hysteresis_bps: self.hysteresis_bps,
            });
        }
        Ok(())
    }

    /// Decide whether to snap an input ratio using deterministic tie-breaking.
    #[must_use]
    pub fn decide(self, ratio_bps: u16, previous_snap: Option<u16>) -> PaneSnapDecision {
        let step = u32::from(self.step_bps).max(1);
        let ratio = u32::from(ratio_bps).min(10_000);
        let low = ((ratio / step) * step).min(10_000);
        let high = (low + step).min(10_000);

        let distance_low = ratio.abs_diff(low);
        let distance_high = ratio.abs_diff(high);

        let (nearest, nearest_distance) = if distance_low <= distance_high {
            (low as u16, distance_low as u16)
        } else {
            (high as u16, distance_high as u16)
        };

        if let Some(previous) = previous_snap {
            let distance_previous = ratio.abs_diff(u32::from(previous));
            if distance_previous <= u32::from(self.hysteresis_bps) {
                return PaneSnapDecision {
                    input_ratio_bps: ratio_bps,
                    snapped_ratio_bps: Some(previous),
                    nearest_ratio_bps: nearest,
                    nearest_distance_bps: nearest_distance,
                    reason: PaneSnapReason::RetainedPrevious,
                };
            }
        }

        if nearest_distance <= self.hysteresis_bps {
            PaneSnapDecision {
                input_ratio_bps: ratio_bps,
                snapped_ratio_bps: Some(nearest),
                nearest_ratio_bps: nearest,
                nearest_distance_bps: nearest_distance,
                reason: PaneSnapReason::SnappedNearest,
            }
        } else {
            PaneSnapDecision {
                input_ratio_bps: ratio_bps,
                snapped_ratio_bps: None,
                nearest_ratio_bps: nearest,
                nearest_distance_bps: nearest_distance,
                reason: PaneSnapReason::UnsnapOutsideWindow,
            }
        }
    }
}

impl Default for PaneSnapTuning {
    fn default() -> Self {
        Self {
            step_bps: PANE_SNAP_DEFAULT_STEP_BPS,
            hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
        }
    }
}

/// Combined drag behavior tuning constants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneDragBehaviorTuning {
    pub activation_threshold: u16,
    pub update_hysteresis: u16,
    pub snap: PaneSnapTuning,
}

impl PaneDragBehaviorTuning {
    pub fn new(
        activation_threshold: u16,
        update_hysteresis: u16,
        snap: PaneSnapTuning,
    ) -> Result<Self, PaneInteractionPolicyError> {
        if activation_threshold == 0 {
            return Err(PaneInteractionPolicyError::InvalidThreshold {
                field: "activation_threshold",
                value: activation_threshold,
            });
        }
        if update_hysteresis == 0 {
            return Err(PaneInteractionPolicyError::InvalidThreshold {
                field: "update_hysteresis",
                value: update_hysteresis,
            });
        }
        snap.validate()?;
        Ok(Self {
            activation_threshold,
            update_hysteresis,
            snap,
        })
    }

    #[must_use]
    pub fn should_start_drag(
        self,
        origin: PanePointerPosition,
        current: PanePointerPosition,
    ) -> bool {
        crossed_drag_threshold(origin, current, self.activation_threshold)
    }

    #[must_use]
    pub fn should_emit_drag_update(
        self,
        previous: PanePointerPosition,
        current: PanePointerPosition,
    ) -> bool {
        crossed_drag_threshold(previous, current, self.update_hysteresis)
    }
}

impl Default for PaneDragBehaviorTuning {
    fn default() -> Self {
        Self {
            activation_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
            snap: PaneSnapTuning::default(),
        }
    }
}

/// Deterministic snap decision categories.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneSnapReason {
    RetainedPrevious,
    SnappedNearest,
    UnsnapOutsideWindow,
}

/// Output of snap-decision evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSnapDecision {
    pub input_ratio_bps: u16,
    pub snapped_ratio_bps: Option<u16>,
    pub nearest_ratio_bps: u16,
    pub nearest_distance_bps: u16,
    pub reason: PaneSnapReason,
}

/// Tuning/policy validation errors for pane interaction behavior controls.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneInteractionPolicyError {
    InvalidThreshold { field: &'static str, value: u16 },
    InvalidSnapTuning { step_bps: u16, hysteresis_bps: u16 },
    DeltaOverflow,
}

impl fmt::Display for PaneInteractionPolicyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidThreshold { field, value } => {
                write!(f, "invalid {field} value {value} (must be > 0)")
            }
            Self::InvalidSnapTuning {
                step_bps,
                hysteresis_bps,
            } => {
                write!(
                    f,
                    "invalid snap tuning step_bps={step_bps} hysteresis_bps={hysteresis_bps}"
                )
            }
            Self::DeltaOverflow => write!(f, "delta scaling overflow"),
        }
    }
}

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

fn scale_delta_by_factor(
    delta: i32,
    factor: PaneScaleFactor,
) -> Result<i32, PaneInteractionPolicyError> {
    let scaled = i64::from(delta)
        .checked_mul(i64::from(factor.numerator()))
        .ok_or(PaneInteractionPolicyError::DeltaOverflow)?;
    let normalized = scaled / i64::from(factor.denominator());
    i32::try_from(normalized).map_err(|_| PaneInteractionPolicyError::DeltaOverflow)
}

/// Deterministic pane drag/resize lifecycle state.
///
/// ```text
/// Idle -> Armed -> Dragging -> Idle
///    \------> Idle (commit/cancel from Armed)
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PaneDragResizeState {
    Idle,
    Armed {
        target: PaneResizeTarget,
        pointer_id: u32,
        origin: PanePointerPosition,
        current: PanePointerPosition,
        started_sequence: u64,
    },
    Dragging {
        target: PaneResizeTarget,
        pointer_id: u32,
        origin: PanePointerPosition,
        current: PanePointerPosition,
        started_sequence: u64,
        drag_started_sequence: u64,
    },
}

/// Explicit no-op diagnostics for lifecycle events that are safely ignored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneDragResizeNoopReason {
    IdleWithoutActiveDrag,
    ActiveDragAlreadyInProgress,
    PointerMismatch,
    TargetMismatch,
    ActiveStateDisallowsDiscreteInput,
    ThresholdNotReached,
    BelowHysteresis,
}

/// Transition effect emitted by one lifecycle step.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "effect", rename_all = "snake_case")]
pub enum PaneDragResizeEffect {
    Armed {
        target: PaneResizeTarget,
        pointer_id: u32,
        origin: PanePointerPosition,
    },
    DragStarted {
        target: PaneResizeTarget,
        pointer_id: u32,
        origin: PanePointerPosition,
        current: PanePointerPosition,
        total_delta_x: i32,
        total_delta_y: i32,
    },
    DragUpdated {
        target: PaneResizeTarget,
        pointer_id: u32,
        previous: PanePointerPosition,
        current: PanePointerPosition,
        delta_x: i32,
        delta_y: i32,
        total_delta_x: i32,
        total_delta_y: i32,
    },
    Committed {
        target: PaneResizeTarget,
        pointer_id: u32,
        origin: PanePointerPosition,
        end: PanePointerPosition,
        total_delta_x: i32,
        total_delta_y: i32,
    },
    Canceled {
        target: Option<PaneResizeTarget>,
        pointer_id: Option<u32>,
        reason: PaneCancelReason,
    },
    KeyboardApplied {
        target: PaneResizeTarget,
        direction: PaneResizeDirection,
        units: u16,
    },
    WheelApplied {
        target: PaneResizeTarget,
        lines: i16,
    },
    Noop {
        reason: PaneDragResizeNoopReason,
    },
}

/// One state-machine transition with deterministic telemetry fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneDragResizeTransition {
    pub transition_id: u64,
    pub sequence: u64,
    pub from: PaneDragResizeState,
    pub to: PaneDragResizeState,
    pub effect: PaneDragResizeEffect,
}

/// Runtime lifecycle machine for pane drag/resize interactions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneDragResizeMachine {
    state: PaneDragResizeState,
    drag_threshold: u16,
    update_hysteresis: u16,
    transition_counter: u64,
}

impl Default for PaneDragResizeMachine {
    fn default() -> Self {
        Self {
            state: PaneDragResizeState::Idle,
            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
            transition_counter: 0,
        }
    }
}

impl PaneDragResizeMachine {
    /// Construct a drag/resize lifecycle machine with explicit threshold.
    pub fn new(drag_threshold: u16) -> Result<Self, PaneDragResizeMachineError> {
        Self::new_with_hysteresis(drag_threshold, PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS)
    }

    /// Construct a drag/resize lifecycle machine with explicit threshold and
    /// drag-update hysteresis.
    pub fn new_with_hysteresis(
        drag_threshold: u16,
        update_hysteresis: u16,
    ) -> Result<Self, PaneDragResizeMachineError> {
        if drag_threshold == 0 {
            return Err(PaneDragResizeMachineError::InvalidDragThreshold {
                threshold: drag_threshold,
            });
        }
        if update_hysteresis == 0 {
            return Err(PaneDragResizeMachineError::InvalidUpdateHysteresis {
                hysteresis: update_hysteresis,
            });
        }
        Ok(Self {
            state: PaneDragResizeState::Idle,
            drag_threshold,
            update_hysteresis,
            transition_counter: 0,
        })
    }

    /// Current lifecycle state.
    #[must_use]
    pub const fn state(&self) -> PaneDragResizeState {
        self.state
    }

    /// Configured drag-start threshold.
    #[must_use]
    pub const fn drag_threshold(&self) -> u16 {
        self.drag_threshold
    }

    /// Configured drag-update hysteresis threshold.
    #[must_use]
    pub const fn update_hysteresis(&self) -> u16 {
        self.update_hysteresis
    }

    /// Whether the machine is in a non-idle state (Armed or Dragging).
    #[must_use]
    pub const fn is_active(&self) -> bool {
        !matches!(self.state, PaneDragResizeState::Idle)
    }

    /// Unconditionally reset the machine to Idle, returning a diagnostic
    /// transition if the machine was in an active state.
    ///
    /// This is a safety valve for RAII cleanup paths (panic, signal, guard
    /// drop) where constructing a valid `PaneSemanticInputEvent` is not
    /// possible. The returned transition carries `PaneCancelReason::Programmatic`
    /// and a `Canceled` effect.
    ///
    /// If the machine is already Idle, returns `None` (no-op).
    pub fn force_cancel(&mut self) -> Option<PaneDragResizeTransition> {
        let from = self.state;
        match from {
            PaneDragResizeState::Idle => None,
            PaneDragResizeState::Armed {
                target, pointer_id, ..
            }
            | PaneDragResizeState::Dragging {
                target, pointer_id, ..
            } => {
                self.state = PaneDragResizeState::Idle;
                self.transition_counter = self.transition_counter.saturating_add(1);
                Some(PaneDragResizeTransition {
                    transition_id: self.transition_counter,
                    sequence: 0,
                    from,
                    to: PaneDragResizeState::Idle,
                    effect: PaneDragResizeEffect::Canceled {
                        target: Some(target),
                        pointer_id: Some(pointer_id),
                        reason: PaneCancelReason::Programmatic,
                    },
                })
            }
        }
    }

    /// Apply one semantic pane input event and emit deterministic transition
    /// diagnostics.
    pub fn apply_event(
        &mut self,
        event: &PaneSemanticInputEvent,
    ) -> Result<PaneDragResizeTransition, PaneDragResizeMachineError> {
        event
            .validate()
            .map_err(PaneDragResizeMachineError::InvalidEvent)?;

        let from = self.state;
        let effect = match (self.state, &event.kind) {
            (
                PaneDragResizeState::Idle,
                PaneSemanticInputEventKind::PointerDown {
                    target,
                    pointer_id,
                    position,
                    ..
                },
            ) => {
                self.state = PaneDragResizeState::Armed {
                    target: *target,
                    pointer_id: *pointer_id,
                    origin: *position,
                    current: *position,
                    started_sequence: event.sequence,
                };
                PaneDragResizeEffect::Armed {
                    target: *target,
                    pointer_id: *pointer_id,
                    origin: *position,
                }
            }
            (
                PaneDragResizeState::Idle,
                PaneSemanticInputEventKind::KeyboardResize {
                    target,
                    direction,
                    units,
                },
            ) => PaneDragResizeEffect::KeyboardApplied {
                target: *target,
                direction: *direction,
                units: *units,
            },
            (
                PaneDragResizeState::Idle,
                PaneSemanticInputEventKind::WheelNudge { target, lines },
            ) => PaneDragResizeEffect::WheelApplied {
                target: *target,
                lines: *lines,
            },
            (PaneDragResizeState::Idle, _) => PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag,
            },
            (
                PaneDragResizeState::Armed {
                    target,
                    pointer_id,
                    origin,
                    current: _,
                    started_sequence,
                },
                PaneSemanticInputEventKind::PointerMove {
                    target: incoming_target,
                    pointer_id: incoming_pointer_id,
                    position,
                    ..
                },
            ) => {
                if *incoming_pointer_id != pointer_id {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::PointerMismatch,
                    }
                } else if *incoming_target != target {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Armed {
                        target,
                        pointer_id,
                        origin,
                        current: *position,
                        started_sequence,
                    };
                    if crossed_drag_threshold(origin, *position, self.drag_threshold) {
                        self.state = PaneDragResizeState::Dragging {
                            target,
                            pointer_id,
                            origin,
                            current: *position,
                            started_sequence,
                            drag_started_sequence: event.sequence,
                        };
                        let (total_delta_x, total_delta_y) = delta(origin, *position);
                        PaneDragResizeEffect::DragStarted {
                            target,
                            pointer_id,
                            origin,
                            current: *position,
                            total_delta_x,
                            total_delta_y,
                        }
                    } else {
                        PaneDragResizeEffect::Noop {
                            reason: PaneDragResizeNoopReason::ThresholdNotReached,
                        }
                    }
                }
            }
            (
                PaneDragResizeState::Armed {
                    target,
                    pointer_id,
                    origin,
                    ..
                },
                PaneSemanticInputEventKind::PointerUp {
                    target: incoming_target,
                    pointer_id: incoming_pointer_id,
                    position,
                    ..
                },
            ) => {
                if *incoming_pointer_id != pointer_id {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::PointerMismatch,
                    }
                } else if *incoming_target != target {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    let (total_delta_x, total_delta_y) = delta(origin, *position);
                    PaneDragResizeEffect::Committed {
                        target,
                        pointer_id,
                        origin,
                        end: *position,
                        total_delta_x,
                        total_delta_y,
                    }
                }
            }
            (
                PaneDragResizeState::Armed {
                    target, pointer_id, ..
                },
                PaneSemanticInputEventKind::Cancel {
                    target: incoming_target,
                    reason,
                },
            ) => {
                if !cancel_target_matches(target, *incoming_target) {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    PaneDragResizeEffect::Canceled {
                        target: Some(target),
                        pointer_id: Some(pointer_id),
                        reason: *reason,
                    }
                }
            }
            (
                PaneDragResizeState::Armed {
                    target, pointer_id, ..
                },
                PaneSemanticInputEventKind::Blur {
                    target: incoming_target,
                },
            ) => {
                if !cancel_target_matches(target, *incoming_target) {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    PaneDragResizeEffect::Canceled {
                        target: Some(target),
                        pointer_id: Some(pointer_id),
                        reason: PaneCancelReason::Blur,
                    }
                }
            }
            (PaneDragResizeState::Armed { .. }, PaneSemanticInputEventKind::PointerDown { .. }) => {
                PaneDragResizeEffect::Noop {
                    reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
                }
            }
            (
                PaneDragResizeState::Armed { .. },
                PaneSemanticInputEventKind::KeyboardResize { .. }
                | PaneSemanticInputEventKind::WheelNudge { .. },
            ) => PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
            },
            (
                PaneDragResizeState::Dragging {
                    target,
                    pointer_id,
                    origin,
                    current,
                    started_sequence,
                    drag_started_sequence,
                },
                PaneSemanticInputEventKind::PointerMove {
                    target: incoming_target,
                    pointer_id: incoming_pointer_id,
                    position,
                    ..
                },
            ) => {
                if *incoming_pointer_id != pointer_id {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::PointerMismatch,
                    }
                } else if *incoming_target != target {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    let previous = current;
                    if !crossed_drag_threshold(previous, *position, self.update_hysteresis) {
                        PaneDragResizeEffect::Noop {
                            reason: PaneDragResizeNoopReason::BelowHysteresis,
                        }
                    } else {
                        let (delta_x, delta_y) = delta(previous, *position);
                        let (total_delta_x, total_delta_y) = delta(origin, *position);
                        self.state = PaneDragResizeState::Dragging {
                            target,
                            pointer_id,
                            origin,
                            current: *position,
                            started_sequence,
                            drag_started_sequence,
                        };
                        PaneDragResizeEffect::DragUpdated {
                            target,
                            pointer_id,
                            previous,
                            current: *position,
                            delta_x,
                            delta_y,
                            total_delta_x,
                            total_delta_y,
                        }
                    }
                }
            }
            (
                PaneDragResizeState::Dragging {
                    target,
                    pointer_id,
                    origin,
                    ..
                },
                PaneSemanticInputEventKind::PointerUp {
                    target: incoming_target,
                    pointer_id: incoming_pointer_id,
                    position,
                    ..
                },
            ) => {
                if *incoming_pointer_id != pointer_id {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::PointerMismatch,
                    }
                } else if *incoming_target != target {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    let (total_delta_x, total_delta_y) = delta(origin, *position);
                    PaneDragResizeEffect::Committed {
                        target,
                        pointer_id,
                        origin,
                        end: *position,
                        total_delta_x,
                        total_delta_y,
                    }
                }
            }
            (
                PaneDragResizeState::Dragging {
                    target, pointer_id, ..
                },
                PaneSemanticInputEventKind::Cancel {
                    target: incoming_target,
                    reason,
                },
            ) => {
                if !cancel_target_matches(target, *incoming_target) {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    PaneDragResizeEffect::Canceled {
                        target: Some(target),
                        pointer_id: Some(pointer_id),
                        reason: *reason,
                    }
                }
            }
            (
                PaneDragResizeState::Dragging {
                    target, pointer_id, ..
                },
                PaneSemanticInputEventKind::Blur {
                    target: incoming_target,
                },
            ) => {
                if !cancel_target_matches(target, *incoming_target) {
                    PaneDragResizeEffect::Noop {
                        reason: PaneDragResizeNoopReason::TargetMismatch,
                    }
                } else {
                    self.state = PaneDragResizeState::Idle;
                    PaneDragResizeEffect::Canceled {
                        target: Some(target),
                        pointer_id: Some(pointer_id),
                        reason: PaneCancelReason::Blur,
                    }
                }
            }
            (
                PaneDragResizeState::Dragging { .. },
                PaneSemanticInputEventKind::PointerDown { .. },
            ) => PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
            },
            (
                PaneDragResizeState::Dragging { .. },
                PaneSemanticInputEventKind::KeyboardResize { .. }
                | PaneSemanticInputEventKind::WheelNudge { .. },
            ) => PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
            },
        };

        self.transition_counter = self.transition_counter.saturating_add(1);
        Ok(PaneDragResizeTransition {
            transition_id: self.transition_counter,
            sequence: event.sequence,
            from,
            to: self.state,
            effect,
        })
    }
}

/// Lifecycle machine configuration/runtime errors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneDragResizeMachineError {
    InvalidDragThreshold { threshold: u16 },
    InvalidUpdateHysteresis { hysteresis: u16 },
    InvalidEvent(PaneSemanticInputEventError),
}

impl fmt::Display for PaneDragResizeMachineError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidDragThreshold { threshold } => {
                write!(f, "drag threshold must be > 0 (got {threshold})")
            }
            Self::InvalidUpdateHysteresis { hysteresis } => {
                write!(f, "update hysteresis must be > 0 (got {hysteresis})")
            }
            Self::InvalidEvent(error) => write!(f, "invalid semantic pane input event: {error}"),
        }
    }
}

impl std::error::Error for PaneDragResizeMachineError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        if let Self::InvalidEvent(error) = self {
            return Some(error);
        }
        None
    }
}

fn delta(origin: PanePointerPosition, current: PanePointerPosition) -> (i32, i32) {
    (current.x - origin.x, current.y - origin.y)
}

fn crossed_drag_threshold(
    origin: PanePointerPosition,
    current: PanePointerPosition,
    threshold: u16,
) -> bool {
    let (dx, dy) = delta(origin, current);
    let threshold = i64::from(threshold);
    let squared_distance = i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy);
    squared_distance >= threshold * threshold
}

fn cancel_target_matches(active: PaneResizeTarget, incoming: Option<PaneResizeTarget>) -> bool {
    incoming.is_none() || incoming == Some(active)
}

fn round_f64_to_i32(value: f64) -> i32 {
    if !value.is_finite() {
        return 0;
    }
    if value >= f64::from(i32::MAX) {
        return i32::MAX;
    }
    if value <= f64::from(i32::MIN) {
        return i32::MIN;
    }
    value.round() as i32
}

fn axis_share_from_pointer(
    rect: Rect,
    pointer: PanePointerPosition,
    axis: SplitAxis,
    inset_cells: f64,
) -> f64 {
    let inset = inset_cells.max(0.0);
    let (origin, extent, coordinate) = match axis {
        SplitAxis::Horizontal => (
            f64::from(rect.x),
            f64::from(rect.width),
            f64::from(pointer.x),
        ),
        SplitAxis::Vertical => (
            f64::from(rect.y),
            f64::from(rect.height),
            f64::from(pointer.y),
        ),
    };
    if extent <= 0.0 {
        return 0.5;
    }
    let low = origin + inset.min(extent / 2.0);
    let high = (origin + extent) - inset.min(extent / 2.0);
    if high <= low {
        return 0.5;
    }
    ((coordinate - low) / (high - low)).clamp(0.0, 1.0)
}

fn elastic_ratio_bps(raw_bps: u16, pressure: PanePressureSnapProfile) -> u16 {
    let raw = f64::from(raw_bps.clamp(1, 9_999)) / 10_000.0;
    let confidence = (f64::from(pressure.strength_bps) / 10_000.0).clamp(0.0, 1.0);
    let edge_band = (0.16 - confidence * 0.09).clamp(0.05, 0.18);
    let resistance = (0.62 - confidence * 0.34).clamp(0.18, 0.68);
    let eased = if raw < edge_band {
        let ratio = (raw / edge_band).clamp(0.0, 1.0);
        edge_band * ratio.powf(1.0 / (1.0 + resistance))
    } else if raw > 1.0 - edge_band {
        let ratio = ((1.0 - raw) / edge_band).clamp(0.0, 1.0);
        1.0 - edge_band * ratio.powf(1.0 / (1.0 + resistance))
    } else {
        raw
    };
    (eased * 10_000.0).round().clamp(1.0, 9_999.0) as u16
}

fn classify_resize_grip(
    rect: Rect,
    pointer: PanePointerPosition,
    inset_cells: f64,
) -> Option<PaneResizeGrip> {
    let inset = inset_cells.max(0.5);
    let left = f64::from(rect.x);
    let right = f64::from(rect.x.saturating_add(rect.width.saturating_sub(1)));
    let top = f64::from(rect.y);
    let bottom = f64::from(rect.y.saturating_add(rect.height.saturating_sub(1)));
    let px = f64::from(pointer.x);
    let py = f64::from(pointer.y);

    if px < left - inset || px > right + inset || py < top - inset || py > bottom + inset {
        return None;
    }

    let mut near_left = (px - left).abs() <= inset;
    let mut near_right = (px - right).abs() <= inset;
    let mut near_top = (py - top).abs() <= inset;
    let mut near_bottom = (py - bottom).abs() <= inset;

    // Disambiguate overlapping zones (small panes) by proximity
    if near_left && near_right {
        if (px - left).abs() < (px - right).abs() {
            near_right = false;
        } else {
            near_left = false;
        }
    }
    if near_top && near_bottom {
        if (py - top).abs() < (py - bottom).abs() {
            near_bottom = false;
        } else {
            near_top = false;
        }
    }

    match (near_left, near_right, near_top, near_bottom) {
        (true, false, true, false) => Some(PaneResizeGrip::TopLeft),
        (false, true, true, false) => Some(PaneResizeGrip::TopRight),
        (true, false, false, true) => Some(PaneResizeGrip::BottomLeft),
        (false, true, false, true) => Some(PaneResizeGrip::BottomRight),
        (true, false, false, false) => Some(PaneResizeGrip::Left),
        (false, true, false, false) => Some(PaneResizeGrip::Right),
        (false, false, true, false) => Some(PaneResizeGrip::Top),
        (false, false, false, true) => Some(PaneResizeGrip::Bottom),
        _ => None,
    }
}

fn euclidean_distance(a: PanePointerPosition, b: PanePointerPosition) -> f64 {
    let dx = f64::from(a.x - b.x);
    let dy = f64::from(a.y - b.y);
    (dx * dx + dy * dy).sqrt()
}

fn rect_zone_anchor(rect: Rect, zone: PaneDockZone) -> PanePointerPosition {
    let left = i32::from(rect.x);
    let right = i32::from(rect.x.saturating_add(rect.width.saturating_sub(1)));
    let top = i32::from(rect.y);
    let bottom = i32::from(rect.y.saturating_add(rect.height.saturating_sub(1)));
    let mid_x = (left + right) / 2;
    let mid_y = (top + bottom) / 2;
    match zone {
        PaneDockZone::Left => PanePointerPosition::new(left, mid_y),
        PaneDockZone::Right => PanePointerPosition::new(right, mid_y),
        PaneDockZone::Top => PanePointerPosition::new(mid_x, top),
        PaneDockZone::Bottom => PanePointerPosition::new(mid_x, bottom),
        PaneDockZone::Center => PanePointerPosition::new(mid_x, mid_y),
    }
}

fn dock_zone_ghost_rect(rect: Rect, zone: PaneDockZone) -> Rect {
    match zone {
        PaneDockZone::Left => {
            Rect::new(rect.x, rect.y, (rect.width / 2).max(1), rect.height.max(1))
        }
        PaneDockZone::Right => {
            let width = (rect.width / 2).max(1);
            Rect::new(
                rect.x.saturating_add(rect.width.saturating_sub(width)),
                rect.y,
                width,
                rect.height.max(1),
            )
        }
        PaneDockZone::Top => Rect::new(rect.x, rect.y, rect.width.max(1), (rect.height / 2).max(1)),
        PaneDockZone::Bottom => {
            let height = (rect.height / 2).max(1);
            Rect::new(
                rect.x,
                rect.y.saturating_add(rect.height.saturating_sub(height)),
                rect.width.max(1),
                height,
            )
        }
        PaneDockZone::Center => rect,
    }
}

fn dock_zone_score(distance: f64, radius: f64, zone: PaneDockZone) -> f64 {
    if radius <= 0.0 || distance > radius {
        return 0.0;
    }
    let base = 1.0 - (distance / radius);
    let zone_weight = match zone {
        PaneDockZone::Center => 0.85,
        PaneDockZone::Left | PaneDockZone::Right | PaneDockZone::Top | PaneDockZone::Bottom => 1.0,
    };
    base * zone_weight
}

const fn dock_zone_rank(zone: PaneDockZone) -> u8 {
    match zone {
        PaneDockZone::Left => 0,
        PaneDockZone::Right => 1,
        PaneDockZone::Top => 2,
        PaneDockZone::Bottom => 3,
        PaneDockZone::Center => 4,
    }
}

fn dock_preview_for_rect(
    target: PaneId,
    rect: Rect,
    pointer: PanePointerPosition,
    magnetic_field_cells: f64,
) -> Option<PaneDockPreview> {
    let radius = magnetic_field_cells.max(0.5);
    let zones = [
        PaneDockZone::Left,
        PaneDockZone::Right,
        PaneDockZone::Top,
        PaneDockZone::Bottom,
        PaneDockZone::Center,
    ];
    let mut best: Option<PaneDockPreview> = None;
    for zone in zones {
        let anchor = rect_zone_anchor(rect, zone);
        let distance = euclidean_distance(anchor, pointer);
        let score = dock_zone_score(distance, radius, zone);
        if score <= 0.0 {
            continue;
        }
        let candidate = PaneDockPreview {
            target,
            zone,
            score,
            ghost_rect: dock_zone_ghost_rect(rect, zone),
        };
        match best {
            Some(current) if candidate.score <= current.score => {}
            _ => best = Some(candidate),
        }
    }
    best
}

fn dock_zone_motion_intent(zone: PaneDockZone, motion: PaneMotionVector) -> f64 {
    let dx = f64::from(motion.delta_x);
    let dy = f64::from(motion.delta_y);
    let abs_dx = dx.abs();
    let abs_dy = dy.abs();
    let total = (abs_dx + abs_dy).max(1.0);
    let horizontal = abs_dx / total;
    let vertical = abs_dy / total;
    let speed_factor = (motion.speed / 140.0).clamp(0.0, 1.0);
    let noise_penalty = (f64::from(motion.direction_changes) / 10.0).clamp(0.0, 1.0);

    let directional = match zone {
        PaneDockZone::Left => {
            if dx < 0.0 {
                0.95 + horizontal * 0.55
            } else {
                1.0 - horizontal * 0.35
            }
        }
        PaneDockZone::Right => {
            if dx > 0.0 {
                0.95 + horizontal * 0.55
            } else {
                1.0 - horizontal * 0.35
            }
        }
        PaneDockZone::Top => {
            if dy < 0.0 {
                0.95 + vertical * 0.55
            } else {
                1.0 - vertical * 0.35
            }
        }
        PaneDockZone::Bottom => {
            if dy > 0.0 {
                0.95 + vertical * 0.55
            } else {
                1.0 - vertical * 0.35
            }
        }
        PaneDockZone::Center => {
            let axis_ambiguity = 1.0 - horizontal.max(vertical);
            0.9 + axis_ambiguity * 0.25 - speed_factor * 0.12
        }
    };
    (directional - noise_penalty * 0.22).clamp(0.55, 1.45)
}

fn dock_preview_for_rect_with_motion(
    target: PaneId,
    rect: Rect,
    pointer: PanePointerPosition,
    magnetic_field_cells: f64,
    motion: PaneMotionVector,
) -> Option<PaneDockPreview> {
    let radius = magnetic_field_cells.max(0.5);
    let zones = [
        PaneDockZone::Left,
        PaneDockZone::Right,
        PaneDockZone::Top,
        PaneDockZone::Bottom,
        PaneDockZone::Center,
    ];
    let mut best: Option<PaneDockPreview> = None;
    for zone in zones {
        let anchor = rect_zone_anchor(rect, zone);
        let distance = euclidean_distance(anchor, pointer);
        let base = dock_zone_score(distance, radius, zone);
        if base <= 0.0 {
            continue;
        }
        let intent = dock_zone_motion_intent(zone, motion);
        let score = (base * intent).clamp(0.0, 1.0);
        if score <= 0.0 {
            continue;
        }
        let candidate = PaneDockPreview {
            target,
            zone,
            score,
            ghost_rect: dock_zone_ghost_rect(rect, zone),
        };
        match best {
            Some(current) if candidate.score <= current.score => {}
            _ => best = Some(candidate),
        }
    }
    best
}

fn zone_to_axis_placement_and_target_share(
    zone: PaneDockZone,
    incoming_share_bps: u16,
) -> (SplitAxis, PanePlacement, u16) {
    let incoming = incoming_share_bps.clamp(500, 9_500);
    let target_share = 10_000_u16.saturating_sub(incoming);
    match zone {
        PaneDockZone::Left => (
            SplitAxis::Horizontal,
            PanePlacement::IncomingFirst,
            incoming,
        ),
        PaneDockZone::Right => (
            SplitAxis::Horizontal,
            PanePlacement::ExistingFirst,
            target_share,
        ),
        PaneDockZone::Top => (SplitAxis::Vertical, PanePlacement::IncomingFirst, incoming),
        PaneDockZone::Bottom => (
            SplitAxis::Vertical,
            PanePlacement::ExistingFirst,
            target_share,
        ),
        PaneDockZone::Center => (SplitAxis::Horizontal, PanePlacement::ExistingFirst, 5_000),
    }
}

/// Supported structural pane operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum PaneOperation {
    /// Split an existing leaf by wrapping it with a new split parent and adding
    /// one new sibling leaf.
    SplitLeaf {
        target: PaneId,
        axis: SplitAxis,
        ratio: PaneSplitRatio,
        placement: PanePlacement,
        new_leaf: PaneLeaf,
    },
    /// Close a non-root pane (leaf or subtree) and promote its sibling.
    CloseNode { target: PaneId },
    /// Move an existing subtree next to a target node by wrapping the target in
    /// a new split with the source subtree.
    MoveSubtree {
        source: PaneId,
        target: PaneId,
        axis: SplitAxis,
        ratio: PaneSplitRatio,
        placement: PanePlacement,
    },
    /// Swap two non-ancestor subtrees.
    SwapNodes { first: PaneId, second: PaneId },
    /// Set an explicit split ratio on an existing split node.
    SetSplitRatio {
        split: PaneId,
        ratio: PaneSplitRatio,
    },
    /// Canonicalize all split ratios to reduced form and validate positivity.
    NormalizeRatios,
}

impl PaneOperation {
    /// Operation family.
    #[must_use]
    pub const fn kind(&self) -> PaneOperationKind {
        match self {
            Self::SplitLeaf { .. } => PaneOperationKind::SplitLeaf,
            Self::CloseNode { .. } => PaneOperationKind::CloseNode,
            Self::MoveSubtree { .. } => PaneOperationKind::MoveSubtree,
            Self::SwapNodes { .. } => PaneOperationKind::SwapNodes,
            Self::SetSplitRatio { .. } => PaneOperationKind::SetSplitRatio,
            Self::NormalizeRatios => PaneOperationKind::NormalizeRatios,
        }
    }

    #[must_use]
    fn referenced_nodes(&self) -> Vec<PaneId> {
        match self {
            Self::SplitLeaf { target, .. } | Self::CloseNode { target } => vec![*target],
            Self::MoveSubtree { source, target, .. }
            | Self::SwapNodes {
                first: source,
                second: target,
            } => {
                vec![*source, *target]
            }
            Self::SetSplitRatio { split, .. } => vec![*split],
            Self::NormalizeRatios => Vec::new(),
        }
    }
}

/// Stable operation discriminator used in logs and telemetry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneOperationKind {
    SplitLeaf,
    CloseNode,
    MoveSubtree,
    SwapNodes,
    SetSplitRatio,
    NormalizeRatios,
}

/// Semantic family of a pane operation.
///
/// The family partitions operations by how far their effect can reach in the
/// tree, which is what makes certified fast paths sound. `Local` operations
/// admit cheaper application and validation; `Structural` operations always use
/// the conservative clone-and-full-validate baseline. The family is the single
/// source of truth for the per-operation execution and validation strategy, so
/// the "escalation decision" for any operation is fully recoverable from its
/// [`PaneOperationKind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneOperationFamily {
    /// Bounded mutation whose effect is confined to a single split node and its
    /// immediate parent/child closure (currently only `SetSplitRatio`). Eligible
    /// for the in-place atomic apply path and local-closure validation, both of
    /// which are proven equivalent to the conservative baseline.
    Local,
    /// Topology-changing mutation that can affect arbitrary regions of the tree
    /// (`SplitLeaf`, `CloseNode`, `MoveSubtree`, `SwapNodes`, `NormalizeRatios`).
    /// Always uses the conservative clone-and-whole-tree-validate path.
    Structural,
}

impl PaneOperationKind {
    /// Classify this operation kind into its [`PaneOperationFamily`].
    #[must_use]
    pub const fn family(self) -> PaneOperationFamily {
        match self {
            Self::SetSplitRatio => PaneOperationFamily::Local,
            Self::SplitLeaf
            | Self::CloseNode
            | Self::MoveSubtree
            | Self::SwapNodes
            | Self::NormalizeRatios => PaneOperationFamily::Structural,
        }
    }
}

impl PaneOperation {
    /// Classify this operation into its [`PaneOperationFamily`].
    #[must_use]
    pub const fn family(&self) -> PaneOperationFamily {
        self.kind().family()
    }
}

/// Successful transactional operation result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneOperationOutcome {
    pub operation_id: u64,
    pub kind: PaneOperationKind,
    /// Nodes touched by the operation. Inline for the common small case (the
    /// resize fast path touches one node), so building an outcome on the hot
    /// path does not heap-allocate.
    pub touched_nodes: SmallVec<[PaneId; 4]>,
    pub before_hash: u64,
    pub after_hash: u64,
}

/// Failure payload for transactional operation APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneOperationError {
    pub operation_id: u64,
    pub kind: PaneOperationKind,
    /// Nodes touched by the rejected operation. Inline for the common small case
    /// (see [`PaneOperationOutcome::touched_nodes`]).
    pub touched_nodes: SmallVec<[PaneId; 4]>,
    pub before_hash: u64,
    pub after_hash: u64,
    pub reason: PaneOperationFailure,
}

/// Structured reasons for pane operation failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneOperationFailure {
    MissingNode {
        node_id: PaneId,
    },
    NodeNotLeaf {
        node_id: PaneId,
    },
    ParentNotSplit {
        node_id: PaneId,
    },
    ParentChildMismatch {
        parent: PaneId,
        child: PaneId,
    },
    CannotCloseRoot {
        node_id: PaneId,
    },
    CannotMoveRoot {
        node_id: PaneId,
    },
    SameNode {
        first: PaneId,
        second: PaneId,
    },
    AncestorConflict {
        ancestor: PaneId,
        descendant: PaneId,
    },
    TargetRemovedByDetach {
        target: PaneId,
        detached_parent: PaneId,
    },
    PaneIdOverflow {
        current: PaneId,
    },
    InvalidRatio {
        node_id: PaneId,
        numerator: u32,
        denominator: u32,
    },
    Validation(PaneModelError),
}

impl fmt::Display for PaneOperationFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingNode { node_id } => write!(f, "node {} not found", node_id.0),
            Self::NodeNotLeaf { node_id } => write!(f, "node {} is not a leaf", node_id.0),
            Self::ParentNotSplit { node_id } => {
                write!(f, "node {} is not a split parent", node_id.0)
            }
            Self::ParentChildMismatch { parent, child } => write!(
                f,
                "split parent {} does not reference child {}",
                parent.0, child.0
            ),
            Self::CannotCloseRoot { node_id } => {
                write!(f, "cannot close root node {}", node_id.0)
            }
            Self::CannotMoveRoot { node_id } => {
                write!(f, "cannot move root node {}", node_id.0)
            }
            Self::SameNode { first, second } => write!(
                f,
                "operation requires distinct nodes, got {} and {}",
                first.0, second.0
            ),
            Self::AncestorConflict {
                ancestor,
                descendant,
            } => write!(
                f,
                "operation would create cycle: node {} is an ancestor of {}",
                ancestor.0, descendant.0
            ),
            Self::TargetRemovedByDetach {
                target,
                detached_parent,
            } => write!(
                f,
                "target {} would be removed while detaching parent {}",
                target.0, detached_parent.0
            ),
            Self::PaneIdOverflow { current } => {
                write!(f, "pane id overflow after {}", current.0)
            }
            Self::InvalidRatio {
                node_id,
                numerator,
                denominator,
            } => write!(
                f,
                "split node {} has invalid ratio {numerator}/{denominator}",
                node_id.0
            ),
            Self::Validation(err) => write!(f, "{err}"),
        }
    }
}

impl std::error::Error for PaneOperationFailure {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        if let Self::Validation(err) = self {
            return Some(err);
        }
        None
    }
}

impl fmt::Display for PaneOperationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "pane op {} ({:?}) failed: {} [nodes={:?}, before_hash={:#x}, after_hash={:#x}]",
            self.operation_id,
            self.kind,
            self.reason,
            self.touched_nodes
                .iter()
                .map(|node_id| node_id.0)
                .collect::<Vec<_>>(),
            self.before_hash,
            self.after_hash
        )
    }
}

impl std::error::Error for PaneOperationError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.reason)
    }
}

/// One deterministic operation journal row emitted by a transaction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneOperationJournalEntry {
    pub transaction_id: u64,
    pub sequence: u64,
    pub operation_id: u64,
    pub operation: PaneOperation,
    pub kind: PaneOperationKind,
    pub touched_nodes: Vec<PaneId>,
    pub before_hash: u64,
    pub after_hash: u64,
    pub result: PaneOperationJournalResult,
}

/// Journal result state for one attempted operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PaneOperationJournalResult {
    Applied,
    Rejected { reason: String },
}

/// Finalized transaction payload emitted by commit/rollback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneTransactionOutcome {
    pub transaction_id: u64,
    pub committed: bool,
    pub tree: PaneTree,
    pub journal: Vec<PaneOperationJournalEntry>,
}

/// Transaction boundary wrapper for pane mutations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneTransaction {
    transaction_id: u64,
    sequence: u64,
    base_tree: PaneTree,
    working_tree: PaneTree,
    journal: Vec<PaneOperationJournalEntry>,
}

impl PaneTransaction {
    fn new(transaction_id: u64, base_tree: PaneTree) -> Self {
        Self {
            transaction_id,
            sequence: 1,
            base_tree: base_tree.clone(),
            working_tree: base_tree,
            journal: Vec::new(),
        }
    }

    /// Transaction identifier supplied by the caller.
    #[must_use]
    pub const fn transaction_id(&self) -> u64 {
        self.transaction_id
    }

    /// Current mutable working tree for read-only inspection.
    #[must_use]
    pub fn tree(&self) -> &PaneTree {
        &self.working_tree
    }

    /// Journal entries in deterministic insertion order.
    #[must_use]
    pub fn journal(&self) -> &[PaneOperationJournalEntry] {
        &self.journal
    }

    /// Attempt one operation against the transaction working tree.
    ///
    /// Every attempt is journaled, including rejected operations.
    pub fn apply_operation(
        &mut self,
        operation_id: u64,
        operation: PaneOperation,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        let operation_for_journal = operation.clone();
        let kind = operation_for_journal.kind();
        let sequence = self.next_sequence();

        match self.working_tree.apply_operation(operation_id, operation) {
            Ok(outcome) => {
                self.journal.push(PaneOperationJournalEntry {
                    transaction_id: self.transaction_id,
                    sequence,
                    operation_id,
                    operation: operation_for_journal,
                    kind,
                    touched_nodes: outcome.touched_nodes.to_vec(),
                    before_hash: outcome.before_hash,
                    after_hash: outcome.after_hash,
                    result: PaneOperationJournalResult::Applied,
                });
                Ok(outcome)
            }
            Err(err) => {
                self.journal.push(PaneOperationJournalEntry {
                    transaction_id: self.transaction_id,
                    sequence,
                    operation_id,
                    operation: operation_for_journal,
                    kind,
                    touched_nodes: err.touched_nodes.to_vec(),
                    before_hash: err.before_hash,
                    after_hash: err.after_hash,
                    result: PaneOperationJournalResult::Rejected {
                        reason: err.reason.to_string(),
                    },
                });
                Err(err)
            }
        }
    }

    /// Finalize and keep all successful mutations.
    #[must_use]
    pub fn commit(self) -> PaneTransactionOutcome {
        PaneTransactionOutcome {
            transaction_id: self.transaction_id,
            committed: true,
            tree: self.working_tree,
            journal: self.journal,
        }
    }

    /// Finalize and discard all mutations.
    #[must_use]
    pub fn rollback(self) -> PaneTransactionOutcome {
        PaneTransactionOutcome {
            transaction_id: self.transaction_id,
            committed: false,
            tree: self.base_tree,
            journal: self.journal,
        }
    }

    fn next_sequence(&mut self) -> u64 {
        let sequence = self.sequence;
        self.sequence = self.sequence.saturating_add(1);
        sequence
    }
}

/// Validated pane tree model for runtime usage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneTree {
    schema_version: u16,
    root: PaneId,
    next_id: PaneId,
    nodes: BTreeMap<PaneId, PaneNodeRecord>,
    extensions: BTreeMap<String, String>,
}

impl PaneTree {
    /// Build a singleton tree with one root leaf.
    #[must_use]
    pub fn singleton(surface_key: impl Into<String>) -> Self {
        let root = PaneId::MIN;
        let mut nodes = BTreeMap::new();
        let _ = nodes.insert(
            root,
            PaneNodeRecord::leaf(root, None, PaneLeaf::new(surface_key)),
        );
        Self {
            schema_version: PANE_TREE_SCHEMA_VERSION,
            root,
            next_id: root.checked_next().unwrap_or(root),
            nodes,
            extensions: BTreeMap::new(),
        }
    }

    /// Construct and validate from a serial snapshot.
    pub fn from_snapshot(mut snapshot: PaneTreeSnapshot) -> Result<Self, PaneModelError> {
        if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
            return Err(PaneModelError::UnsupportedSchemaVersion {
                version: snapshot.schema_version,
            });
        }
        snapshot.canonicalize();
        let mut nodes = BTreeMap::new();
        for node in snapshot.nodes {
            let node_id = node.id;
            if nodes.insert(node_id, node).is_some() {
                return Err(PaneModelError::DuplicateNodeId { node_id });
            }
        }
        validate_tree(snapshot.root, snapshot.next_id, &nodes)?;
        Ok(Self {
            schema_version: snapshot.schema_version,
            root: snapshot.root,
            next_id: snapshot.next_id,
            nodes,
            extensions: snapshot.extensions,
        })
    }

    /// Export to canonical snapshot form.
    #[must_use]
    pub fn to_snapshot(&self) -> PaneTreeSnapshot {
        let mut snapshot = PaneTreeSnapshot {
            schema_version: self.schema_version,
            root: self.root,
            next_id: self.next_id,
            nodes: self.nodes.values().cloned().collect(),
            extensions: self.extensions.clone(),
        };
        snapshot.canonicalize();
        snapshot
    }

    /// Root node ID.
    #[must_use]
    pub const fn root(&self) -> PaneId {
        self.root
    }

    /// Next deterministic ID value.
    #[must_use]
    pub const fn next_id(&self) -> PaneId {
        self.next_id
    }

    /// Current schema version.
    #[must_use]
    pub const fn schema_version(&self) -> u16 {
        self.schema_version
    }

    /// Lookup a node by ID.
    #[must_use]
    pub fn node(&self, id: PaneId) -> Option<&PaneNodeRecord> {
        self.nodes.get(&id)
    }

    /// Iterate nodes in canonical ID order.
    pub fn nodes(&self) -> impl Iterator<Item = &PaneNodeRecord> {
        self.nodes.values()
    }

    /// Validate internal invariants.
    pub fn validate(&self) -> Result<(), PaneModelError> {
        validate_tree(self.root, self.next_id, &self.nodes)
    }

    /// Structured invariant diagnostics for the current tree snapshot.
    #[must_use]
    pub fn invariant_report(&self) -> PaneInvariantReport {
        self.to_snapshot().invariant_report()
    }

    /// Deterministic structural hash of the current tree state.
    ///
    /// This is intended for operation logs and replay diagnostics.
    #[must_use]
    pub fn state_hash(&self) -> u64 {
        const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
        const PRIME: u64 = 0x0000_0001_0000_01b3;

        fn mix(hash: &mut u64, byte: u8) {
            *hash ^= u64::from(byte);
            *hash = hash.wrapping_mul(PRIME);
        }

        fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
            for byte in bytes {
                mix(hash, *byte);
            }
        }

        fn mix_u16(hash: &mut u64, value: u16) {
            mix_bytes(hash, &value.to_le_bytes());
        }

        fn mix_u32(hash: &mut u64, value: u32) {
            mix_bytes(hash, &value.to_le_bytes());
        }

        fn mix_u64(hash: &mut u64, value: u64) {
            mix_bytes(hash, &value.to_le_bytes());
        }

        fn mix_bool(hash: &mut u64, value: bool) {
            mix(hash, u8::from(value));
        }

        fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
            match value {
                Some(value) => {
                    mix(hash, 1);
                    mix_u16(hash, value);
                }
                None => mix(hash, 0),
            }
        }

        fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
            match value {
                Some(value) => {
                    mix(hash, 1);
                    mix_u64(hash, value.get());
                }
                None => mix(hash, 0),
            }
        }

        fn mix_str(hash: &mut u64, value: &str) {
            mix_u64(hash, value.len() as u64);
            mix_bytes(hash, value.as_bytes());
        }

        fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
            mix_u64(hash, extensions.len() as u64);
            for (key, value) in extensions {
                mix_str(hash, key);
                mix_str(hash, value);
            }
        }

        fn mix_constraints(hash: &mut u64, constraints: PaneConstraints) {
            mix_u16(hash, constraints.min_width);
            mix_u16(hash, constraints.min_height);
            mix_opt_u16(hash, constraints.max_width);
            mix_opt_u16(hash, constraints.max_height);
            mix_bool(hash, constraints.collapsible);
        }

        let mut hash = OFFSET_BASIS;
        mix_u16(&mut hash, self.schema_version);
        mix_u64(&mut hash, self.root.get());
        mix_u64(&mut hash, self.next_id.get());
        mix_extensions(&mut hash, &self.extensions);
        mix_u64(&mut hash, self.nodes.len() as u64);

        for node in self.nodes.values() {
            mix_u64(&mut hash, node.id.get());
            mix_opt_pane_id(&mut hash, node.parent);
            mix_constraints(&mut hash, node.constraints);
            mix_extensions(&mut hash, &node.extensions);

            match &node.kind {
                PaneNodeKind::Leaf(leaf) => {
                    mix(&mut hash, 1);
                    mix_str(&mut hash, &leaf.surface_key);
                    mix_extensions(&mut hash, &leaf.extensions);
                }
                PaneNodeKind::Split(split) => {
                    mix(&mut hash, 2);
                    let axis_byte = match split.axis {
                        SplitAxis::Horizontal => 1,
                        SplitAxis::Vertical => 2,
                    };
                    mix(&mut hash, axis_byte);
                    mix_u32(&mut hash, split.ratio.numerator());
                    mix_u32(&mut hash, split.ratio.denominator());
                    mix_u64(&mut hash, split.first.get());
                    mix_u64(&mut hash, split.second.get());
                }
            }
        }

        hash
    }

    /// Start a transaction boundary for one or more structural operations.
    ///
    /// Transactions stage mutations on a cloned working tree and provide a
    /// deterministic operation journal for replay, undo/redo, and auditing.
    #[must_use]
    pub fn begin_transaction(&self, transaction_id: u64) -> PaneTransaction {
        PaneTransaction::new(transaction_id, self.clone())
    }

    /// Apply one structural operation atomically.
    ///
    /// The operation is executed on a cloned working tree. On success, the
    /// mutated clone replaces `self`; on failure, `self` is unchanged.
    ///
    /// Operations in the [`PaneOperationFamily::Local`] family take a certified
    /// fast path (in-place atomic mutation + local-closure validation) that is
    /// proven equivalent to the conservative baseline in
    /// `tests/pane_operation_family_equivalence.rs`. To bypass every fast path
    /// and force the conservative whole-tree baseline, use
    /// [`PaneTree::apply_operation_conservative`].
    pub fn apply_operation(
        &mut self,
        operation_id: u64,
        operation: PaneOperation,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        if let PaneOperation::SetSplitRatio { split, ratio } = operation {
            // Certified Local fast path: confine work to the touched split.
            return self.apply_set_split_ratio_atomic(operation_id, split, ratio);
        }

        self.apply_operation_generic(operation_id, operation, PaneValidationMode::Adaptive)
    }

    /// Apply one structural operation using the conservative baseline path.
    ///
    /// This always clones a working tree and runs the whole-tree validator
    /// regardless of [`PaneOperationFamily`], bypassing every certified fast
    /// path. It is the easy-to-force conservative validator for diagnosis,
    /// rollback, and rollout, and serves as the differential oracle that the
    /// `Local` fast paths are proven equivalent to. Semantics (accept/reject and
    /// resulting tree) are identical to [`PaneTree::apply_operation`]; only the
    /// execution and validation cost differ.
    pub fn apply_operation_conservative(
        &mut self,
        operation_id: u64,
        operation: PaneOperation,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        self.apply_operation_generic(operation_id, operation, PaneValidationMode::AlwaysFull)
    }

    /// Generic clone-based application shared by the adaptive and conservative
    /// entry points. The `mode` selects whether validation follows the operation
    /// family ([`PaneValidationMode::Adaptive`]) or is forced to the whole-tree
    /// validator ([`PaneValidationMode::AlwaysFull`]).
    fn apply_operation_generic(
        &mut self,
        operation_id: u64,
        operation: PaneOperation,
        mode: PaneValidationMode,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        let kind = operation.kind();
        let before_hash = self.state_hash();
        let mut working = self.clone();
        let mut touched = operation
            .referenced_nodes()
            .into_iter()
            .collect::<BTreeSet<_>>();

        if let Err(reason) = working.apply_operation_inner(operation, &mut touched) {
            return Err(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: touched.into_iter().collect(),
                before_hash,
                after_hash: working.state_hash(),
                reason,
            });
        }

        // Collect the touched set once and reuse it for validation and the
        // outcome (compact storage; the `&SmallVec` coerces to `&[PaneId]`).
        let touched_nodes: SmallVec<[PaneId; 4]> = touched.into_iter().collect();
        if let Err(err) = working.validate_after_operation_with_mode(kind, &touched_nodes, mode) {
            return Err(PaneOperationError {
                operation_id,
                kind,
                touched_nodes,
                before_hash,
                after_hash: working.state_hash(),
                reason: PaneOperationFailure::Validation(err),
            });
        }

        let after_hash = working.state_hash();
        *self = working;

        Ok(PaneOperationOutcome {
            operation_id,
            kind,
            touched_nodes,
            before_hash,
            after_hash,
        })
    }

    fn apply_set_split_ratio_atomic(
        &mut self,
        operation_id: u64,
        split_id: PaneId,
        ratio: PaneSplitRatio,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        let kind = PaneOperationKind::SetSplitRatio;
        let before_hash = self.state_hash();
        let normalized =
            PaneSplitRatio::new(ratio.numerator(), ratio.denominator()).map_err(|_| {
                PaneOperationError {
                    operation_id,
                    kind,
                    touched_nodes: smallvec![split_id],
                    before_hash,
                    after_hash: before_hash,
                    reason: PaneOperationFailure::InvalidRatio {
                        node_id: split_id,
                        numerator: ratio.numerator(),
                        denominator: ratio.denominator(),
                    },
                }
            })?;

        let previous_ratio = {
            let node = self.nodes.get_mut(&split_id).ok_or(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: smallvec![split_id],
                before_hash,
                after_hash: before_hash,
                reason: PaneOperationFailure::MissingNode { node_id: split_id },
            })?;
            let PaneNodeKind::Split(split) = &mut node.kind else {
                return Err(PaneOperationError {
                    operation_id,
                    kind,
                    touched_nodes: smallvec![split_id],
                    before_hash,
                    after_hash: before_hash,
                    reason: PaneOperationFailure::ParentNotSplit { node_id: split_id },
                });
            };
            let previous_ratio = split.ratio;
            split.ratio = normalized;
            previous_ratio
        };

        if let Err(err) = self.validate_after_operation(kind, &[split_id]) {
            let node = self.nodes.get_mut(&split_id).ok_or(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: smallvec![split_id],
                before_hash,
                after_hash: before_hash,
                reason: PaneOperationFailure::Validation(err.clone()),
            })?;
            let PaneNodeKind::Split(split) = &mut node.kind else {
                return Err(PaneOperationError {
                    operation_id,
                    kind,
                    touched_nodes: smallvec![split_id],
                    before_hash,
                    after_hash: before_hash,
                    reason: PaneOperationFailure::Validation(err),
                });
            };
            split.ratio = previous_ratio;
            return Err(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: smallvec![split_id],
                before_hash,
                after_hash: before_hash,
                reason: PaneOperationFailure::Validation(err),
            });
        }

        let after_hash = self.state_hash();
        Ok(PaneOperationOutcome {
            operation_id,
            kind,
            touched_nodes: smallvec![split_id],
            before_hash,
            after_hash,
        })
    }

    fn apply_operation_in_place_for_replay(
        &mut self,
        operation_id: u64,
        operation: &PaneOperation,
    ) -> Result<(), PaneOperationError> {
        let kind = operation.kind();
        let before_hash = self.state_hash();
        let referenced: SmallVec<[PaneId; 4]> = operation.referenced_nodes().into_iter().collect();
        let mut touched = referenced.iter().copied().collect::<BTreeSet<_>>();

        if let Err(reason) = self.apply_operation_inner_ref(operation, &mut touched) {
            return Err(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: referenced,
                before_hash,
                after_hash: self.state_hash(),
                reason,
            });
        }

        // Validation walks the grown touched set (compact slice); errors keep
        // reporting the initially-referenced nodes, preserving prior semantics.
        let grown: SmallVec<[PaneId; 4]> = touched.into_iter().collect();
        if let Err(err) = self.validate_after_operation(kind, &grown) {
            return Err(PaneOperationError {
                operation_id,
                kind,
                touched_nodes: referenced,
                before_hash,
                after_hash: self.state_hash(),
                reason: PaneOperationFailure::Validation(err),
            });
        }

        Ok(())
    }

    fn validate_after_operation(
        &self,
        kind: PaneOperationKind,
        touched: &[PaneId],
    ) -> Result<(), PaneModelError> {
        self.validate_after_operation_with_mode(kind, touched, PaneValidationMode::Adaptive)
    }

    fn validate_after_operation_with_mode(
        &self,
        kind: PaneOperationKind,
        touched: &[PaneId],
        mode: PaneValidationMode,
    ) -> Result<(), PaneModelError> {
        let strategy = match mode {
            PaneValidationMode::AlwaysFull => PaneValidationStrategy::FullTree,
            PaneValidationMode::Adaptive => self.validation_strategy_for_operation(kind),
        };
        match strategy {
            PaneValidationStrategy::FullTree => self.validate(),
            PaneValidationStrategy::LocalClosure => self.validate_local_closure(touched),
        }
    }

    /// Map an operation kind to its validation strategy via its operation family.
    /// The family is the single source of truth, so this is the deterministic,
    /// auditable "escalation decision" for any operation.
    const fn validation_strategy_for_operation(
        &self,
        kind: PaneOperationKind,
    ) -> PaneValidationStrategy {
        match kind.family() {
            PaneOperationFamily::Local => PaneValidationStrategy::LocalClosure,
            PaneOperationFamily::Structural => PaneValidationStrategy::FullTree,
        }
    }

    fn validate_local_closure(&self, touched: &[PaneId]) -> Result<(), PaneModelError> {
        for node_id in touched {
            let node = self
                .nodes
                .get(node_id)
                .ok_or(PaneModelError::MissingRoot { root: *node_id })?;
            node.constraints.validate(*node_id)?;

            if *node_id == self.root {
                if let Some(parent) = node.parent {
                    return Err(PaneModelError::RootHasParent {
                        root: self.root,
                        parent,
                    });
                }
            } else if let Some(parent_id) = node.parent {
                let parent = self
                    .nodes
                    .get(&parent_id)
                    .ok_or(PaneModelError::MissingParent {
                        node_id: *node_id,
                        parent: parent_id,
                    })?;
                let PaneNodeKind::Split(split) = &parent.kind else {
                    return Err(PaneModelError::ParentMismatch {
                        node_id: *node_id,
                        expected: Some(parent_id),
                        actual: node.parent,
                    });
                };
                if split.first != *node_id && split.second != *node_id {
                    return Err(PaneModelError::ParentMismatch {
                        node_id: *node_id,
                        expected: Some(parent_id),
                        actual: node.parent,
                    });
                }
            }

            if let PaneNodeKind::Split(split) = &node.kind {
                if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
                    return Err(PaneModelError::InvalidSplitRatio {
                        numerator: split.ratio.numerator(),
                        denominator: split.ratio.denominator(),
                    });
                }
                if split.first == *node_id || split.second == *node_id {
                    return Err(PaneModelError::SelfReferentialSplit { node_id: *node_id });
                }
                if split.first == split.second {
                    return Err(PaneModelError::DuplicateSplitChildren {
                        node_id: *node_id,
                        child: split.first,
                    });
                }
                for child_id in [split.first, split.second] {
                    let child = self
                        .nodes
                        .get(&child_id)
                        .ok_or(PaneModelError::MissingChild {
                            parent: *node_id,
                            child: child_id,
                        })?;
                    if child.parent != Some(*node_id) {
                        return Err(PaneModelError::ParentMismatch {
                            node_id: child_id,
                            expected: Some(*node_id),
                            actual: child.parent,
                        });
                    }
                }
            }
        }

        Ok(())
    }

    fn apply_operation_inner(
        &mut self,
        operation: PaneOperation,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        match operation {
            PaneOperation::SplitLeaf {
                target,
                axis,
                ratio,
                placement,
                new_leaf,
            } => self.apply_split_leaf(target, axis, ratio, placement, new_leaf, touched),
            PaneOperation::CloseNode { target } => self.apply_close_node(target, touched),
            PaneOperation::MoveSubtree {
                source,
                target,
                axis,
                ratio,
                placement,
            } => self.apply_move_subtree(source, target, axis, ratio, placement, touched),
            PaneOperation::SwapNodes { first, second } => {
                self.apply_swap_nodes(first, second, touched)
            }
            PaneOperation::SetSplitRatio { split, ratio } => {
                self.apply_set_split_ratio(split, ratio, touched)
            }
            PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
        }
    }

    fn apply_operation_inner_ref(
        &mut self,
        operation: &PaneOperation,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        match operation {
            PaneOperation::SplitLeaf {
                target,
                axis,
                ratio,
                placement,
                new_leaf,
            } => self.apply_split_leaf(
                *target,
                *axis,
                *ratio,
                *placement,
                new_leaf.clone(),
                touched,
            ),
            PaneOperation::CloseNode { target } => self.apply_close_node(*target, touched),
            PaneOperation::MoveSubtree {
                source,
                target,
                axis,
                ratio,
                placement,
            } => self.apply_move_subtree(*source, *target, *axis, *ratio, *placement, touched),
            PaneOperation::SwapNodes { first, second } => {
                self.apply_swap_nodes(*first, *second, touched)
            }
            PaneOperation::SetSplitRatio { split, ratio } => {
                self.apply_set_split_ratio(*split, *ratio, touched)
            }
            PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
        }
    }

    fn apply_split_leaf(
        &mut self,
        target: PaneId,
        axis: SplitAxis,
        ratio: PaneSplitRatio,
        placement: PanePlacement,
        new_leaf: PaneLeaf,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        let target_parent = match self.nodes.get(&target) {
            Some(PaneNodeRecord {
                parent,
                kind: PaneNodeKind::Leaf(_),
                ..
            }) => *parent,
            Some(_) => {
                return Err(PaneOperationFailure::NodeNotLeaf { node_id: target });
            }
            None => {
                return Err(PaneOperationFailure::MissingNode { node_id: target });
            }
        };

        let split_id = self.allocate_node_id()?;
        let new_leaf_id = self.allocate_node_id()?;
        touched.extend([target, split_id, new_leaf_id]);
        if let Some(parent_id) = target_parent {
            let _ = touched.insert(parent_id);
        }

        let (first, second) = placement.ordered(target, new_leaf_id);
        let split_record = PaneNodeRecord::split(
            split_id,
            target_parent,
            PaneSplit {
                axis,
                ratio,
                first,
                second,
            },
        );

        if let Some(target_node) = self.nodes.get_mut(&target) {
            target_node.parent = Some(split_id);
        }
        let _ = self.nodes.insert(
            new_leaf_id,
            PaneNodeRecord::leaf(new_leaf_id, Some(split_id), new_leaf),
        );
        let _ = self.nodes.insert(split_id, split_record);

        if let Some(parent_id) = target_parent {
            self.replace_child(parent_id, target, split_id)?;
        } else {
            self.root = split_id;
        }

        Ok(())
    }

    fn apply_close_node(
        &mut self,
        target: PaneId,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        if !self.nodes.contains_key(&target) {
            return Err(PaneOperationFailure::MissingNode { node_id: target });
        }
        if target == self.root {
            return Err(PaneOperationFailure::CannotCloseRoot { node_id: target });
        }

        let subtree_ids = self.collect_subtree_ids(target)?;
        for node_id in &subtree_ids {
            let _ = touched.insert(*node_id);
        }

        let (parent_id, sibling_id, grandparent_id) =
            self.promote_sibling_after_detach(target, touched)?;
        let _ = touched.insert(parent_id);
        let _ = touched.insert(sibling_id);
        if let Some(grandparent_id) = grandparent_id {
            let _ = touched.insert(grandparent_id);
        }

        for node_id in subtree_ids {
            let _ = self.nodes.remove(&node_id);
        }

        Ok(())
    }

    fn apply_move_subtree(
        &mut self,
        source: PaneId,
        target: PaneId,
        axis: SplitAxis,
        ratio: PaneSplitRatio,
        placement: PanePlacement,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        if source == target {
            return Err(PaneOperationFailure::SameNode {
                first: source,
                second: target,
            });
        }

        if !self.nodes.contains_key(&source) {
            return Err(PaneOperationFailure::MissingNode { node_id: source });
        }
        if !self.nodes.contains_key(&target) {
            return Err(PaneOperationFailure::MissingNode { node_id: target });
        }

        if source == self.root {
            return Err(PaneOperationFailure::CannotMoveRoot { node_id: source });
        }
        if self.is_ancestor(source, target)? {
            return Err(PaneOperationFailure::AncestorConflict {
                ancestor: source,
                descendant: target,
            });
        }

        let source_parent = self
            .nodes
            .get(&source)
            .and_then(|node| node.parent)
            .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: source })?;
        if source_parent == target {
            return Err(PaneOperationFailure::TargetRemovedByDetach {
                target,
                detached_parent: source_parent,
            });
        }

        let _ = touched.insert(source);
        let _ = touched.insert(target);
        let _ = touched.insert(source_parent);

        let (removed_parent, sibling_id, grandparent_id) =
            self.promote_sibling_after_detach(source, touched)?;
        let _ = touched.insert(removed_parent);
        let _ = touched.insert(sibling_id);
        if let Some(grandparent_id) = grandparent_id {
            let _ = touched.insert(grandparent_id);
        }

        if let Some(source_node) = self.nodes.get_mut(&source) {
            source_node.parent = None;
        }

        if !self.nodes.contains_key(&target) {
            return Err(PaneOperationFailure::MissingNode { node_id: target });
        }
        let target_parent = self.nodes.get(&target).and_then(|node| node.parent);
        if let Some(parent_id) = target_parent {
            let _ = touched.insert(parent_id);
        }

        let split_id = self.allocate_node_id()?;
        let _ = touched.insert(split_id);
        let (first, second) = placement.ordered(target, source);

        if let Some(target_node) = self.nodes.get_mut(&target) {
            target_node.parent = Some(split_id);
        }
        if let Some(source_node) = self.nodes.get_mut(&source) {
            source_node.parent = Some(split_id);
        }

        let _ = self.nodes.insert(
            split_id,
            PaneNodeRecord::split(
                split_id,
                target_parent,
                PaneSplit {
                    axis,
                    ratio,
                    first,
                    second,
                },
            ),
        );

        if let Some(parent_id) = target_parent {
            self.replace_child(parent_id, target, split_id)?;
        } else {
            self.root = split_id;
        }

        Ok(())
    }

    fn apply_swap_nodes(
        &mut self,
        first: PaneId,
        second: PaneId,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        if first == second {
            return Ok(());
        }

        if !self.nodes.contains_key(&first) {
            return Err(PaneOperationFailure::MissingNode { node_id: first });
        }
        if !self.nodes.contains_key(&second) {
            return Err(PaneOperationFailure::MissingNode { node_id: second });
        }
        if self.is_ancestor(first, second)? {
            return Err(PaneOperationFailure::AncestorConflict {
                ancestor: first,
                descendant: second,
            });
        }
        if self.is_ancestor(second, first)? {
            return Err(PaneOperationFailure::AncestorConflict {
                ancestor: second,
                descendant: first,
            });
        }

        let _ = touched.insert(first);
        let _ = touched.insert(second);

        let first_parent = self.nodes.get(&first).and_then(|node| node.parent);
        let second_parent = self.nodes.get(&second).and_then(|node| node.parent);

        if first_parent == second_parent {
            if let Some(parent_id) = first_parent {
                let _ = touched.insert(parent_id);
                self.swap_children(parent_id, first, second)?;
            }
            return Ok(());
        }

        match (first_parent, second_parent) {
            (Some(left_parent), Some(right_parent)) => {
                let _ = touched.insert(left_parent);
                let _ = touched.insert(right_parent);
                self.replace_child(left_parent, first, second)?;
                self.replace_child(right_parent, second, first)?;
                if let Some(left) = self.nodes.get_mut(&first) {
                    left.parent = Some(right_parent);
                }
                if let Some(right) = self.nodes.get_mut(&second) {
                    right.parent = Some(left_parent);
                }
            }
            (None, Some(parent_id)) => {
                let _ = touched.insert(parent_id);
                self.replace_child(parent_id, second, first)?;
                if let Some(first_node) = self.nodes.get_mut(&first) {
                    first_node.parent = Some(parent_id);
                }
                if let Some(second_node) = self.nodes.get_mut(&second) {
                    second_node.parent = None;
                }
                self.root = second;
            }
            (Some(parent_id), None) => {
                let _ = touched.insert(parent_id);
                self.replace_child(parent_id, first, second)?;
                if let Some(first_node) = self.nodes.get_mut(&first) {
                    first_node.parent = None;
                }
                if let Some(second_node) = self.nodes.get_mut(&second) {
                    second_node.parent = Some(parent_id);
                }
                self.root = first;
            }
            (None, None) => {}
        }

        Ok(())
    }

    fn apply_normalize_ratios(
        &mut self,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        for node in self.nodes.values_mut() {
            if let PaneNodeKind::Split(split) = &mut node.kind {
                let normalized =
                    PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator())
                        .map_err(|_| PaneOperationFailure::InvalidRatio {
                            node_id: node.id,
                            numerator: split.ratio.numerator(),
                            denominator: split.ratio.denominator(),
                        })?;
                split.ratio = normalized;
                let _ = touched.insert(node.id);
            }
        }
        Ok(())
    }

    fn apply_set_split_ratio(
        &mut self,
        split_id: PaneId,
        ratio: PaneSplitRatio,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(), PaneOperationFailure> {
        let node = self
            .nodes
            .get_mut(&split_id)
            .ok_or(PaneOperationFailure::MissingNode { node_id: split_id })?;
        let PaneNodeKind::Split(split) = &mut node.kind else {
            return Err(PaneOperationFailure::ParentNotSplit { node_id: split_id });
        };
        split.ratio =
            PaneSplitRatio::new(ratio.numerator(), ratio.denominator()).map_err(|_| {
                PaneOperationFailure::InvalidRatio {
                    node_id: split_id,
                    numerator: ratio.numerator(),
                    denominator: ratio.denominator(),
                }
            })?;
        let _ = touched.insert(split_id);
        Ok(())
    }

    fn replace_child(
        &mut self,
        parent_id: PaneId,
        old_child: PaneId,
        new_child: PaneId,
    ) -> Result<(), PaneOperationFailure> {
        let parent = self
            .nodes
            .get_mut(&parent_id)
            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
        let PaneNodeKind::Split(split) = &mut parent.kind else {
            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
        };

        if split.first == old_child {
            split.first = new_child;
            return Ok(());
        }
        if split.second == old_child {
            split.second = new_child;
            return Ok(());
        }

        Err(PaneOperationFailure::ParentChildMismatch {
            parent: parent_id,
            child: old_child,
        })
    }

    fn swap_children(
        &mut self,
        parent_id: PaneId,
        left: PaneId,
        right: PaneId,
    ) -> Result<(), PaneOperationFailure> {
        let parent = self
            .nodes
            .get_mut(&parent_id)
            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
        let PaneNodeKind::Split(split) = &mut parent.kind else {
            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
        };

        let has_pair = (split.first == left && split.second == right)
            || (split.first == right && split.second == left);
        if !has_pair {
            return Err(PaneOperationFailure::ParentChildMismatch {
                parent: parent_id,
                child: left,
            });
        }

        std::mem::swap(&mut split.first, &mut split.second);
        Ok(())
    }

    fn promote_sibling_after_detach(
        &mut self,
        detached: PaneId,
        touched: &mut BTreeSet<PaneId>,
    ) -> Result<(PaneId, PaneId, Option<PaneId>), PaneOperationFailure> {
        let parent_id = self
            .nodes
            .get(&detached)
            .ok_or(PaneOperationFailure::MissingNode { node_id: detached })?
            .parent
            .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: detached })?;
        let parent_node = self
            .nodes
            .get(&parent_id)
            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
        let PaneNodeKind::Split(parent_split) = &parent_node.kind else {
            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
        };

        let sibling_id = if parent_split.first == detached {
            parent_split.second
        } else if parent_split.second == detached {
            parent_split.first
        } else {
            return Err(PaneOperationFailure::ParentChildMismatch {
                parent: parent_id,
                child: detached,
            });
        };

        let grandparent_id = parent_node.parent;
        let _ = touched.insert(parent_id);
        let _ = touched.insert(sibling_id);
        if let Some(grandparent_id) = grandparent_id {
            let _ = touched.insert(grandparent_id);
            self.replace_child(grandparent_id, parent_id, sibling_id)?;
        } else {
            self.root = sibling_id;
        }

        let sibling_node =
            self.nodes
                .get_mut(&sibling_id)
                .ok_or(PaneOperationFailure::MissingNode {
                    node_id: sibling_id,
                })?;
        sibling_node.parent = grandparent_id;
        let _ = self.nodes.remove(&parent_id);

        Ok((parent_id, sibling_id, grandparent_id))
    }

    fn is_ancestor(
        &self,
        ancestor: PaneId,
        mut node_id: PaneId,
    ) -> Result<bool, PaneOperationFailure> {
        loop {
            let node = self
                .nodes
                .get(&node_id)
                .ok_or(PaneOperationFailure::MissingNode { node_id })?;
            let Some(parent_id) = node.parent else {
                return Ok(false);
            };
            if parent_id == ancestor {
                return Ok(true);
            }
            node_id = parent_id;
        }
    }

    fn collect_subtree_ids(&self, root_id: PaneId) -> Result<Vec<PaneId>, PaneOperationFailure> {
        if !self.nodes.contains_key(&root_id) {
            return Err(PaneOperationFailure::MissingNode { node_id: root_id });
        }

        let mut out = Vec::new();
        let mut stack = vec![root_id];
        while let Some(node_id) = stack.pop() {
            let node = self
                .nodes
                .get(&node_id)
                .ok_or(PaneOperationFailure::MissingNode { node_id })?;
            out.push(node_id);
            if let PaneNodeKind::Split(split) = &node.kind {
                stack.push(split.first);
                stack.push(split.second);
            }
        }
        Ok(out)
    }

    fn allocate_node_id(&mut self) -> Result<PaneId, PaneOperationFailure> {
        let current = self.next_id;
        self.next_id = self
            .next_id
            .checked_next()
            .map_err(|_| PaneOperationFailure::PaneIdOverflow { current })?;
        Ok(current)
    }

    /// Solve the split-tree into concrete rectangles for the provided viewport.
    ///
    /// Deterministic tie-break rule:
    /// - Desired split size is `floor(available * ratio)`.
    /// - If clamping is required by constraints, we clamp into the feasible
    ///   interval for the first child; remainder goes to the second child.
    ///
    /// Complexity:
    /// - Time: `O(node_count)` (single DFS over split tree)
    /// - Space: `O(node_count)` (output rectangle map)
    pub fn solve_layout(&self, area: Rect) -> Result<PaneLayout, PaneModelError> {
        let mut rects = BTreeMap::new();
        self.solve_node(self.root, area, &mut rects)?;
        Ok(PaneLayout { area, rects })
    }

    fn solve_node(
        &self,
        node_id: PaneId,
        area: Rect,
        rects: &mut BTreeMap<PaneId, Rect>,
    ) -> Result<(), PaneModelError> {
        let Some(node) = self.nodes.get(&node_id) else {
            return Err(PaneModelError::MissingRoot { root: node_id });
        };

        validate_area_against_constraints(node_id, area, node.constraints)?;
        let _ = rects.insert(node_id, area);

        let PaneNodeKind::Split(split) = &node.kind else {
            return Ok(());
        };

        let first_node = self
            .nodes
            .get(&split.first)
            .ok_or(PaneModelError::MissingChild {
                parent: node_id,
                child: split.first,
            })?;
        let second_node = self
            .nodes
            .get(&split.second)
            .ok_or(PaneModelError::MissingChild {
                parent: node_id,
                child: split.second,
            })?;

        let (first_bounds, second_bounds, available) = match split.axis {
            SplitAxis::Horizontal => (
                axis_bounds(first_node.constraints, split.axis),
                axis_bounds(second_node.constraints, split.axis),
                area.width,
            ),
            SplitAxis::Vertical => (
                axis_bounds(first_node.constraints, split.axis),
                axis_bounds(second_node.constraints, split.axis),
                area.height,
            ),
        };

        let (first_size, second_size) = solve_split_sizes(
            node_id,
            split.axis,
            available,
            split.ratio,
            first_bounds,
            second_bounds,
        )?;

        let (first_rect, second_rect) = match split.axis {
            SplitAxis::Horizontal => (
                Rect::new(area.x, area.y, first_size, area.height),
                Rect::new(
                    area.x.saturating_add(first_size),
                    area.y,
                    second_size,
                    area.height,
                ),
            ),
            SplitAxis::Vertical => (
                Rect::new(area.x, area.y, area.width, first_size),
                Rect::new(
                    area.x,
                    area.y.saturating_add(first_size),
                    area.width,
                    second_size,
                ),
            ),
        };

        self.solve_node(split.first, first_rect, rects)?;
        self.solve_node(split.second, second_rect, rects)?;
        Ok(())
    }

    /// Pick the best magnetic docking preview at a pointer location.
    #[must_use]
    pub fn choose_dock_preview(
        &self,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        magnetic_field_cells: f64,
    ) -> Option<PaneDockPreview> {
        self.choose_dock_preview_excluding(layout, pointer, magnetic_field_cells, None)
    }

    /// Return top-ranked magnetic docking candidates (best-first) using
    /// motion-aware intent weighting.
    #[must_use]
    pub fn ranked_dock_previews_with_motion(
        &self,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        motion: PaneMotionVector,
        magnetic_field_cells: f64,
        excluded: Option<PaneId>,
        limit: usize,
    ) -> Vec<PaneDockPreview> {
        self.collect_dock_previews_excluding_with_motion(
            layout,
            pointer,
            magnetic_field_cells,
            excluded,
            motion,
            limit,
        )
    }

    /// Plan a pane move with inertial projection, magnetic docking, and
    /// pressure-sensitive snapping.
    pub fn plan_reflow_move_with_preview(
        &self,
        source: PaneId,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        motion: PaneMotionVector,
        inertial: Option<PaneInertialThrow>,
        magnetic_field_cells: f64,
    ) -> Result<PaneReflowMovePlan, PaneReflowPlanError> {
        if !self.nodes.contains_key(&source) {
            return Err(PaneReflowPlanError::MissingSource { source });
        }
        if source == self.root {
            return Err(PaneReflowPlanError::SourceCannotMoveRoot { source });
        }

        let projected = inertial
            .map(|profile| profile.projected_pointer(pointer))
            .unwrap_or(pointer);
        let preview = self
            .choose_dock_preview_excluding_with_motion(
                layout,
                projected,
                magnetic_field_cells,
                Some(source),
                motion,
            )
            .ok_or(PaneReflowPlanError::NoDockTarget)?;

        let snap_profile = PanePressureSnapProfile::from_motion(motion);
        let magnetic_boost = (preview.score * 1_800.0).round().clamp(0.0, 1_800.0) as u16;
        let incoming_share_bps = snap_profile
            .strength_bps
            .saturating_sub(2_200)
            .saturating_add(magnetic_boost / 2)
            .clamp(2_400, 7_800);

        let operations = if preview.zone == PaneDockZone::Center {
            vec![PaneOperation::SwapNodes {
                first: source,
                second: preview.target,
            }]
        } else {
            let (axis, placement, target_first_share) =
                zone_to_axis_placement_and_target_share(preview.zone, incoming_share_bps);
            let ratio = PaneSplitRatio::new(
                u32::from(target_first_share.max(1)),
                u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
            )
            .map_err(|_| PaneReflowPlanError::InvalidRatio {
                numerator: u32::from(target_first_share.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
            })?;
            vec![PaneOperation::MoveSubtree {
                source,
                target: preview.target,
                axis,
                ratio,
                placement,
            }]
        };

        Ok(PaneReflowMovePlan {
            source,
            pointer,
            projected_pointer: projected,
            preview,
            snap_profile,
            operations,
        })
    }

    /// Apply a previously planned reflow move.
    pub fn apply_reflow_move_plan(
        &mut self,
        operation_seed: u64,
        plan: &PaneReflowMovePlan,
    ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
        let mut outcomes = Vec::with_capacity(plan.operations.len());
        for (index, operation) in plan.operations.iter().cloned().enumerate() {
            let outcome =
                self.apply_operation(operation_seed.saturating_add(index as u64), operation)?;
            outcomes.push(outcome);
        }
        Ok(outcomes)
    }

    /// Plan any-edge / any-corner organic resize for one leaf.
    pub fn plan_edge_resize(
        &self,
        leaf: PaneId,
        layout: &PaneLayout,
        grip: PaneResizeGrip,
        pointer: PanePointerPosition,
        pressure: PanePressureSnapProfile,
    ) -> Result<PaneEdgeResizePlan, PaneEdgeResizePlanError> {
        let node = self
            .nodes
            .get(&leaf)
            .ok_or(PaneEdgeResizePlanError::MissingLeaf { leaf })?;
        if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
            return Err(PaneEdgeResizePlanError::NodeNotLeaf { node: leaf });
        }

        let tuned_snap = pressure.apply_to_tuning(PaneSnapTuning::default());
        let mut operations = Vec::with_capacity(2);

        if let Some(_toward_max) = grip.horizontal_edge() {
            let split_id = self
                .nearest_axis_split_for_node(leaf, SplitAxis::Horizontal)
                .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
                    leaf,
                    axis: SplitAxis::Horizontal,
                })?;
            let split_rect = layout
                .rect(split_id)
                .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
            let share = axis_share_from_pointer(
                split_rect,
                pointer,
                SplitAxis::Horizontal,
                PANE_EDGE_GRIP_INSET_CELLS,
            );
            let raw_bps = elastic_ratio_bps(
                (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
                pressure,
            );
            let snapped = tuned_snap
                .decide(raw_bps, None)
                .snapped_ratio_bps
                .unwrap_or(raw_bps);
            let ratio = PaneSplitRatio::new(
                u32::from(snapped.max(1)),
                u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            )
            .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
                numerator: u32::from(snapped.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            })?;
            operations.push(PaneOperation::SetSplitRatio {
                split: split_id,
                ratio,
            });
        }

        if let Some(_toward_max) = grip.vertical_edge() {
            let split_id = self
                .nearest_axis_split_for_node(leaf, SplitAxis::Vertical)
                .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
                    leaf,
                    axis: SplitAxis::Vertical,
                })?;
            let split_rect = layout
                .rect(split_id)
                .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
            let share = axis_share_from_pointer(
                split_rect,
                pointer,
                SplitAxis::Vertical,
                PANE_EDGE_GRIP_INSET_CELLS,
            );
            let raw_bps = elastic_ratio_bps(
                (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
                pressure,
            );
            let snapped = tuned_snap
                .decide(raw_bps, None)
                .snapped_ratio_bps
                .unwrap_or(raw_bps);
            let ratio = PaneSplitRatio::new(
                u32::from(snapped.max(1)),
                u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            )
            .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
                numerator: u32::from(snapped.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            })?;
            operations.push(PaneOperation::SetSplitRatio {
                split: split_id,
                ratio,
            });
        }

        Ok(PaneEdgeResizePlan {
            leaf,
            grip,
            operations,
        })
    }

    /// Apply all operations generated by an edge/corner resize plan.
    pub fn apply_edge_resize_plan(
        &mut self,
        operation_seed: u64,
        plan: &PaneEdgeResizePlan,
    ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
        let mut outcomes = Vec::with_capacity(plan.operations.len());
        for (index, operation) in plan.operations.iter().cloned().enumerate() {
            outcomes.push(
                self.apply_operation(operation_seed.saturating_add(index as u64), operation)?,
            );
        }
        Ok(outcomes)
    }

    /// Build a normalized split ratio from a first-child share in basis points.
    ///
    /// The share is clamped to `1..=9_999` so neither child collapses to a zero
    /// ratio, mirroring the bounds enforced by [`PaneTree::plan_edge_resize`].
    fn ratio_from_first_share_bps(
        first_share_bps: u16,
    ) -> Result<PaneSplitRatio, PaneSplitterResizePlanError> {
        let first = first_share_bps.clamp(1, 9_999);
        let numerator = u32::from(first);
        let denominator = u32::from(10_000_u16 - first);
        PaneSplitRatio::new(numerator, denominator).map_err(|_| {
            PaneSplitterResizePlanError::InvalidRatio {
                numerator,
                denominator,
            }
        })
    }

    /// Resolve a [`PaneResizeTarget`] to its split node, verifying axis agreement.
    fn resolve_splitter_target(
        &self,
        target: PaneResizeTarget,
    ) -> Result<&PaneSplit, PaneSplitterResizePlanError> {
        let node =
            self.nodes
                .get(&target.split_id)
                .ok_or(PaneSplitterResizePlanError::MissingSplit {
                    split: target.split_id,
                })?;
        let PaneNodeKind::Split(split) = &node.kind else {
            return Err(PaneSplitterResizePlanError::NotASplit {
                node: target.split_id,
            });
        };
        if split.axis != target.axis {
            return Err(PaneSplitterResizePlanError::AxisMismatch {
                split: target.split_id,
                expected: target.axis,
                actual: split.axis,
            });
        }
        Ok(split)
    }

    /// Plan a split-ratio operation for a directly-addressed splitter target
    /// from one pointer sample.
    ///
    /// This is the splitter-handle analogue of [`PaneTree::plan_edge_resize`]:
    /// where `plan_edge_resize` walks from a leaf to its nearest ancestor split,
    /// this resizes the split named by [`PaneResizeTarget`] directly. The pointer
    /// position is projected onto the split's axis (with the same elastic edge
    /// resistance and pressure-tuned snapping used by edge resize) to derive the
    /// new first-child share. It is the pointer-drag primitive consumed by
    /// [`PaneTree::operations_for_transition`].
    pub fn plan_splitter_resize(
        &self,
        target: PaneResizeTarget,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        pressure: PanePressureSnapProfile,
    ) -> Result<PaneOperation, PaneSplitterResizePlanError> {
        self.resolve_splitter_target(target)?;
        let split_rect =
            layout
                .rect(target.split_id)
                .ok_or(PaneSplitterResizePlanError::MissingLayoutRect {
                    node: target.split_id,
                })?;
        let share =
            axis_share_from_pointer(split_rect, pointer, target.axis, PANE_EDGE_GRIP_INSET_CELLS);
        let raw_bps = elastic_ratio_bps(
            (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
            pressure,
        );
        let snapped = pressure
            .apply_to_tuning(PaneSnapTuning::default())
            .decide(raw_bps, None)
            .snapped_ratio_bps
            .unwrap_or(raw_bps);
        let ratio = Self::ratio_from_first_share_bps(snapped)?;
        Ok(PaneOperation::SetSplitRatio {
            split: target.split_id,
            ratio,
        })
    }

    /// Plan a discrete split-ratio nudge for a directly-addressed splitter
    /// target.
    ///
    /// The split's *current* ratio is stepped by `delta_bps`: positive values
    /// grow the first child, negative values grow the second. This realizes
    /// keyboard- and wheel-driven resize transitions, which carry no pointer
    /// geometry and instead advance the existing ratio.
    pub fn plan_splitter_nudge(
        &self,
        target: PaneResizeTarget,
        delta_bps: i32,
    ) -> Result<PaneOperation, PaneSplitterResizePlanError> {
        let split = self.resolve_splitter_target(target)?;
        let total = u64::from(split.ratio.numerator()) + u64::from(split.ratio.denominator());
        let current_bps = ((u64::from(split.ratio.numerator()) * 10_000) / total) as i32;
        let next_bps = current_bps.saturating_add(delta_bps).clamp(1, 9_999) as u16;
        let ratio = Self::ratio_from_first_share_bps(next_bps)?;
        Ok(PaneOperation::SetSplitRatio {
            split: target.split_id,
            ratio,
        })
    }

    /// Bridge one drag/resize state-machine transition into the pane operations
    /// that realize it against this tree.
    ///
    /// This is the connective tissue between host input adapters — the terminal
    /// `PaneTerminalAdapter` and the web pointer-capture adapter — and live
    /// [`PaneTree`] mutation. Adapters emit [`PaneDragResizeTransition`] values;
    /// this method converts each geometry-bearing effect into [`PaneOperation`]s
    /// ready for [`PaneTree::apply_operation`]:
    ///
    /// - `DragStarted` / `DragUpdated` / `Committed` → pointer-derived
    ///   [`PaneTree::plan_splitter_resize`] at the effect's current/end position.
    /// - `KeyboardApplied` / `WheelApplied` → [`PaneTree::plan_splitter_nudge`]
    ///   stepping by [`PANE_SNAP_DEFAULT_STEP_BPS`] per unit/line.
    ///
    /// Non-geometric transitions (`Armed`, `Canceled`, `Noop`) and targets that
    /// can no longer be resolved against `layout` (for example a split removed
    /// since the gesture began) yield an empty vector. For diagnostics on
    /// resolution failures, call [`PaneTree::plan_splitter_resize`] /
    /// [`PaneTree::plan_splitter_nudge`] directly.
    #[must_use]
    pub fn operations_for_transition(
        &self,
        transition: &PaneDragResizeTransition,
        layout: &PaneLayout,
        pressure: PanePressureSnapProfile,
    ) -> Vec<PaneOperation> {
        let planned = match transition.effect {
            PaneDragResizeEffect::DragStarted {
                target, current, ..
            }
            | PaneDragResizeEffect::DragUpdated {
                target, current, ..
            } => self.plan_splitter_resize(target, layout, current, pressure),
            PaneDragResizeEffect::Committed { target, end, .. } => {
                self.plan_splitter_resize(target, layout, end, pressure)
            }
            PaneDragResizeEffect::KeyboardApplied {
                target,
                direction,
                units,
            } => {
                let magnitude = i32::from(units) * i32::from(PANE_SNAP_DEFAULT_STEP_BPS);
                let delta_bps = match direction {
                    PaneResizeDirection::Increase => magnitude,
                    PaneResizeDirection::Decrease => -magnitude,
                };
                self.plan_splitter_nudge(target, delta_bps)
            }
            PaneDragResizeEffect::WheelApplied { target, lines } => {
                let delta_bps = i32::from(lines) * i32::from(PANE_SNAP_DEFAULT_STEP_BPS);
                self.plan_splitter_nudge(target, delta_bps)
            }
            PaneDragResizeEffect::Armed { .. }
            | PaneDragResizeEffect::Canceled { .. }
            | PaneDragResizeEffect::Noop { .. } => return Vec::new(),
        };
        planned.into_iter().collect()
    }

    /// Plan a cluster move by moving the anchor and then reattaching members.
    pub fn plan_group_move(
        &self,
        selection: &PaneSelectionState,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        motion: PaneMotionVector,
        inertial: Option<PaneInertialThrow>,
        magnetic_field_cells: f64,
    ) -> Result<PaneGroupTransformPlan, PaneReflowPlanError> {
        if selection.is_empty() {
            return Ok(PaneGroupTransformPlan {
                members: Vec::new(),
                operations: Vec::new(),
            });
        }
        let members = selection.as_sorted_vec();
        let anchor = selection.anchor.unwrap_or(members[0]);
        let reflow = self.plan_reflow_move_with_preview(
            anchor,
            layout,
            pointer,
            motion,
            inertial,
            magnetic_field_cells,
        )?;
        let mut operations = reflow.operations.clone();
        if members.len() > 1 {
            let (axis, placement, target_first_share) =
                zone_to_axis_placement_and_target_share(reflow.preview.zone, 5_000);
            let ratio = PaneSplitRatio::new(
                u32::from(target_first_share.max(1)),
                u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
            )
            .map_err(|_| PaneReflowPlanError::InvalidRatio {
                numerator: u32::from(target_first_share.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
            })?;
            for member in members.iter().copied().filter(|member| *member != anchor) {
                operations.push(PaneOperation::MoveSubtree {
                    source: member,
                    target: anchor,
                    axis,
                    ratio,
                    placement,
                });
            }
        }
        Ok(PaneGroupTransformPlan {
            members,
            operations,
        })
    }

    /// Plan a cluster resize by resizing the shared outer boundary while
    /// preserving internal cluster ratios.
    pub fn plan_group_resize(
        &self,
        selection: &PaneSelectionState,
        layout: &PaneLayout,
        grip: PaneResizeGrip,
        pointer: PanePointerPosition,
        pressure: PanePressureSnapProfile,
    ) -> Result<PaneGroupTransformPlan, PaneEdgeResizePlanError> {
        if selection.is_empty() {
            return Ok(PaneGroupTransformPlan {
                members: Vec::new(),
                operations: Vec::new(),
            });
        }
        let members = selection.as_sorted_vec();
        let cluster_root = self
            .lowest_common_ancestor(&members)
            .unwrap_or_else(|| selection.anchor.unwrap_or(members[0]));
        let proxy_leaf = selection.anchor.unwrap_or(members[0]);

        let tuned_snap = pressure.apply_to_tuning(PaneSnapTuning::default());
        let mut operations = Vec::with_capacity(2);

        if grip.horizontal_edge().is_some() {
            let split_id = self
                .nearest_axis_split_for_node(cluster_root, SplitAxis::Horizontal)
                .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
                    leaf: proxy_leaf,
                    axis: SplitAxis::Horizontal,
                })?;
            let split_rect = layout
                .rect(split_id)
                .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
            let share = axis_share_from_pointer(
                split_rect,
                pointer,
                SplitAxis::Horizontal,
                PANE_EDGE_GRIP_INSET_CELLS,
            );
            let raw_bps = elastic_ratio_bps(
                (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
                pressure,
            );
            let snapped = tuned_snap
                .decide(raw_bps, None)
                .snapped_ratio_bps
                .unwrap_or(raw_bps);
            let ratio = PaneSplitRatio::new(
                u32::from(snapped.max(1)),
                u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            )
            .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
                numerator: u32::from(snapped.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            })?;
            operations.push(PaneOperation::SetSplitRatio {
                split: split_id,
                ratio,
            });
        }

        if grip.vertical_edge().is_some() {
            let split_id = self
                .nearest_axis_split_for_node(cluster_root, SplitAxis::Vertical)
                .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
                    leaf: proxy_leaf,
                    axis: SplitAxis::Vertical,
                })?;
            let split_rect = layout
                .rect(split_id)
                .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
            let share = axis_share_from_pointer(
                split_rect,
                pointer,
                SplitAxis::Vertical,
                PANE_EDGE_GRIP_INSET_CELLS,
            );
            let raw_bps = elastic_ratio_bps(
                (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
                pressure,
            );
            let snapped = tuned_snap
                .decide(raw_bps, None)
                .snapped_ratio_bps
                .unwrap_or(raw_bps);
            let ratio = PaneSplitRatio::new(
                u32::from(snapped.max(1)),
                u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            )
            .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
                numerator: u32::from(snapped.max(1)),
                denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
            })?;
            operations.push(PaneOperation::SetSplitRatio {
                split: split_id,
                ratio,
            });
        }

        Ok(PaneGroupTransformPlan {
            members,
            operations,
        })
    }

    /// Apply a group transform plan.
    pub fn apply_group_transform_plan(
        &mut self,
        operation_seed: u64,
        plan: &PaneGroupTransformPlan,
    ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
        let mut outcomes = Vec::with_capacity(plan.operations.len());
        for (index, operation) in plan.operations.iter().cloned().enumerate() {
            outcomes.push(
                self.apply_operation(operation_seed.saturating_add(index as u64), operation)?,
            );
        }
        Ok(outcomes)
    }

    /// Plan adaptive topology transitions using core split-tree operations.
    pub fn plan_intelligence_mode(
        &self,
        mode: PaneLayoutIntelligenceMode,
        primary: PaneId,
    ) -> Result<Vec<PaneOperation>, PaneReflowPlanError> {
        if !self.nodes.contains_key(&primary) {
            return Err(PaneReflowPlanError::MissingSource { source: primary });
        }
        let mut leaves = self
            .nodes
            .values()
            .filter_map(|node| matches!(node.kind, PaneNodeKind::Leaf(_)).then_some(node.id))
            .collect::<Vec<_>>();
        leaves.sort_unstable();
        let secondary = leaves.iter().copied().find(|leaf| *leaf != primary);

        let focused_ratio =
            PaneSplitRatio::new(7, 3).map_err(|_| PaneReflowPlanError::InvalidRatio {
                numerator: 7,
                denominator: 3,
            })?;
        let balanced_ratio =
            PaneSplitRatio::new(1, 1).map_err(|_| PaneReflowPlanError::InvalidRatio {
                numerator: 1,
                denominator: 1,
            })?;
        let monitor_ratio =
            PaneSplitRatio::new(2, 1).map_err(|_| PaneReflowPlanError::InvalidRatio {
                numerator: 2,
                denominator: 1,
            })?;

        let mut operations = Vec::new();
        match mode {
            PaneLayoutIntelligenceMode::Focus => {
                if primary != self.root {
                    operations.push(PaneOperation::MoveSubtree {
                        source: primary,
                        target: self.root,
                        axis: SplitAxis::Horizontal,
                        ratio: focused_ratio,
                        placement: PanePlacement::IncomingFirst,
                    });
                }
            }
            PaneLayoutIntelligenceMode::Compare => {
                if let Some(other) = secondary
                    && other != primary
                {
                    operations.push(PaneOperation::MoveSubtree {
                        source: primary,
                        target: other,
                        axis: SplitAxis::Horizontal,
                        ratio: balanced_ratio,
                        placement: PanePlacement::IncomingFirst,
                    });
                }
            }
            PaneLayoutIntelligenceMode::Monitor => {
                if primary != self.root {
                    operations.push(PaneOperation::MoveSubtree {
                        source: primary,
                        target: self.root,
                        axis: SplitAxis::Vertical,
                        ratio: monitor_ratio,
                        placement: PanePlacement::IncomingFirst,
                    });
                }
            }
            PaneLayoutIntelligenceMode::Compact => {
                for node in self.nodes.values() {
                    if matches!(node.kind, PaneNodeKind::Split(_)) {
                        operations.push(PaneOperation::SetSplitRatio {
                            split: node.id,
                            ratio: balanced_ratio,
                        });
                    }
                }
                operations.push(PaneOperation::NormalizeRatios);
            }
        }
        Ok(operations)
    }

    fn choose_dock_preview_excluding(
        &self,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        magnetic_field_cells: f64,
        excluded: Option<PaneId>,
    ) -> Option<PaneDockPreview> {
        let mut best: Option<PaneDockPreview> = None;
        for node in self.nodes.values() {
            if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
                continue;
            }
            if excluded == Some(node.id) {
                continue;
            }
            let Some(rect) = layout.rect(node.id) else {
                continue;
            };
            let Some(candidate) =
                dock_preview_for_rect(node.id, rect, pointer, magnetic_field_cells)
            else {
                continue;
            };
            match best {
                Some(current)
                    if candidate.score < current.score
                        || (candidate.score == current.score
                            && candidate.target > current.target) => {}
                _ => best = Some(candidate),
            }
        }
        best
    }

    fn choose_dock_preview_excluding_with_motion(
        &self,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        magnetic_field_cells: f64,
        excluded: Option<PaneId>,
        motion: PaneMotionVector,
    ) -> Option<PaneDockPreview> {
        self.collect_dock_previews_excluding_with_motion(
            layout,
            pointer,
            magnetic_field_cells,
            excluded,
            motion,
            1,
        )
        .into_iter()
        .next()
    }

    fn collect_dock_previews_excluding_with_motion(
        &self,
        layout: &PaneLayout,
        pointer: PanePointerPosition,
        magnetic_field_cells: f64,
        excluded: Option<PaneId>,
        motion: PaneMotionVector,
        limit: usize,
    ) -> Vec<PaneDockPreview> {
        let limit = limit.max(1);
        let mut candidates = Vec::new();
        for node in self.nodes.values() {
            if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
                continue;
            }
            if excluded == Some(node.id) {
                continue;
            }
            let Some(rect) = layout.rect(node.id) else {
                continue;
            };
            let Some(candidate) = dock_preview_for_rect_with_motion(
                node.id,
                rect,
                pointer,
                magnetic_field_cells,
                motion,
            ) else {
                continue;
            };
            candidates.push(candidate);
        }
        candidates.sort_by(|left, right| {
            right
                .score
                .total_cmp(&left.score)
                .then_with(|| left.target.cmp(&right.target))
                .then_with(|| dock_zone_rank(left.zone).cmp(&dock_zone_rank(right.zone)))
        });
        if candidates.len() > limit {
            candidates.truncate(limit);
        }
        candidates
    }

    fn nearest_axis_split_for_node(&self, node: PaneId, axis: SplitAxis) -> Option<PaneId> {
        let mut cursor = Some(node);
        while let Some(node_id) = cursor {
            let parent = self.nodes.get(&node_id).and_then(|record| record.parent)?;
            let parent_record = self.nodes.get(&parent)?;
            if let PaneNodeKind::Split(split) = &parent_record.kind
                && split.axis == axis
            {
                return Some(parent);
            }
            cursor = Some(parent);
        }
        None
    }

    fn lowest_common_ancestor(&self, nodes: &[PaneId]) -> Option<PaneId> {
        if nodes.is_empty() {
            return None;
        }
        let mut ancestor_paths = nodes
            .iter()
            .map(|node_id| self.ancestor_chain(*node_id))
            .collect::<Option<Vec<_>>>()?;
        let first = ancestor_paths.remove(0);
        first
            .into_iter()
            .find(|candidate| ancestor_paths.iter().all(|path| path.contains(candidate)))
    }

    fn ancestor_chain(&self, node: PaneId) -> Option<Vec<PaneId>> {
        let mut out = Vec::new();
        let mut cursor = Some(node);
        while let Some(node_id) = cursor {
            if !self.nodes.contains_key(&node_id) {
                return None;
            }
            out.push(node_id);
            cursor = self.nodes.get(&node_id).and_then(|record| record.parent);
        }
        Some(out)
    }
}

impl PaneInteractionTimeline {
    /// Construct a timeline with an explicit baseline snapshot.
    #[must_use]
    pub fn with_baseline(tree: &PaneTree) -> Self {
        Self {
            baseline: Some(tree.to_snapshot()),
            entries: Vec::new(),
            cursor: 0,
            checkpoints: Vec::new(),
            checkpoint_interval: DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL,
            max_entries: DEFAULT_PANE_TIMELINE_MAX_ENTRIES,
        }
    }

    /// Return a copy of this timeline configured with a retained-entry cap.
    #[must_use]
    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
        self.max_entries = max_entries;
        self
    }

    /// Install a retained-entry cap and immediately enforce it, pruning the
    /// oldest entries (advancing the replay baseline and re-basing checkpoints)
    /// if the timeline currently exceeds `max_entries`.
    ///
    /// Unlike [`with_max_entries`](Self::with_max_entries) this acts on an
    /// existing timeline in place, so a retention policy can re-bound it
    /// retroactively. The newest entries are always kept, so the head state —
    /// and its `after_hash` — is preserved. Returns the number of entries pruned
    /// by this call. `0` is unbounded.
    pub fn set_max_entries(&mut self, max_entries: usize) -> usize {
        let before = self.entries.len();
        self.max_entries = max_entries;
        self.enforce_entry_limit();
        before.saturating_sub(self.entries.len())
    }

    /// Number of currently-applied entries.
    #[must_use]
    pub const fn applied_len(&self) -> usize {
        self.cursor
    }

    /// Next operation id above every retained history entry.
    #[must_use]
    pub fn next_operation_id(&self) -> u64 {
        self.entries
            .iter()
            .map(|entry| entry.operation_id)
            .max()
            .unwrap_or(0)
            .saturating_add(1)
            .max(1)
    }

    /// Replay/checkpoint diagnostics for the current cursor.
    #[must_use]
    pub fn replay_diagnostics(&self) -> PaneInteractionTimelineReplayDiagnostics {
        let replay_start_idx = self
            .checkpoints
            .iter()
            .rev()
            .find(|checkpoint| checkpoint.applied_len <= self.cursor)
            .map_or(0, |checkpoint| checkpoint.applied_len);

        PaneInteractionTimelineReplayDiagnostics {
            entry_count: self.entries.len(),
            cursor: self.cursor,
            checkpoint_count: self.checkpoints.len(),
            checkpoint_interval: self.checkpoint_interval,
            checkpoint_hit: replay_start_idx != 0,
            replay_start_idx,
            replay_depth: self.cursor.saturating_sub(replay_start_idx),
        }
    }

    /// Retained-state diagnostics for memory telemetry and pruning analysis.
    #[must_use]
    pub fn retention_diagnostics(&self) -> PaneInteractionTimelineRetentionDiagnostics {
        let mut snapshot_stats = PaneTimelineSnapshotRetentionStats::default();
        let baseline_present = self.baseline.is_some();
        if let Some(baseline) = &self.baseline {
            snapshot_stats.add_snapshot(baseline);
        }
        for checkpoint in &self.checkpoints {
            snapshot_stats.add_snapshot(&checkpoint.snapshot);
        }

        let retained_operation_payload_bytes = self
            .entries
            .iter()
            .map(|entry| pane_operation_retained_payload_bytes(&entry.operation))
            .sum::<usize>();
        let estimated_entry_struct_bytes = self
            .entries
            .len()
            .saturating_mul(std::mem::size_of::<PaneInteractionTimelineEntry>());
        let estimated_checkpoint_struct_bytes = self
            .checkpoints
            .len()
            .saturating_mul(std::mem::size_of::<PaneInteractionTimelineCheckpoint>());
        let estimated_snapshot_struct_bytes = snapshot_stats
            .snapshot_count
            .saturating_mul(std::mem::size_of::<PaneTreeSnapshot>())
            .saturating_add(
                snapshot_stats
                    .node_count
                    .saturating_mul(std::mem::size_of::<PaneNodeRecord>()),
            );
        let estimated_total_retained_bytes = std::mem::size_of::<Self>()
            .saturating_add(estimated_entry_struct_bytes)
            .saturating_add(estimated_checkpoint_struct_bytes)
            .saturating_add(estimated_snapshot_struct_bytes)
            .saturating_add(snapshot_stats.leaf_payload_bytes)
            .saturating_add(snapshot_stats.extension_payload_bytes)
            .saturating_add(retained_operation_payload_bytes);

        PaneInteractionTimelineRetentionDiagnostics {
            entry_count: self.entries.len(),
            cursor: self.cursor,
            redo_entry_count: self.entries.len().saturating_sub(self.cursor),
            checkpoint_count: self.checkpoints.len(),
            checkpoint_interval: self.checkpoint_interval,
            max_entries: self.max_entries,
            baseline_present,
            retained_snapshot_count: snapshot_stats.snapshot_count,
            baseline_node_count: self
                .baseline
                .as_ref()
                .map_or(0, |snapshot| snapshot.nodes.len()),
            checkpoint_node_count: self
                .checkpoints
                .iter()
                .map(|checkpoint| checkpoint.snapshot.nodes.len())
                .sum(),
            retained_snapshot_node_count: snapshot_stats.node_count,
            retained_leaf_payload_bytes: snapshot_stats.leaf_payload_bytes,
            retained_extension_entry_count: snapshot_stats.extension_entry_count,
            retained_extension_payload_bytes: snapshot_stats.extension_payload_bytes,
            retained_operation_payload_bytes,
            estimated_entry_struct_bytes,
            estimated_checkpoint_struct_bytes,
            estimated_snapshot_struct_bytes,
            estimated_total_retained_bytes,
        }
    }

    /// Deterministic checkpoint-spacing decision from measured snapshot and replay costs.
    #[must_use]
    pub fn checkpoint_decision(
        snapshot_cost_ns: u128,
        replay_step_cost_ns: u128,
    ) -> PaneInteractionTimelineCheckpointDecision {
        let interval =
            analytically_tuned_checkpoint_interval(snapshot_cost_ns, replay_step_cost_ns);
        let replay_depth_ns = replay_step_cost_ns.saturating_mul(interval as u128) / 2;
        PaneInteractionTimelineCheckpointDecision {
            checkpoint_interval: interval,
            estimated_snapshot_cost_ns: snapshot_cost_ns,
            estimated_replay_step_cost_ns: replay_step_cost_ns,
            estimated_replay_depth_ns: replay_depth_ns,
        }
    }

    /// Append one operation by applying it to the provided tree.
    ///
    /// If the cursor is behind the head (after undo), redo entries are dropped
    /// before appending the new branch.
    pub fn apply_and_record(
        &mut self,
        tree: &mut PaneTree,
        sequence: u64,
        operation_id: u64,
        operation: PaneOperation,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        if self.baseline.is_none() {
            self.baseline = Some(tree.to_snapshot());
        }
        if self.cursor < self.entries.len() {
            self.entries.truncate(self.cursor);
            self.checkpoints
                .retain(|checkpoint| checkpoint.applied_len <= self.cursor);
        }
        let outcome = tree.apply_operation(operation_id, operation.clone())?;
        self.entries.push(PaneInteractionTimelineEntry {
            sequence,
            operation_id,
            operation,
            before_hash: outcome.before_hash,
            after_hash: outcome.after_hash,
        });
        self.cursor = self.entries.len();
        self.enforce_entry_limit();
        self.maybe_record_checkpoint(tree);
        Ok(outcome)
    }

    /// Apply one operation and merge adjacent resize deltas for the same split.
    ///
    /// The first delta keeps its pre-gesture hash so undo still restores the
    /// state before the drag began, while replay uses the most recent ratio.
    /// Coalescing is limited to entries whose operation id is above the
    /// gesture-start boundary, so separate drags on the same split remain
    /// separate undo steps.
    pub fn apply_and_record_coalesced_resize_delta(
        &mut self,
        tree: &mut PaneTree,
        sequence: u64,
        operation_id: u64,
        operation: PaneOperation,
        coalesce_after_operation_id: u64,
    ) -> Result<PaneOperationOutcome, PaneOperationError> {
        if self.baseline.is_none() {
            self.baseline = Some(tree.to_snapshot());
        }
        if self.cursor < self.entries.len() {
            self.entries.truncate(self.cursor);
            self.checkpoints
                .retain(|checkpoint| checkpoint.applied_len <= self.cursor);
        }
        let coalesced_before_hash = match &operation {
            PaneOperation::SetSplitRatio { split, .. } if self.cursor == self.entries.len() => self
                .entries
                .last()
                .and_then(|entry| match &entry.operation {
                    PaneOperation::SetSplitRatio {
                        split: previous_split,
                        ..
                    } if previous_split == split
                        && entry.operation_id > coalesce_after_operation_id =>
                    {
                        Some(entry.before_hash)
                    }
                    _ => None,
                }),
            _ => None,
        };

        let outcome = tree.apply_operation(operation_id, operation.clone())?;
        if let Some(before_hash) = coalesced_before_hash
            && let Some(entry) = self.entries.last_mut()
        {
            *entry = PaneInteractionTimelineEntry {
                sequence,
                operation_id,
                operation,
                before_hash,
                after_hash: outcome.after_hash,
            };
            self.cursor = self.entries.len();
            self.checkpoints
                .retain(|checkpoint| checkpoint.applied_len < self.cursor);
            self.enforce_entry_limit();
            self.maybe_record_checkpoint(tree);
            return Ok(outcome);
        }

        self.entries.push(PaneInteractionTimelineEntry {
            sequence,
            operation_id,
            operation,
            before_hash: outcome.before_hash,
            after_hash: outcome.after_hash,
        });
        self.cursor = self.entries.len();
        self.enforce_entry_limit();
        self.maybe_record_checkpoint(tree);
        Ok(outcome)
    }

    /// Undo the last applied entry by deterministic rebuild from baseline.
    pub fn undo(&mut self, tree: &mut PaneTree) -> Result<bool, PaneInteractionTimelineError> {
        if self.cursor == 0 {
            return Ok(false);
        }
        self.cursor -= 1;
        self.rebuild(tree)?;
        Ok(true)
    }

    /// Redo one entry by deterministic rebuild from baseline.
    pub fn redo(&mut self, tree: &mut PaneTree) -> Result<bool, PaneInteractionTimelineError> {
        if self.cursor >= self.entries.len() {
            return Ok(false);
        }
        self.cursor += 1;
        self.rebuild(tree)?;
        Ok(true)
    }

    /// Rebuild a new tree from baseline and currently-applied entries.
    pub fn replay(&self) -> Result<PaneTree, PaneInteractionTimelineError> {
        let (mut tree, start_idx) = self.restore_replay_start()?;
        for entry in self.entries.iter().take(self.cursor).skip(start_idx) {
            tree.apply_operation_in_place_for_replay(entry.operation_id, &entry.operation)
                .map_err(|source| PaneInteractionTimelineError::ApplyFailed { source })?;
        }
        Ok(tree)
    }

    fn rebuild(&self, tree: &mut PaneTree) -> Result<(), PaneInteractionTimelineError> {
        let replayed = self.replay()?;
        *tree = replayed;
        Ok(())
    }

    fn restore_replay_start(&self) -> Result<(PaneTree, usize), PaneInteractionTimelineError> {
        if let Some(checkpoint) = self
            .checkpoints
            .iter()
            .rev()
            .find(|checkpoint| checkpoint.applied_len <= self.cursor)
        {
            let tree = PaneTree::from_snapshot(checkpoint.snapshot.clone())
                .map_err(|source| PaneInteractionTimelineError::BaselineInvalid { source })?;
            return Ok((tree, checkpoint.applied_len));
        }

        let baseline = self
            .baseline
            .clone()
            .ok_or(PaneInteractionTimelineError::MissingBaseline)?;
        let tree = PaneTree::from_snapshot(baseline)
            .map_err(|source| PaneInteractionTimelineError::BaselineInvalid { source })?;
        Ok((tree, 0))
    }

    fn maybe_record_checkpoint(&mut self, tree: &PaneTree) {
        if self.checkpoint_interval == 0 || self.cursor == 0 {
            return;
        }
        if !self.cursor.is_multiple_of(self.checkpoint_interval) {
            return;
        }
        if let Some(checkpoint) = self
            .checkpoints
            .iter_mut()
            .find(|checkpoint| checkpoint.applied_len == self.cursor)
        {
            checkpoint.snapshot = tree.to_snapshot();
            return;
        }
        self.checkpoints.push(PaneInteractionTimelineCheckpoint {
            applied_len: self.cursor,
            snapshot: tree.to_snapshot(),
        });
    }

    fn enforce_entry_limit(&mut self) {
        if self.max_entries == 0 || self.entries.len() <= self.max_entries {
            return;
        }

        // Never prune past the cursor: the current state is baseline +
        // entries[..cursor], so baking an entry beyond the cursor into the
        // baseline would silently replace the user's (undone-to) state with a
        // later one. Excess entries are tolerated until the cursor advances.
        let prune_count = self
            .entries
            .len()
            .saturating_sub(self.max_entries)
            .min(self.cursor);
        if prune_count == 0 {
            return;
        }
        let Some(baseline) = self.baseline.clone() else {
            return;
        };
        let Ok(mut baseline_tree) = PaneTree::from_snapshot(baseline) else {
            return;
        };

        for entry in self.entries.iter().take(prune_count) {
            if baseline_tree
                .apply_operation_in_place_for_replay(entry.operation_id, &entry.operation)
                .is_err()
            {
                return;
            }
        }

        self.baseline = Some(baseline_tree.to_snapshot());
        drop(self.entries.drain(..prune_count));
        self.cursor = self
            .cursor
            .saturating_sub(prune_count)
            .min(self.entries.len());
        self.checkpoints = self
            .checkpoints
            .iter()
            .filter(|checkpoint| checkpoint.applied_len > prune_count)
            .map(|checkpoint| PaneInteractionTimelineCheckpoint {
                applied_len: checkpoint.applied_len - prune_count,
                snapshot: checkpoint.snapshot.clone(),
            })
            .collect();
    }
}

#[derive(Debug, Clone, Copy, Default)]
struct PaneTimelineSnapshotRetentionStats {
    snapshot_count: usize,
    node_count: usize,
    leaf_payload_bytes: usize,
    extension_entry_count: usize,
    extension_payload_bytes: usize,
}

impl PaneTimelineSnapshotRetentionStats {
    fn add_snapshot(&mut self, snapshot: &PaneTreeSnapshot) {
        self.snapshot_count = self.snapshot_count.saturating_add(1);
        self.node_count = self.node_count.saturating_add(snapshot.nodes.len());
        self.extension_entry_count = self
            .extension_entry_count
            .saturating_add(snapshot.extensions.len());
        self.extension_payload_bytes = self
            .extension_payload_bytes
            .saturating_add(string_map_payload_bytes(&snapshot.extensions));

        for node in &snapshot.nodes {
            self.extension_entry_count = self
                .extension_entry_count
                .saturating_add(node.extensions.len());
            self.extension_payload_bytes = self
                .extension_payload_bytes
                .saturating_add(string_map_payload_bytes(&node.extensions));
            if let PaneNodeKind::Leaf(leaf) = &node.kind {
                self.leaf_payload_bytes = self
                    .leaf_payload_bytes
                    .saturating_add(pane_leaf_surface_payload_bytes(leaf));
                self.extension_entry_count = self
                    .extension_entry_count
                    .saturating_add(leaf.extensions.len());
                self.extension_payload_bytes = self
                    .extension_payload_bytes
                    .saturating_add(string_map_payload_bytes(&leaf.extensions));
            }
        }
    }
}

fn pane_operation_retained_payload_bytes(operation: &PaneOperation) -> usize {
    match operation {
        PaneOperation::SplitLeaf { new_leaf, .. } => pane_leaf_retained_payload_bytes(new_leaf),
        PaneOperation::CloseNode { .. }
        | PaneOperation::MoveSubtree { .. }
        | PaneOperation::SwapNodes { .. }
        | PaneOperation::SetSplitRatio { .. }
        | PaneOperation::NormalizeRatios => 0,
    }
}

fn pane_leaf_retained_payload_bytes(leaf: &PaneLeaf) -> usize {
    pane_leaf_surface_payload_bytes(leaf).saturating_add(string_map_payload_bytes(&leaf.extensions))
}

fn pane_leaf_surface_payload_bytes(leaf: &PaneLeaf) -> usize {
    leaf.surface_key.len()
}

fn string_map_payload_bytes(map: &BTreeMap<String, String>) -> usize {
    map.iter()
        .map(|(key, value)| key.len().saturating_add(value.len()))
        .sum()
}

fn analytically_tuned_checkpoint_interval(
    snapshot_cost_ns: u128,
    replay_step_cost_ns: u128,
) -> usize {
    if snapshot_cost_ns == 0 || replay_step_cost_ns == 0 {
        return DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL;
    }

    let ratio = snapshot_cost_ns.saturating_mul(2) / replay_step_cost_ns;
    let root = integer_sqrt(ratio).max(1);
    usize::try_from(root).unwrap_or(DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL)
}

fn integer_sqrt(value: u128) -> u128 {
    if value < 2 {
        return value;
    }

    let mut left = 1u128;
    let mut right = value;
    let mut answer = 1u128;
    while left <= right {
        let mid = left + (right - left) / 2;
        if mid <= value / mid {
            answer = mid;
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    answer
}

/// Deterministic allocator for pane IDs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneIdAllocator {
    next: PaneId,
}

impl PaneIdAllocator {
    /// Start allocating from a known ID.
    #[must_use]
    pub const fn with_next(next: PaneId) -> Self {
        Self { next }
    }

    /// Create allocator from the next ID in a validated tree.
    #[must_use]
    pub fn from_tree(tree: &PaneTree) -> Self {
        Self { next: tree.next_id }
    }

    /// Peek at the next ID without consuming.
    #[must_use]
    pub const fn peek(&self) -> PaneId {
        self.next
    }

    /// Allocate the next ID and advance.
    pub fn allocate(&mut self) -> Result<PaneId, PaneModelError> {
        let current = self.next;
        self.next = self.next.checked_next()?;
        Ok(current)
    }
}

impl Default for PaneIdAllocator {
    fn default() -> Self {
        Self { next: PaneId::MIN }
    }
}

/// Validation errors for pane schema construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneModelError {
    ZeroPaneId,
    UnsupportedSchemaVersion {
        version: u16,
    },
    DuplicateNodeId {
        node_id: PaneId,
    },
    MissingRoot {
        root: PaneId,
    },
    RootHasParent {
        root: PaneId,
        parent: PaneId,
    },
    MissingParent {
        node_id: PaneId,
        parent: PaneId,
    },
    MissingChild {
        parent: PaneId,
        child: PaneId,
    },
    MultipleParents {
        child: PaneId,
        first_parent: PaneId,
        second_parent: PaneId,
    },
    ParentMismatch {
        node_id: PaneId,
        expected: Option<PaneId>,
        actual: Option<PaneId>,
    },
    SelfReferentialSplit {
        node_id: PaneId,
    },
    DuplicateSplitChildren {
        node_id: PaneId,
        child: PaneId,
    },
    InvalidSplitRatio {
        numerator: u32,
        denominator: u32,
    },
    InvalidConstraint {
        node_id: PaneId,
        axis: &'static str,
        min: u16,
        max: u16,
    },
    NodeConstraintUnsatisfied {
        node_id: PaneId,
        axis: &'static str,
        actual: u16,
        min: u16,
        max: Option<u16>,
    },
    OverconstrainedSplit {
        node_id: PaneId,
        axis: SplitAxis,
        available: u16,
        first_min: u16,
        first_max: u16,
        second_min: u16,
        second_max: u16,
    },
    CycleDetected {
        node_id: PaneId,
    },
    UnreachableNode {
        node_id: PaneId,
    },
    NextIdNotGreaterThanExisting {
        next_id: PaneId,
        max_existing: PaneId,
    },
    PaneIdOverflow {
        current: PaneId,
    },
}

impl fmt::Display for PaneModelError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ZeroPaneId => write!(f, "pane id 0 is invalid"),
            Self::UnsupportedSchemaVersion { version } => {
                write!(
                    f,
                    "unsupported pane schema version {version} (expected {PANE_TREE_SCHEMA_VERSION})"
                )
            }
            Self::DuplicateNodeId { node_id } => write!(f, "duplicate pane node id {}", node_id.0),
            Self::MissingRoot { root } => write!(f, "root pane node {} not found", root.0),
            Self::RootHasParent { root, parent } => write!(
                f,
                "root pane node {} must not have parent {}",
                root.0, parent.0
            ),
            Self::MissingParent { node_id, parent } => write!(
                f,
                "node {} references missing parent {}",
                node_id.0, parent.0
            ),
            Self::MissingChild { parent, child } => write!(
                f,
                "split node {} references missing child {}",
                parent.0, child.0
            ),
            Self::MultipleParents {
                child,
                first_parent,
                second_parent,
            } => write!(
                f,
                "node {} has multiple parents: {} and {}",
                child.0, first_parent.0, second_parent.0
            ),
            Self::ParentMismatch {
                node_id,
                expected,
                actual,
            } => write!(
                f,
                "node {} parent mismatch: expected {:?}, got {:?}",
                node_id.0,
                expected.map(PaneId::get),
                actual.map(PaneId::get)
            ),
            Self::SelfReferentialSplit { node_id } => {
                write!(f, "split node {} cannot reference itself", node_id.0)
            }
            Self::DuplicateSplitChildren { node_id, child } => write!(
                f,
                "split node {} references child {} twice",
                node_id.0, child.0
            ),
            Self::InvalidSplitRatio {
                numerator,
                denominator,
            } => write!(
                f,
                "invalid split ratio {numerator}/{denominator}: both values must be > 0"
            ),
            Self::InvalidConstraint {
                node_id,
                axis,
                min,
                max,
            } => write!(
                f,
                "invalid {axis} constraints for node {}: max {max} < min {min}",
                node_id.0
            ),
            Self::NodeConstraintUnsatisfied {
                node_id,
                axis,
                actual,
                min,
                max,
            } => write!(
                f,
                "node {} {axis}={} violates constraints [min={}, max={:?}]",
                node_id.0, actual, min, max
            ),
            Self::OverconstrainedSplit {
                node_id,
                axis,
                available,
                first_min,
                first_max,
                second_min,
                second_max,
            } => write!(
                f,
                "overconstrained {:?} split at node {} (available={}): first[min={}, max={}], second[min={}, max={}]",
                axis, node_id.0, available, first_min, first_max, second_min, second_max
            ),
            Self::CycleDetected { node_id } => {
                write!(f, "cycle detected at node {}", node_id.0)
            }
            Self::UnreachableNode { node_id } => {
                write!(f, "node {} is unreachable from root", node_id.0)
            }
            Self::NextIdNotGreaterThanExisting {
                next_id,
                max_existing,
            } => write!(
                f,
                "next_id {} must be greater than max existing id {}",
                next_id.0, max_existing.0
            ),
            Self::PaneIdOverflow { current } => {
                write!(f, "pane id overflow after {}", current.0)
            }
        }
    }
}

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

fn snapshot_state_hash(snapshot: &PaneTreeSnapshot) -> u64 {
    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0001_0000_01b3;

    fn mix(hash: &mut u64, byte: u8) {
        *hash ^= u64::from(byte);
        *hash = hash.wrapping_mul(PRIME);
    }

    fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
        for byte in bytes {
            mix(hash, *byte);
        }
    }

    fn mix_u16(hash: &mut u64, value: u16) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_u32(hash: &mut u64, value: u32) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_u64(hash: &mut u64, value: u64) {
        mix_bytes(hash, &value.to_le_bytes());
    }

    fn mix_bool(hash: &mut u64, value: bool) {
        mix(hash, u8::from(value));
    }

    fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
        match value {
            Some(value) => {
                mix(hash, 1);
                mix_u16(hash, value);
            }
            None => mix(hash, 0),
        }
    }

    fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
        match value {
            Some(value) => {
                mix(hash, 1);
                mix_u64(hash, value.get());
            }
            None => mix(hash, 0),
        }
    }

    fn mix_str(hash: &mut u64, value: &str) {
        mix_u64(hash, value.len() as u64);
        mix_bytes(hash, value.as_bytes());
    }

    fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
        mix_u64(hash, extensions.len() as u64);
        for (key, value) in extensions {
            mix_str(hash, key);
            mix_str(hash, value);
        }
    }

    let mut canonical = snapshot.clone();
    canonical.canonicalize();

    let mut hash = OFFSET_BASIS;
    mix_u16(&mut hash, canonical.schema_version);
    mix_u64(&mut hash, canonical.root.get());
    mix_u64(&mut hash, canonical.next_id.get());
    mix_extensions(&mut hash, &canonical.extensions);
    mix_u64(&mut hash, canonical.nodes.len() as u64);

    for node in &canonical.nodes {
        mix_u64(&mut hash, node.id.get());
        mix_opt_pane_id(&mut hash, node.parent);
        mix_u16(&mut hash, node.constraints.min_width);
        mix_u16(&mut hash, node.constraints.min_height);
        mix_opt_u16(&mut hash, node.constraints.max_width);
        mix_opt_u16(&mut hash, node.constraints.max_height);
        mix_bool(&mut hash, node.constraints.collapsible);
        mix_extensions(&mut hash, &node.extensions);

        match &node.kind {
            PaneNodeKind::Leaf(leaf) => {
                mix(&mut hash, 1);
                mix_str(&mut hash, &leaf.surface_key);
                mix_extensions(&mut hash, &leaf.extensions);
            }
            PaneNodeKind::Split(split) => {
                mix(&mut hash, 2);
                let axis_byte = match split.axis {
                    SplitAxis::Horizontal => 1,
                    SplitAxis::Vertical => 2,
                };
                mix(&mut hash, axis_byte);
                mix_u32(&mut hash, split.ratio.numerator());
                mix_u32(&mut hash, split.ratio.denominator());
                mix_u64(&mut hash, split.first.get());
                mix_u64(&mut hash, split.second.get());
            }
        }
    }

    hash
}

fn push_invariant_issue(
    issues: &mut Vec<PaneInvariantIssue>,
    code: PaneInvariantCode,
    repairable: bool,
    node_id: Option<PaneId>,
    related_node: Option<PaneId>,
    message: impl Into<String>,
) {
    issues.push(PaneInvariantIssue {
        code,
        severity: PaneInvariantSeverity::Error,
        repairable,
        node_id,
        related_node,
        message: message.into(),
    });
}

fn dfs_collect_cycles_and_reachable(
    node_id: PaneId,
    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
    visiting: &mut BTreeSet<PaneId>,
    visited: &mut BTreeSet<PaneId>,
    cycle_nodes: &mut BTreeSet<PaneId>,
) {
    if visiting.contains(&node_id) {
        let _ = cycle_nodes.insert(node_id);
        return;
    }
    if !visited.insert(node_id) {
        return;
    }

    let _ = visiting.insert(node_id);
    if let Some(node) = nodes.get(&node_id)
        && let PaneNodeKind::Split(split) = &node.kind
    {
        for child in [split.first, split.second] {
            if nodes.contains_key(&child) {
                dfs_collect_cycles_and_reachable(child, nodes, visiting, visited, cycle_nodes);
            }
        }
    }
    let _ = visiting.remove(&node_id);
}

fn build_invariant_report(snapshot: &PaneTreeSnapshot) -> PaneInvariantReport {
    let mut issues = Vec::new();

    if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
        push_invariant_issue(
            &mut issues,
            PaneInvariantCode::UnsupportedSchemaVersion,
            false,
            None,
            None,
            format!(
                "unsupported schema version {} (expected {})",
                snapshot.schema_version, PANE_TREE_SCHEMA_VERSION
            ),
        );
    }

    let mut nodes = BTreeMap::new();
    for node in &snapshot.nodes {
        if nodes.insert(node.id, node.clone()).is_some() {
            push_invariant_issue(
                &mut issues,
                PaneInvariantCode::DuplicateNodeId,
                false,
                Some(node.id),
                None,
                format!("duplicate node id {}", node.id.get()),
            );
        }
    }

    if let Some(max_existing) = nodes.keys().next_back().copied()
        && snapshot.next_id <= max_existing
    {
        push_invariant_issue(
            &mut issues,
            PaneInvariantCode::NextIdNotGreaterThanExisting,
            true,
            Some(snapshot.next_id),
            Some(max_existing),
            format!(
                "next_id {} must be greater than max node id {}",
                snapshot.next_id.get(),
                max_existing.get()
            ),
        );
    }

    if !nodes.contains_key(&snapshot.root) {
        push_invariant_issue(
            &mut issues,
            PaneInvariantCode::MissingRoot,
            false,
            Some(snapshot.root),
            None,
            format!("root node {} is missing", snapshot.root.get()),
        );
    }

    let mut expected_parents = BTreeMap::new();
    for node in nodes.values() {
        if let Err(err) = node.constraints.validate(node.id) {
            push_invariant_issue(
                &mut issues,
                PaneInvariantCode::InvalidConstraint,
                false,
                Some(node.id),
                None,
                err.to_string(),
            );
        }

        if let Some(parent) = node.parent
            && !nodes.contains_key(&parent)
        {
            push_invariant_issue(
                &mut issues,
                PaneInvariantCode::MissingParent,
                true,
                Some(node.id),
                Some(parent),
                format!(
                    "node {} references missing parent {}",
                    node.id.get(),
                    parent.get()
                ),
            );
        }

        if let PaneNodeKind::Split(split) = &node.kind {
            if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
                push_invariant_issue(
                    &mut issues,
                    PaneInvariantCode::InvalidSplitRatio,
                    false,
                    Some(node.id),
                    None,
                    format!(
                        "split node {} has invalid ratio {}/{}",
                        node.id.get(),
                        split.ratio.numerator(),
                        split.ratio.denominator()
                    ),
                );
            }

            if split.first == node.id || split.second == node.id {
                push_invariant_issue(
                    &mut issues,
                    PaneInvariantCode::SelfReferentialSplit,
                    false,
                    Some(node.id),
                    None,
                    format!("split node {} references itself", node.id.get()),
                );
            }

            if split.first == split.second {
                push_invariant_issue(
                    &mut issues,
                    PaneInvariantCode::DuplicateSplitChildren,
                    false,
                    Some(node.id),
                    Some(split.first),
                    format!(
                        "split node {} references child {} twice",
                        node.id.get(),
                        split.first.get()
                    ),
                );
            }

            for child in [split.first, split.second] {
                if !nodes.contains_key(&child) {
                    push_invariant_issue(
                        &mut issues,
                        PaneInvariantCode::MissingChild,
                        false,
                        Some(node.id),
                        Some(child),
                        format!(
                            "split node {} references missing child {}",
                            node.id.get(),
                            child.get()
                        ),
                    );
                    continue;
                }

                if let Some(first_parent) = expected_parents.insert(child, node.id)
                    && first_parent != node.id
                {
                    push_invariant_issue(
                        &mut issues,
                        PaneInvariantCode::MultipleParents,
                        false,
                        Some(child),
                        Some(node.id),
                        format!(
                            "node {} has multiple split parents {} and {}",
                            child.get(),
                            first_parent.get(),
                            node.id.get()
                        ),
                    );
                }
            }
        }
    }

    if let Some(root_node) = nodes.get(&snapshot.root)
        && let Some(parent) = root_node.parent
    {
        push_invariant_issue(
            &mut issues,
            PaneInvariantCode::RootHasParent,
            true,
            Some(snapshot.root),
            Some(parent),
            format!(
                "root node {} must not have parent {}",
                snapshot.root.get(),
                parent.get()
            ),
        );
    }

    for node in nodes.values() {
        let expected_parent = if node.id == snapshot.root {
            None
        } else {
            expected_parents.get(&node.id).copied()
        };

        if node.parent != expected_parent {
            push_invariant_issue(
                &mut issues,
                PaneInvariantCode::ParentMismatch,
                true,
                Some(node.id),
                expected_parent,
                format!(
                    "node {} parent mismatch: expected {:?}, got {:?}",
                    node.id.get(),
                    expected_parent.map(PaneId::get),
                    node.parent.map(PaneId::get)
                ),
            );
        }
    }

    if nodes.contains_key(&snapshot.root) {
        let mut visiting = BTreeSet::new();
        let mut visited = BTreeSet::new();
        let mut cycle_nodes = BTreeSet::new();
        dfs_collect_cycles_and_reachable(
            snapshot.root,
            &nodes,
            &mut visiting,
            &mut visited,
            &mut cycle_nodes,
        );

        for node_id in cycle_nodes {
            push_invariant_issue(
                &mut issues,
                PaneInvariantCode::CycleDetected,
                false,
                Some(node_id),
                None,
                format!("cycle detected at node {}", node_id.get()),
            );
        }

        for node_id in nodes.keys() {
            if !visited.contains(node_id) {
                push_invariant_issue(
                    &mut issues,
                    PaneInvariantCode::UnreachableNode,
                    true,
                    Some(*node_id),
                    None,
                    format!("node {} is unreachable from root", node_id.get()),
                );
            }
        }
    }

    issues.sort_by(|left, right| {
        (
            left.code,
            left.node_id.is_none(),
            left.node_id,
            left.related_node.is_none(),
            left.related_node,
            &left.message,
        )
            .cmp(&(
                right.code,
                right.node_id.is_none(),
                right.node_id,
                right.related_node.is_none(),
                right.related_node,
                &right.message,
            ))
    });

    PaneInvariantReport {
        snapshot_hash: snapshot_state_hash(snapshot),
        issues,
    }
}

fn repair_snapshot_safe(
    mut snapshot: PaneTreeSnapshot,
) -> Result<PaneRepairOutcome, PaneRepairError> {
    snapshot.canonicalize();

    let before_hash = snapshot_state_hash(&snapshot);
    let report_before = build_invariant_report(&snapshot);
    let mut unsafe_codes = report_before
        .issues
        .iter()
        .filter(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
        .map(|issue| issue.code)
        .collect::<Vec<_>>();
    unsafe_codes.sort();
    unsafe_codes.dedup();

    if !unsafe_codes.is_empty() {
        return Err(PaneRepairError {
            before_hash,
            report: report_before,
            reason: PaneRepairFailure::UnsafeIssuesPresent {
                codes: unsafe_codes,
            },
        });
    }

    let mut nodes = BTreeMap::new();
    for node in snapshot.nodes {
        let _ = nodes.entry(node.id).or_insert(node);
    }

    let mut actions = Vec::new();
    let mut expected_parents = BTreeMap::new();
    for node in nodes.values() {
        if let PaneNodeKind::Split(split) = &node.kind {
            for child in [split.first, split.second] {
                let _ = expected_parents.entry(child).or_insert(node.id);
            }
        }
    }

    for node in nodes.values_mut() {
        let expected_parent = if node.id == snapshot.root {
            None
        } else {
            expected_parents.get(&node.id).copied()
        };
        if node.parent != expected_parent {
            actions.push(PaneRepairAction::ReparentNode {
                node_id: node.id,
                before_parent: node.parent,
                after_parent: expected_parent,
            });
            node.parent = expected_parent;
        }

        if let PaneNodeKind::Split(split) = &mut node.kind {
            let normalized =
                PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator()).map_err(
                    |error| PaneRepairError {
                        before_hash,
                        report: report_before.clone(),
                        reason: PaneRepairFailure::ValidationFailed { error },
                    },
                )?;
            if split.ratio != normalized {
                actions.push(PaneRepairAction::NormalizeRatio {
                    node_id: node.id,
                    before_numerator: split.ratio.numerator(),
                    before_denominator: split.ratio.denominator(),
                    after_numerator: normalized.numerator(),
                    after_denominator: normalized.denominator(),
                });
                split.ratio = normalized;
            }
        }
    }

    let mut visiting = BTreeSet::new();
    let mut visited = BTreeSet::new();
    let mut cycle_nodes = BTreeSet::new();
    if nodes.contains_key(&snapshot.root) {
        dfs_collect_cycles_and_reachable(
            snapshot.root,
            &nodes,
            &mut visiting,
            &mut visited,
            &mut cycle_nodes,
        );
    }
    if !cycle_nodes.is_empty() {
        let mut codes = vec![PaneInvariantCode::CycleDetected];
        codes.sort();
        codes.dedup();
        return Err(PaneRepairError {
            before_hash,
            report: report_before,
            reason: PaneRepairFailure::UnsafeIssuesPresent { codes },
        });
    }

    let all_node_ids = nodes.keys().copied().collect::<Vec<_>>();
    for node_id in all_node_ids {
        if !visited.contains(&node_id) {
            let _ = nodes.remove(&node_id);
            actions.push(PaneRepairAction::RemoveOrphanNode { node_id });
        }
    }

    if let Some(max_existing) = nodes.keys().next_back().copied()
        && snapshot.next_id <= max_existing
    {
        let after = max_existing
            .checked_next()
            .map_err(|error| PaneRepairError {
                before_hash,
                report: report_before.clone(),
                reason: PaneRepairFailure::ValidationFailed { error },
            })?;
        actions.push(PaneRepairAction::BumpNextId {
            before: snapshot.next_id,
            after,
        });
        snapshot.next_id = after;
    }

    snapshot.nodes = nodes.into_values().collect();
    snapshot.canonicalize();

    let tree = PaneTree::from_snapshot(snapshot).map_err(|error| PaneRepairError {
        before_hash,
        report: report_before.clone(),
        reason: PaneRepairFailure::ValidationFailed { error },
    })?;
    let report_after = tree.invariant_report();
    let after_hash = tree.state_hash();

    Ok(PaneRepairOutcome {
        before_hash,
        after_hash,
        report_before,
        report_after,
        actions,
        tree,
    })
}

fn validate_tree(
    root: PaneId,
    next_id: PaneId,
    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
) -> Result<(), PaneModelError> {
    if !nodes.contains_key(&root) {
        return Err(PaneModelError::MissingRoot { root });
    }

    let max_existing = nodes.keys().next_back().copied().unwrap_or(root);
    if next_id <= max_existing {
        return Err(PaneModelError::NextIdNotGreaterThanExisting {
            next_id,
            max_existing,
        });
    }

    let mut expected_parents = BTreeMap::new();

    for node in nodes.values() {
        node.constraints.validate(node.id)?;

        if let Some(parent) = node.parent
            && !nodes.contains_key(&parent)
        {
            return Err(PaneModelError::MissingParent {
                node_id: node.id,
                parent,
            });
        }

        if let PaneNodeKind::Split(split) = &node.kind {
            if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
                return Err(PaneModelError::InvalidSplitRatio {
                    numerator: split.ratio.numerator(),
                    denominator: split.ratio.denominator(),
                });
            }

            if split.first == node.id || split.second == node.id {
                return Err(PaneModelError::SelfReferentialSplit { node_id: node.id });
            }
            if split.first == split.second {
                return Err(PaneModelError::DuplicateSplitChildren {
                    node_id: node.id,
                    child: split.first,
                });
            }

            for child in [split.first, split.second] {
                if !nodes.contains_key(&child) {
                    return Err(PaneModelError::MissingChild {
                        parent: node.id,
                        child,
                    });
                }
                if let Some(first_parent) = expected_parents.insert(child, node.id)
                    && first_parent != node.id
                {
                    return Err(PaneModelError::MultipleParents {
                        child,
                        first_parent,
                        second_parent: node.id,
                    });
                }
            }
        }
    }

    if let Some(parent) = nodes.get(&root).and_then(|node| node.parent) {
        return Err(PaneModelError::RootHasParent { root, parent });
    }

    for node in nodes.values() {
        let expected = if node.id == root {
            None
        } else {
            expected_parents.get(&node.id).copied()
        };
        if node.parent != expected {
            return Err(PaneModelError::ParentMismatch {
                node_id: node.id,
                expected,
                actual: node.parent,
            });
        }
    }

    let mut visiting = BTreeSet::new();
    let mut visited = BTreeSet::new();
    dfs_validate(root, nodes, &mut visiting, &mut visited)?;

    if visited.len() != nodes.len()
        && let Some(node_id) = nodes.keys().find(|node_id| !visited.contains(node_id))
    {
        return Err(PaneModelError::UnreachableNode { node_id: *node_id });
    }

    Ok(())
}

#[derive(Debug, Clone, Copy)]
struct AxisBounds {
    min: u16,
    max: Option<u16>,
}

fn axis_bounds(constraints: PaneConstraints, axis: SplitAxis) -> AxisBounds {
    match axis {
        SplitAxis::Horizontal => AxisBounds {
            min: constraints.min_width,
            max: constraints.max_width,
        },
        SplitAxis::Vertical => AxisBounds {
            min: constraints.min_height,
            max: constraints.max_height,
        },
    }
}

fn validate_area_against_constraints(
    node_id: PaneId,
    area: Rect,
    constraints: PaneConstraints,
) -> Result<(), PaneModelError> {
    if area.width < constraints.min_width {
        return Err(PaneModelError::NodeConstraintUnsatisfied {
            node_id,
            axis: "width",
            actual: area.width,
            min: constraints.min_width,
            max: constraints.max_width,
        });
    }
    if area.height < constraints.min_height {
        return Err(PaneModelError::NodeConstraintUnsatisfied {
            node_id,
            axis: "height",
            actual: area.height,
            min: constraints.min_height,
            max: constraints.max_height,
        });
    }
    if let Some(max_width) = constraints.max_width
        && area.width > max_width
    {
        return Err(PaneModelError::NodeConstraintUnsatisfied {
            node_id,
            axis: "width",
            actual: area.width,
            min: constraints.min_width,
            max: constraints.max_width,
        });
    }
    if let Some(max_height) = constraints.max_height
        && area.height > max_height
    {
        return Err(PaneModelError::NodeConstraintUnsatisfied {
            node_id,
            axis: "height",
            actual: area.height,
            min: constraints.min_height,
            max: constraints.max_height,
        });
    }
    Ok(())
}

fn solve_split_sizes(
    node_id: PaneId,
    axis: SplitAxis,
    available: u16,
    ratio: PaneSplitRatio,
    first: AxisBounds,
    second: AxisBounds,
) -> Result<(u16, u16), PaneModelError> {
    let first_max = first.max.unwrap_or(available).min(available);
    let second_max = second.max.unwrap_or(available).min(available);

    let feasible_first_min = first.min.max(available.saturating_sub(second_max));
    let feasible_first_max = first_max.min(available.saturating_sub(second.min));

    if feasible_first_min > feasible_first_max {
        return Err(PaneModelError::OverconstrainedSplit {
            node_id,
            axis,
            available,
            first_min: first.min,
            first_max,
            second_min: second.min,
            second_max,
        });
    }

    let total_weight = u64::from(ratio.numerator()) + u64::from(ratio.denominator());
    let desired_first_u64 = (u64::from(available) * u64::from(ratio.numerator())) / total_weight;
    let desired_first = desired_first_u64 as u16;

    let first_size = desired_first.clamp(feasible_first_min, feasible_first_max);
    let second_size = available.saturating_sub(first_size);
    Ok((first_size, second_size))
}

fn dfs_validate(
    node_id: PaneId,
    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
    visiting: &mut BTreeSet<PaneId>,
    visited: &mut BTreeSet<PaneId>,
) -> Result<(), PaneModelError> {
    if visiting.contains(&node_id) {
        return Err(PaneModelError::CycleDetected { node_id });
    }
    if !visited.insert(node_id) {
        return Ok(());
    }

    let _ = visiting.insert(node_id);
    if let Some(node) = nodes.get(&node_id)
        && let PaneNodeKind::Split(split) = &node.kind
    {
        dfs_validate(split.first, nodes, visiting, visited)?;
        dfs_validate(split.second, nodes, visiting, visited)?;
    }
    let _ = visiting.remove(&node_id);
    Ok(())
}

fn gcd_u32(mut left: u32, mut right: u32) -> u32 {
    while right != 0 {
        let rem = left % right;
        left = right;
        right = rem;
    }
    left.max(1)
}

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

    fn id(raw: u64) -> PaneId {
        PaneId::new(raw).expect("test ID must be non-zero")
    }

    fn make_valid_snapshot() -> PaneTreeSnapshot {
        let root = id(1);
        let left = id(2);
        let right = id(3);

        PaneTreeSnapshot {
            schema_version: PANE_TREE_SCHEMA_VERSION,
            root,
            next_id: id(4),
            nodes: vec![
                PaneNodeRecord::leaf(
                    right,
                    Some(root),
                    PaneLeaf {
                        surface_key: "right".to_string(),
                        extensions: BTreeMap::new(),
                    },
                ),
                PaneNodeRecord::split(
                    root,
                    None,
                    PaneSplit {
                        axis: SplitAxis::Horizontal,
                        ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
                        first: left,
                        second: right,
                    },
                ),
                PaneNodeRecord::leaf(
                    left,
                    Some(root),
                    PaneLeaf {
                        surface_key: "left".to_string(),
                        extensions: BTreeMap::new(),
                    },
                ),
            ],
            extensions: BTreeMap::new(),
        }
    }

    fn split_ratio(tree: &PaneTree, split: PaneId) -> PaneSplitRatio {
        let node = tree.node(split).expect("split node should exist");
        let PaneNodeKind::Split(split_node) = &node.kind else {
            let expected_split_node = false;
            assert!(expected_split_node, "node should be a split");
            return PaneSplitRatio::default();
        };
        split_node.ratio
    }

    fn make_nested_snapshot() -> PaneTreeSnapshot {
        let root = id(1);
        let left = id(2);
        let right_split = id(3);
        let right_top = id(4);
        let right_bottom = id(5);

        PaneTreeSnapshot {
            schema_version: PANE_TREE_SCHEMA_VERSION,
            root,
            next_id: id(6),
            nodes: vec![
                PaneNodeRecord::split(
                    root,
                    None,
                    PaneSplit {
                        axis: SplitAxis::Horizontal,
                        ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                        first: left,
                        second: right_split,
                    },
                ),
                PaneNodeRecord::leaf(left, Some(root), PaneLeaf::new("left")),
                PaneNodeRecord::split(
                    right_split,
                    Some(root),
                    PaneSplit {
                        axis: SplitAxis::Vertical,
                        ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                        first: right_top,
                        second: right_bottom,
                    },
                ),
                PaneNodeRecord::leaf(right_top, Some(right_split), PaneLeaf::new("right_top")),
                PaneNodeRecord::leaf(
                    right_bottom,
                    Some(right_split),
                    PaneLeaf::new("right_bottom"),
                ),
            ],
            extensions: BTreeMap::new(),
        }
    }

    #[test]
    fn ratio_is_normalized() {
        let ratio = PaneSplitRatio::new(12, 8).expect("ratio should normalize");
        assert_eq!(ratio.numerator(), 3);
        assert_eq!(ratio.denominator(), 2);
    }

    #[test]
    fn snapshot_round_trip_preserves_canonical_order() {
        let tree =
            PaneTree::from_snapshot(make_valid_snapshot()).expect("snapshot should validate");
        let snapshot = tree.to_snapshot();
        let ids = snapshot
            .nodes
            .iter()
            .map(|node| node.id.get())
            .collect::<Vec<_>>();
        assert_eq!(ids, vec![1, 2, 3]);
    }

    #[test]
    fn duplicate_node_id_is_rejected() {
        let mut snapshot = make_valid_snapshot();
        snapshot.nodes.push(PaneNodeRecord::leaf(
            id(2),
            Some(id(1)),
            PaneLeaf::new("dup"),
        ));
        let err = PaneTree::from_snapshot(snapshot).expect_err("duplicate ID should fail");
        assert_eq!(err, PaneModelError::DuplicateNodeId { node_id: id(2) });
    }

    #[test]
    fn missing_child_is_rejected() {
        let mut snapshot = make_valid_snapshot();
        snapshot.nodes.retain(|node| node.id != id(3));
        let err = PaneTree::from_snapshot(snapshot).expect_err("missing child should fail");
        assert_eq!(
            err,
            PaneModelError::MissingChild {
                parent: id(1),
                child: id(3),
            }
        );
    }

    #[test]
    fn unreachable_node_is_rejected() {
        let mut snapshot = make_valid_snapshot();
        snapshot
            .nodes
            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
        snapshot.next_id = id(11);
        let err = PaneTree::from_snapshot(snapshot).expect_err("orphan should fail");
        assert_eq!(err, PaneModelError::UnreachableNode { node_id: id(10) });
    }

    #[test]
    fn next_id_must_be_greater_than_existing_ids() {
        let mut snapshot = make_valid_snapshot();
        snapshot.next_id = id(3);
        let err = PaneTree::from_snapshot(snapshot).expect_err("next_id should be > max ID");
        assert_eq!(
            err,
            PaneModelError::NextIdNotGreaterThanExisting {
                next_id: id(3),
                max_existing: id(3),
            }
        );
    }

    #[test]
    fn constraints_validate_bounds() {
        let constraints = PaneConstraints {
            min_width: 8,
            min_height: 1,
            max_width: Some(4),
            max_height: None,
            collapsible: false,
            margin: None,
            padding: None,
        };
        let err = constraints
            .validate(id(5))
            .expect_err("max width below min width must fail");
        assert_eq!(
            err,
            PaneModelError::InvalidConstraint {
                node_id: id(5),
                axis: "width",
                min: 8,
                max: 4,
            }
        );
    }

    #[test]
    fn allocator_is_deterministic() {
        let mut allocator = PaneIdAllocator::default();
        assert_eq!(allocator.allocate().expect("id 1"), id(1));
        assert_eq!(allocator.allocate().expect("id 2"), id(2));
        assert_eq!(allocator.peek(), id(3));
    }

    #[test]
    fn snapshot_json_shape_contains_forward_compat_fields() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let json = serde_json::to_value(tree.to_snapshot()).expect("snapshot should serialize");
        assert_eq!(json["schema_version"], serde_json::json!(1));
        assert!(json.get("extensions").is_some());
        let nodes = json["nodes"]
            .as_array()
            .expect("nodes should serialize as array");
        assert_eq!(nodes.len(), 3);
        assert!(nodes[0].get("kind").is_some());
    }

    #[test]
    fn solver_horizontal_ratio_split() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 50, 10))
            .expect("layout solve should succeed");

        assert_eq!(layout.rect(id(1)), Some(Rect::new(0, 0, 50, 10)));
        assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 30, 10)));
        assert_eq!(layout.rect(id(3)), Some(Rect::new(30, 0, 20, 10)));
    }

    #[test]
    fn solver_clamps_to_child_minimum_constraints() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if node.id == id(2) {
                node.constraints.min_width = 35;
            }
        }

        let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 50, 10))
            .expect("layout solve should succeed");

        assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 35, 10)));
        assert_eq!(layout.rect(id(3)), Some(Rect::new(35, 0, 15, 10)));
    }

    #[test]
    fn solver_rejects_overconstrained_split() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if node.id == id(2) {
                node.constraints.min_width = 30;
            }
            if node.id == id(3) {
                node.constraints.min_width = 30;
            }
        }

        let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
        let err = tree
            .solve_layout(Rect::new(0, 0, 50, 10))
            .expect_err("infeasible constraints should fail");

        assert_eq!(
            err,
            PaneModelError::OverconstrainedSplit {
                node_id: id(1),
                axis: SplitAxis::Horizontal,
                available: 50,
                first_min: 30,
                first_max: 50,
                second_min: 30,
                second_max: 50,
            }
        );
    }

    #[test]
    fn solver_is_deterministic() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let first = tree
            .solve_layout(Rect::new(0, 0, 79, 17))
            .expect("first solve should succeed");
        let second = tree
            .solve_layout(Rect::new(0, 0, 79, 17))
            .expect("second solve should succeed");
        assert_eq!(first, second);
    }

    #[test]
    fn split_leaf_wraps_existing_leaf_with_new_split() {
        let mut tree = PaneTree::singleton("root");
        let outcome = tree
            .apply_operation(
                7,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                    new_leaf: PaneLeaf::new("new"),
                },
            )
            .expect("split should succeed");

        assert_eq!(outcome.operation_id, 7);
        assert_eq!(outcome.kind, PaneOperationKind::SplitLeaf);
        assert_ne!(outcome.before_hash, outcome.after_hash);
        assert_eq!(tree.root(), id(2));

        let root = tree.node(id(2)).expect("split node exists");
        let PaneNodeKind::Split(split) = &root.kind else {
            unreachable!("root should be split");
        };
        assert_eq!(split.first, id(1));
        assert_eq!(split.second, id(3));

        let original = tree.node(id(1)).expect("original leaf exists");
        assert_eq!(original.parent, Some(id(2)));
        assert!(matches!(original.kind, PaneNodeKind::Leaf(_)));

        let new_leaf = tree.node(id(3)).expect("new leaf exists");
        assert_eq!(new_leaf.parent, Some(id(2)));
        let PaneNodeKind::Leaf(leaf) = &new_leaf.kind else {
            unreachable!("new node must be leaf");
        };
        assert_eq!(leaf.surface_key, "new");
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn touched_nodes_compact_storage_holds_correct_content() {
        // Compact (`SmallVec`) touched-node storage must round-trip the same
        // content as before across the structural, fast, and rejected paths.
        let mut tree = PaneTree::singleton("root");
        let split = tree
            .apply_operation(
                1,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(1, 1).expect("ratio"),
                    placement: PanePlacement::ExistingFirst,
                    new_leaf: PaneLeaf::new("b"),
                },
            )
            .expect("split should succeed");
        // Structural op: touched set carries the target (multi-element case).
        assert!(split.touched_nodes.contains(&id(1)));

        // SetSplitRatio (the resize fast path) touches exactly the split node.
        let resize = tree
            .apply_operation(
                2,
                PaneOperation::SetSplitRatio {
                    split: id(2),
                    ratio: PaneSplitRatio::new(3, 1).expect("ratio"),
                },
            )
            .expect("resize should succeed");
        assert_eq!(resize.touched_nodes.to_vec(), vec![id(2)]);

        // A rejected fast-path op also carries compact touched-node storage.
        let rejected = tree
            .apply_operation(
                3,
                PaneOperation::SetSplitRatio {
                    split: id(999),
                    ratio: PaneSplitRatio::new(1, 1).expect("ratio"),
                },
            )
            .expect_err("missing split must be rejected");
        assert_eq!(rejected.touched_nodes.to_vec(), vec![id(999)]);
    }

    #[test]
    fn close_node_promotes_sibling_and_removes_split_parent() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let outcome = tree
            .apply_operation(8, PaneOperation::CloseNode { target: id(2) })
            .expect("close should succeed");
        assert_eq!(outcome.kind, PaneOperationKind::CloseNode);

        assert_eq!(tree.root(), id(3));
        assert!(tree.node(id(1)).is_none());
        assert!(tree.node(id(2)).is_none());
        assert_eq!(tree.node(id(3)).and_then(|node| node.parent), None);
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn close_root_is_rejected_with_stable_hashes() {
        let mut tree = PaneTree::singleton("root");
        let err = tree
            .apply_operation(9, PaneOperation::CloseNode { target: id(1) })
            .expect_err("closing root must fail");

        assert_eq!(err.operation_id, 9);
        assert_eq!(err.kind, PaneOperationKind::CloseNode);
        assert_eq!(
            err.reason,
            PaneOperationFailure::CannotCloseRoot { node_id: id(1) }
        );
        assert_eq!(err.before_hash, err.after_hash);
        assert_eq!(tree.root(), id(1));
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn move_subtree_wraps_target_and_detaches_old_parent() {
        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
        let outcome = tree
            .apply_operation(
                10,
                PaneOperation::MoveSubtree {
                    source: id(4),
                    target: id(2),
                    axis: SplitAxis::Vertical,
                    ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                },
            )
            .expect("move should succeed");
        assert_eq!(outcome.kind, PaneOperationKind::MoveSubtree);

        assert!(
            tree.node(id(3)).is_none(),
            "old split parent should be removed"
        );
        assert_eq!(tree.node(id(5)).and_then(|node| node.parent), Some(id(1)));

        let inserted_split = tree
            .nodes()
            .find(|node| matches!(node.kind, PaneNodeKind::Split(_)) && node.id.get() >= 6)
            .expect("new split should exist");
        let PaneNodeKind::Split(split) = &inserted_split.kind else {
            unreachable!();
        };
        assert_eq!(split.first, id(2));
        assert_eq!(split.second, id(4));
        assert_eq!(
            tree.node(id(2)).and_then(|node| node.parent),
            Some(inserted_split.id)
        );
        assert_eq!(
            tree.node(id(4)).and_then(|node| node.parent),
            Some(inserted_split.id)
        );
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn move_subtree_rejects_ancestor_target() {
        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
        let err = tree
            .apply_operation(
                11,
                PaneOperation::MoveSubtree {
                    source: id(3),
                    target: id(4),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                },
            )
            .expect_err("ancestor move must fail");

        assert_eq!(err.kind, PaneOperationKind::MoveSubtree);
        assert_eq!(
            err.reason,
            PaneOperationFailure::AncestorConflict {
                ancestor: id(3),
                descendant: id(4),
            }
        );
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn swap_nodes_exchanges_sibling_positions() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let outcome = tree
            .apply_operation(
                12,
                PaneOperation::SwapNodes {
                    first: id(2),
                    second: id(3),
                },
            )
            .expect("swap should succeed");
        assert_eq!(outcome.kind, PaneOperationKind::SwapNodes);

        let root = tree.node(id(1)).expect("root exists");
        let PaneNodeKind::Split(split) = &root.kind else {
            unreachable!("root should remain split");
        };
        assert_eq!(split.first, id(3));
        assert_eq!(split.second, id(2));
        assert_eq!(tree.node(id(2)).and_then(|node| node.parent), Some(id(1)));
        assert_eq!(tree.node(id(3)).and_then(|node| node.parent), Some(id(1)));
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn swap_nodes_rejects_ancestor_relation() {
        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
        let err = tree
            .apply_operation(
                13,
                PaneOperation::SwapNodes {
                    first: id(3),
                    second: id(4),
                },
            )
            .expect_err("ancestor swap must fail");

        assert_eq!(err.kind, PaneOperationKind::SwapNodes);
        assert_eq!(
            err.reason,
            PaneOperationFailure::AncestorConflict {
                ancestor: id(3),
                descendant: id(4),
            }
        );
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn normalize_ratios_canonicalizes_non_reduced_values() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if let PaneNodeKind::Split(split) = &mut node.kind {
                split.ratio = PaneSplitRatio {
                    numerator: 12,
                    denominator: 8,
                };
            }
        }

        let mut tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
        let outcome = tree
            .apply_operation(14, PaneOperation::NormalizeRatios)
            .expect("normalize should succeed");
        assert_eq!(outcome.kind, PaneOperationKind::NormalizeRatios);

        let root = tree.node(id(1)).expect("root exists");
        let PaneNodeKind::Split(split) = &root.kind else {
            unreachable!("root should be split");
        };
        assert_eq!(split.ratio.numerator(), 3);
        assert_eq!(split.ratio.denominator(), 2);
    }

    #[test]
    fn transaction_commit_persists_mutations_and_journal_order() {
        let tree = PaneTree::singleton("root");
        let mut tx = tree.begin_transaction(77);

        let split = tx
            .apply_operation(
                100,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                    new_leaf: PaneLeaf::new("secondary"),
                },
            )
            .expect("split should succeed");
        assert_eq!(split.kind, PaneOperationKind::SplitLeaf);

        let normalize = tx
            .apply_operation(101, PaneOperation::NormalizeRatios)
            .expect("normalize should succeed");
        assert_eq!(normalize.kind, PaneOperationKind::NormalizeRatios);

        let outcome = tx.commit();
        assert!(outcome.committed);
        assert_eq!(outcome.transaction_id, 77);
        assert_eq!(outcome.tree.root(), id(2));
        assert_eq!(outcome.journal.len(), 2);
        assert_eq!(outcome.journal[0].sequence, 1);
        assert_eq!(outcome.journal[1].sequence, 2);
        assert_eq!(outcome.journal[0].operation_id, 100);
        assert_eq!(outcome.journal[1].operation_id, 101);
        assert_eq!(
            outcome.journal[0].result,
            PaneOperationJournalResult::Applied
        );
        assert_eq!(
            outcome.journal[1].result,
            PaneOperationJournalResult::Applied
        );
    }

    #[test]
    fn transaction_rollback_discards_mutations() {
        let tree = PaneTree::singleton("root");
        let before_hash = tree.state_hash();
        let mut tx = tree.begin_transaction(78);

        tx.apply_operation(
            200,
            PaneOperation::SplitLeaf {
                target: id(1),
                axis: SplitAxis::Vertical,
                ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
                placement: PanePlacement::ExistingFirst,
                new_leaf: PaneLeaf::new("extra"),
            },
        )
        .expect("split should succeed");

        let outcome = tx.rollback();
        assert!(!outcome.committed);
        assert_eq!(outcome.tree.state_hash(), before_hash);
        assert_eq!(outcome.tree.root(), id(1));
        assert_eq!(outcome.journal.len(), 1);
        assert_eq!(outcome.journal[0].operation_id, 200);
    }

    #[test]
    fn transaction_journals_rejected_operation_without_mutation() {
        let tree = PaneTree::singleton("root");
        let mut tx = tree.begin_transaction(79);
        let before_hash = tx.tree().state_hash();

        let err = tx
            .apply_operation(300, PaneOperation::CloseNode { target: id(1) })
            .expect_err("close root should fail");
        assert_eq!(err.before_hash, err.after_hash);
        assert_eq!(tx.tree().state_hash(), before_hash);

        let journal = tx.journal();
        assert_eq!(journal.len(), 1);
        assert_eq!(journal[0].operation_id, 300);
        let PaneOperationJournalResult::Rejected { reason } = &journal[0].result else {
            unreachable!("journal entry should be rejected");
        };
        assert!(reason.contains("cannot close root"));
    }

    #[test]
    fn transaction_journal_is_deterministic_for_equivalent_runs() {
        let base = PaneTree::singleton("root");

        let mut first_tx = base.begin_transaction(80);
        first_tx
            .apply_operation(
                1,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
                    placement: PanePlacement::IncomingFirst,
                    new_leaf: PaneLeaf::new("new"),
                },
            )
            .expect("split should succeed");
        first_tx
            .apply_operation(2, PaneOperation::NormalizeRatios)
            .expect("normalize should succeed");
        let first = first_tx.commit();

        let mut second_tx = base.begin_transaction(80);
        second_tx
            .apply_operation(
                1,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
                    placement: PanePlacement::IncomingFirst,
                    new_leaf: PaneLeaf::new("new"),
                },
            )
            .expect("split should succeed");
        second_tx
            .apply_operation(2, PaneOperation::NormalizeRatios)
            .expect("normalize should succeed");
        let second = second_tx.commit();

        assert_eq!(first.tree.state_hash(), second.tree.state_hash());
        assert_eq!(first.journal, second.journal);
    }

    #[test]
    fn invariant_report_detects_parent_mismatch_and_orphan() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if node.id == id(2) {
                node.parent = Some(id(3));
            }
        }
        snapshot
            .nodes
            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
        snapshot.next_id = id(11);

        let report = snapshot.invariant_report();
        assert!(report.has_errors());
        assert!(
            report
                .issues
                .iter()
                .any(|issue| issue.code == PaneInvariantCode::ParentMismatch)
        );
        assert!(
            report
                .issues
                .iter()
                .any(|issue| issue.code == PaneInvariantCode::UnreachableNode)
        );
    }

    #[test]
    fn repair_safe_normalizes_ratio_repairs_parents_and_removes_orphans() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if node.id == id(1) {
                node.parent = Some(id(3));
                let PaneNodeKind::Split(split) = &mut node.kind else {
                    unreachable!("root should be split");
                };
                split.ratio = PaneSplitRatio {
                    numerator: 12,
                    denominator: 8,
                };
            }
            if node.id == id(2) {
                node.parent = Some(id(3));
            }
        }
        snapshot
            .nodes
            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
        snapshot.next_id = id(11);

        let repaired = snapshot.repair_safe().expect("repair should succeed");
        assert_ne!(repaired.before_hash, repaired.after_hash);
        assert!(repaired.tree.validate().is_ok());
        assert!(!repaired.report_after.has_errors());
        assert!(
            repaired
                .actions
                .iter()
                .any(|action| matches!(action, PaneRepairAction::NormalizeRatio { node_id, .. } if *node_id == id(1)))
        );
        assert!(
            repaired
                .actions
                .iter()
                .any(|action| matches!(action, PaneRepairAction::ReparentNode { node_id, .. } if *node_id == id(1)))
        );
        assert!(
            repaired
                .actions
                .iter()
                .any(|action| matches!(action, PaneRepairAction::RemoveOrphanNode { node_id } if *node_id == id(10)))
        );
    }

    #[test]
    fn repair_safe_rejects_unsafe_topology() {
        let mut snapshot = make_valid_snapshot();
        snapshot.nodes.retain(|node| node.id != id(3));

        let err = snapshot
            .repair_safe()
            .expect_err("missing-child topology must be rejected");
        assert!(matches!(
            err.reason,
            PaneRepairFailure::UnsafeIssuesPresent { .. }
        ));
        let PaneRepairFailure::UnsafeIssuesPresent { codes } = err.reason else {
            unreachable!("expected unsafe issue failure");
        };
        assert!(codes.contains(&PaneInvariantCode::MissingChild));
    }

    #[test]
    fn repair_safe_is_deterministic_for_equivalent_snapshot() {
        let mut snapshot = make_valid_snapshot();
        for node in &mut snapshot.nodes {
            if node.id == id(1) {
                let PaneNodeKind::Split(split) = &mut node.kind else {
                    unreachable!("root should be split");
                };
                split.ratio = PaneSplitRatio {
                    numerator: 12,
                    denominator: 8,
                };
            }
        }
        snapshot
            .nodes
            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
        snapshot.next_id = id(11);

        let first = snapshot.clone().repair_safe().expect("first repair");
        let second = snapshot.repair_safe().expect("second repair");

        assert_eq!(first.tree.state_hash(), second.tree.state_hash());
        assert_eq!(first.actions, second.actions);
        assert_eq!(first.report_after, second.report_after);
    }

    fn default_target() -> PaneResizeTarget {
        PaneResizeTarget {
            split_id: id(7),
            axis: SplitAxis::Horizontal,
        }
    }

    #[test]
    fn semantic_input_event_fixture_round_trip_covers_all_variants() {
        let mut pointer_down = PaneSemanticInputEvent::new(
            1,
            PaneSemanticInputEventKind::PointerDown {
                target: default_target(),
                pointer_id: 11,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(42, 9),
            },
        );
        pointer_down.modifiers = PaneModifierSnapshot {
            shift: true,
            alt: false,
            ctrl: true,
            meta: false,
        };
        let pointer_down_fixture = r#"{"schema_version":1,"sequence":1,"modifiers":{"shift":true,"alt":false,"ctrl":true,"meta":false},"event":"pointer_down","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":42,"y":9},"extensions":{}}"#;

        let pointer_move = PaneSemanticInputEvent::new(
            2,
            PaneSemanticInputEventKind::PointerMove {
                target: default_target(),
                pointer_id: 11,
                position: PanePointerPosition::new(45, 8),
                delta_x: 3,
                delta_y: -1,
            },
        );
        let pointer_move_fixture = r#"{"schema_version":1,"sequence":2,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":45,"y":8},"delta_x":3,"delta_y":-1,"extensions":{}}"#;

        let pointer_up = PaneSemanticInputEvent::new(
            3,
            PaneSemanticInputEventKind::PointerUp {
                target: default_target(),
                pointer_id: 11,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(45, 8),
            },
        );
        let pointer_up_fixture = r#"{"schema_version":1,"sequence":3,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_up","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":45,"y":8},"extensions":{}}"#;

        let wheel_nudge = PaneSemanticInputEvent::new(
            4,
            PaneSemanticInputEventKind::WheelNudge {
                target: default_target(),
                lines: -2,
            },
        );
        let wheel_nudge_fixture = r#"{"schema_version":1,"sequence":4,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"wheel_nudge","target":{"split_id":7,"axis":"horizontal"},"lines":-2,"extensions":{}}"#;

        let keyboard_resize = PaneSemanticInputEvent::new(
            5,
            PaneSemanticInputEventKind::KeyboardResize {
                target: default_target(),
                direction: PaneResizeDirection::Increase,
                units: 3,
            },
        );
        let keyboard_resize_fixture = r#"{"schema_version":1,"sequence":5,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"keyboard_resize","target":{"split_id":7,"axis":"horizontal"},"direction":"increase","units":3,"extensions":{}}"#;

        let cancel = PaneSemanticInputEvent::new(
            6,
            PaneSemanticInputEventKind::Cancel {
                target: Some(default_target()),
                reason: PaneCancelReason::PointerCancel,
            },
        );
        let cancel_fixture = r#"{"schema_version":1,"sequence":6,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"cancel","target":{"split_id":7,"axis":"horizontal"},"reason":"pointer_cancel","extensions":{}}"#;

        let blur =
            PaneSemanticInputEvent::new(7, PaneSemanticInputEventKind::Blur { target: None });
        let blur_fixture = r#"{"schema_version":1,"sequence":7,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;

        let fixtures = [
            ("pointer_down", pointer_down_fixture, pointer_down),
            ("pointer_move", pointer_move_fixture, pointer_move),
            ("pointer_up", pointer_up_fixture, pointer_up),
            ("wheel_nudge", wheel_nudge_fixture, wheel_nudge),
            ("keyboard_resize", keyboard_resize_fixture, keyboard_resize),
            ("cancel", cancel_fixture, cancel),
            ("blur", blur_fixture, blur),
        ];

        for (name, fixture, expected) in fixtures {
            let parsed: PaneSemanticInputEvent =
                serde_json::from_str(fixture).expect("fixture should parse");
            assert_eq!(
                parsed, expected,
                "{name} fixture should match expected shape"
            );
            parsed.validate().expect("fixture should validate");
            let encoded = serde_json::to_string(&parsed).expect("event should encode");
            assert_eq!(encoded, fixture, "{name} fixture should be canonical");
        }
    }

    #[test]
    fn semantic_input_event_defaults_schema_version_to_current() {
        let fixture = r#"{"sequence":9,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
        let parsed: PaneSemanticInputEvent =
            serde_json::from_str(fixture).expect("fixture should parse");
        assert_eq!(
            parsed.schema_version,
            PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
        );
        parsed.validate().expect("defaulted event should validate");
    }

    #[test]
    fn semantic_input_event_rejects_invalid_invariants() {
        let target = default_target();

        let mut schema_version = PaneSemanticInputEvent::new(
            1,
            PaneSemanticInputEventKind::Blur {
                target: Some(target),
            },
        );
        schema_version.schema_version = 99;
        assert_eq!(
            schema_version.validate(),
            Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
                version: 99,
                expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
            })
        );

        let sequence = PaneSemanticInputEvent::new(
            0,
            PaneSemanticInputEventKind::Blur {
                target: Some(target),
            },
        );
        assert_eq!(
            sequence.validate(),
            Err(PaneSemanticInputEventError::ZeroSequence)
        );

        let pointer = PaneSemanticInputEvent::new(
            2,
            PaneSemanticInputEventKind::PointerDown {
                target,
                pointer_id: 0,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(0, 0),
            },
        );
        assert_eq!(
            pointer.validate(),
            Err(PaneSemanticInputEventError::ZeroPointerId)
        );

        let wheel = PaneSemanticInputEvent::new(
            3,
            PaneSemanticInputEventKind::WheelNudge { target, lines: 0 },
        );
        assert_eq!(
            wheel.validate(),
            Err(PaneSemanticInputEventError::ZeroWheelLines)
        );

        let keyboard = PaneSemanticInputEvent::new(
            4,
            PaneSemanticInputEventKind::KeyboardResize {
                target,
                direction: PaneResizeDirection::Decrease,
                units: 0,
            },
        );
        assert_eq!(
            keyboard.validate(),
            Err(PaneSemanticInputEventError::ZeroResizeUnits)
        );
    }

    #[test]
    fn semantic_input_trace_fixture_round_trip_and_checksum_validation() {
        let fixture = r#"{"metadata":{"schema_version":1,"seed":7,"start_unix_ms":1700000000000,"host":"terminal","checksum":0},"events":[{"schema_version":1,"sequence":1,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_down","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":10,"y":4},"extensions":{}},{"schema_version":1,"sequence":2,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":13,"y":4},"delta_x":0,"delta_y":0,"extensions":{}},{"schema_version":1,"sequence":3,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":15,"y":6},"delta_x":0,"delta_y":0,"extensions":{}},{"schema_version":1,"sequence":4,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_up","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":16,"y":6},"extensions":{}}]}"#;

        let parsed: PaneSemanticInputTrace =
            serde_json::from_str(fixture).expect("trace fixture should parse");
        let checksum_mismatch = parsed
            .validate()
            .expect_err("fixture checksum=0 should fail validation");
        assert!(matches!(
            checksum_mismatch,
            PaneSemanticInputTraceError::ChecksumMismatch { recorded: 0, .. }
        ));

        let mut canonical = parsed;
        canonical.metadata.checksum = canonical.recompute_checksum();
        canonical
            .validate()
            .expect("canonicalized fixture should validate");
        let encoded = serde_json::to_string(&canonical).expect("trace should encode");
        let reparsed: PaneSemanticInputTrace =
            serde_json::from_str(&encoded).expect("encoded fixture should parse");
        assert_eq!(reparsed, canonical);
        assert_eq!(reparsed.metadata.checksum, reparsed.recompute_checksum());
    }

    #[test]
    fn semantic_input_trace_rejects_out_of_order_sequence() {
        let target = default_target();
        let mut trace = PaneSemanticInputTrace::new(
            42,
            1_700_000_000_111,
            "web",
            vec![
                PaneSemanticInputEvent::new(
                    1,
                    PaneSemanticInputEventKind::PointerDown {
                        target,
                        pointer_id: 9,
                        button: PanePointerButton::Primary,
                        position: PanePointerPosition::new(0, 0),
                    },
                ),
                PaneSemanticInputEvent::new(
                    2,
                    PaneSemanticInputEventKind::PointerMove {
                        target,
                        pointer_id: 9,
                        position: PanePointerPosition::new(2, 0),
                        delta_x: 0,
                        delta_y: 0,
                    },
                ),
                PaneSemanticInputEvent::new(
                    3,
                    PaneSemanticInputEventKind::PointerUp {
                        target,
                        pointer_id: 9,
                        button: PanePointerButton::Primary,
                        position: PanePointerPosition::new(2, 0),
                    },
                ),
            ],
        )
        .expect("trace should construct");

        trace.events[2].sequence = 2;
        trace.metadata.checksum = trace.recompute_checksum();
        assert_eq!(
            trace.validate(),
            Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
                index: 2,
                previous: 2,
                current: 2
            })
        );
    }

    #[test]
    fn semantic_replay_fixture_runner_produces_diff_artifacts() {
        let target = default_target();
        let trace = PaneSemanticInputTrace::new(
            99,
            1_700_000_000_222,
            "terminal",
            vec![
                PaneSemanticInputEvent::new(
                    1,
                    PaneSemanticInputEventKind::PointerDown {
                        target,
                        pointer_id: 11,
                        button: PanePointerButton::Primary,
                        position: PanePointerPosition::new(10, 4),
                    },
                ),
                PaneSemanticInputEvent::new(
                    2,
                    PaneSemanticInputEventKind::PointerMove {
                        target,
                        pointer_id: 11,
                        position: PanePointerPosition::new(13, 4),
                        delta_x: 0,
                        delta_y: 0,
                    },
                ),
                PaneSemanticInputEvent::new(
                    3,
                    PaneSemanticInputEventKind::PointerMove {
                        target,
                        pointer_id: 11,
                        position: PanePointerPosition::new(15, 6),
                        delta_x: 0,
                        delta_y: 0,
                    },
                ),
                PaneSemanticInputEvent::new(
                    4,
                    PaneSemanticInputEventKind::PointerUp {
                        target,
                        pointer_id: 11,
                        button: PanePointerButton::Primary,
                        position: PanePointerPosition::new(16, 6),
                    },
                ),
            ],
        )
        .expect("trace should construct");

        let mut baseline_machine = PaneDragResizeMachine::default();
        let baseline = trace
            .replay(&mut baseline_machine)
            .expect("baseline replay should pass");
        let fixture = PaneSemanticReplayFixture {
            trace: trace.clone(),
            expected_transitions: baseline.transitions.clone(),
            expected_final_state: baseline.final_state,
        };

        let mut pass_machine = PaneDragResizeMachine::default();
        let pass_report = fixture
            .run(&mut pass_machine)
            .expect("fixture replay should succeed");
        assert!(pass_report.passed);
        assert!(pass_report.diffs.is_empty());

        let mut mismatch_fixture = fixture.clone();
        mismatch_fixture.expected_transitions[1].transition_id += 77;
        mismatch_fixture.expected_final_state = PaneDragResizeState::Armed {
            target,
            pointer_id: 11,
            origin: PanePointerPosition::new(10, 4),
            current: PanePointerPosition::new(10, 4),
            started_sequence: 1,
        };

        let mut mismatch_machine = PaneDragResizeMachine::default();
        let mismatch_report = mismatch_fixture
            .run(&mut mismatch_machine)
            .expect("mismatch replay should still execute");
        assert!(!mismatch_report.passed);
        assert!(
            mismatch_report
                .diffs
                .iter()
                .any(|diff| diff.kind == PaneSemanticReplayDiffKind::TransitionMismatch)
        );
        assert!(
            mismatch_report
                .diffs
                .iter()
                .any(|diff| diff.kind == PaneSemanticReplayDiffKind::FinalStateMismatch)
        );
    }

    fn default_coordinate_normalizer() -> PaneCoordinateNormalizer {
        PaneCoordinateNormalizer::new(
            PanePointerPosition::new(100, 50),
            PanePointerPosition::new(20, 10),
            8,
            16,
            PaneScaleFactor::new(2, 1).expect("valid dpr"),
            PaneScaleFactor::ONE,
            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
        )
        .expect("normalizer should be valid")
    }

    #[test]
    fn coordinate_normalizer_css_device_and_cell_pipeline() {
        let normalizer = default_coordinate_normalizer();

        let css = normalizer
            .normalize(PaneInputCoordinate::CssPixels {
                position: PanePointerPosition::new(116, 82),
            })
            .expect("css normalization should succeed");
        assert_eq!(
            css,
            PaneNormalizedCoordinate {
                global_cell: PanePointerPosition::new(22, 12),
                local_cell: PanePointerPosition::new(2, 2),
                local_css: PanePointerPosition::new(16, 32),
            }
        );

        let device = normalizer
            .normalize(PaneInputCoordinate::DevicePixels {
                position: PanePointerPosition::new(232, 164),
            })
            .expect("device normalization should match css");
        assert_eq!(device, css);

        let cell = normalizer
            .normalize(PaneInputCoordinate::Cell {
                position: PanePointerPosition::new(3, 1),
            })
            .expect("cell normalization should succeed");
        assert_eq!(
            cell,
            PaneNormalizedCoordinate {
                global_cell: PanePointerPosition::new(23, 11),
                local_cell: PanePointerPosition::new(3, 1),
                local_css: PanePointerPosition::new(24, 16),
            }
        );
    }

    #[test]
    fn coordinate_normalizer_zoom_and_rounding_tie_breaks_are_deterministic() {
        let zoomed = PaneCoordinateNormalizer::new(
            PanePointerPosition::new(100, 50),
            PanePointerPosition::new(0, 0),
            8,
            8,
            PaneScaleFactor::ONE,
            PaneScaleFactor::new(5, 4).expect("valid zoom"),
            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
        )
        .expect("zoomed normalizer should be valid");

        let zoomed_point = zoomed
            .normalize(PaneInputCoordinate::CssPixels {
                position: PanePointerPosition::new(120, 70),
            })
            .expect("zoomed normalization should succeed");
        assert_eq!(zoomed_point.local_css, PanePointerPosition::new(16, 16));
        assert_eq!(zoomed_point.local_cell, PanePointerPosition::new(2, 2));

        let nearest = PaneCoordinateNormalizer::new(
            PanePointerPosition::new(0, 0),
            PanePointerPosition::new(0, 0),
            10,
            10,
            PaneScaleFactor::ONE,
            PaneScaleFactor::ONE,
            PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity,
        )
        .expect("nearest normalizer should be valid");

        let positive_tie = nearest
            .normalize(PaneInputCoordinate::CssPixels {
                position: PanePointerPosition::new(15, 0),
            })
            .expect("positive tie should normalize");
        let positive_above_tie = nearest
            .normalize(PaneInputCoordinate::CssPixels {
                position: PanePointerPosition::new(16, 0),
            })
            .expect("positive > half should normalize");
        let negative_tie = nearest
            .normalize(PaneInputCoordinate::CssPixels {
                position: PanePointerPosition::new(-15, 0),
            })
            .expect("negative tie should normalize");

        assert_eq!(positive_tie.local_cell.x, 1);
        assert_eq!(positive_above_tie.local_cell.x, 2);
        assert_eq!(negative_tie.local_cell.x, -2);
    }

    #[test]
    fn coordinate_normalizer_rejects_invalid_configuration() {
        assert_eq!(
            PaneScaleFactor::new(0, 1).expect_err("zero numerator must fail"),
            PaneCoordinateNormalizationError::InvalidScaleFactor {
                field: "scale_factor",
                numerator: 0,
                denominator: 1,
            }
        );

        let err = PaneCoordinateNormalizer::new(
            PanePointerPosition::new(0, 0),
            PanePointerPosition::new(0, 0),
            0,
            10,
            PaneScaleFactor::ONE,
            PaneScaleFactor::ONE,
            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
        )
        .expect_err("zero width must fail");
        assert_eq!(
            err,
            PaneCoordinateNormalizationError::InvalidCellSize {
                width: 0,
                height: 10,
            }
        );
    }

    #[test]
    fn coordinate_normalizer_repeated_device_updates_do_not_drift() {
        let normalizer = PaneCoordinateNormalizer::new(
            PanePointerPosition::new(0, 0),
            PanePointerPosition::new(0, 0),
            7,
            11,
            PaneScaleFactor::new(3, 2).expect("valid dpr"),
            PaneScaleFactor::new(5, 4).expect("valid zoom"),
            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
        )
        .expect("normalizer should be valid");

        let mut prev = i32::MIN;
        for x in 150..190 {
            let first = normalizer
                .normalize(PaneInputCoordinate::DevicePixels {
                    position: PanePointerPosition::new(x, 0),
                })
                .expect("first normalization should succeed");
            let second = normalizer
                .normalize(PaneInputCoordinate::DevicePixels {
                    position: PanePointerPosition::new(x, 0),
                })
                .expect("second normalization should succeed");

            assert_eq!(
                first, second,
                "normalization should be stable for same input"
            );
            assert!(
                first.global_cell.x >= prev,
                "cell coordinate should be monotonic"
            );
            if prev != i32::MIN {
                assert!(
                    first.global_cell.x - prev <= 1,
                    "cell coordinate should not jump by more than one per pixel step"
                );
            }
            prev = first.global_cell.x;
        }
    }

    #[test]
    fn snap_tuning_is_deterministic_with_tie_breaks_and_hysteresis() {
        let tuning = PaneSnapTuning::default();

        let tie = tuning.decide(3_250, None);
        assert_eq!(tie.nearest_ratio_bps, 3_000);
        assert_eq!(tie.snapped_ratio_bps, None);
        assert_eq!(tie.reason, PaneSnapReason::UnsnapOutsideWindow);

        let snap = tuning.decide(3_499, None);
        assert_eq!(snap.nearest_ratio_bps, 3_500);
        assert_eq!(snap.snapped_ratio_bps, Some(3_500));
        assert_eq!(snap.reason, PaneSnapReason::SnappedNearest);

        let retain = tuning.decide(3_390, Some(3_500));
        assert_eq!(retain.snapped_ratio_bps, Some(3_500));
        assert_eq!(retain.reason, PaneSnapReason::RetainedPrevious);

        assert_eq!(
            PaneSnapTuning::new(0, 125).expect_err("step=0 must fail"),
            PaneInteractionPolicyError::InvalidSnapTuning {
                step_bps: 0,
                hysteresis_bps: 125
            }
        );
    }

    #[test]
    fn precision_policy_applies_axis_lock_and_mode_scaling() {
        let fine = PanePrecisionPolicy::from_modifiers(
            PaneModifierSnapshot {
                shift: true,
                alt: true,
                ctrl: false,
                meta: false,
            },
            SplitAxis::Horizontal,
        );
        assert_eq!(fine.mode, PanePrecisionMode::Fine);
        assert_eq!(fine.axis_lock, Some(SplitAxis::Horizontal));
        assert_eq!(fine.apply_delta(5, 3).expect("fine delta"), (2, 0));

        let coarse = PanePrecisionPolicy::from_modifiers(
            PaneModifierSnapshot {
                shift: false,
                alt: false,
                ctrl: true,
                meta: false,
            },
            SplitAxis::Vertical,
        );
        assert_eq!(coarse.mode, PanePrecisionMode::Coarse);
        assert_eq!(coarse.axis_lock, None);
        assert_eq!(coarse.apply_delta(2, -3).expect("coarse delta"), (4, -6));
    }

    #[test]
    fn drag_behavior_tuning_validates_and_threshold_helpers_are_stable() {
        let tuning = PaneDragBehaviorTuning::new(3, 2, PaneSnapTuning::default())
            .expect("valid tuning should construct");
        assert!(tuning.should_start_drag(
            PanePointerPosition::new(0, 0),
            PanePointerPosition::new(3, 0)
        ));
        assert!(!tuning.should_start_drag(
            PanePointerPosition::new(0, 0),
            PanePointerPosition::new(2, 0)
        ));
        assert!(tuning.should_emit_drag_update(
            PanePointerPosition::new(10, 10),
            PanePointerPosition::new(12, 10)
        ));
        assert!(!tuning.should_emit_drag_update(
            PanePointerPosition::new(10, 10),
            PanePointerPosition::new(11, 10)
        ));

        assert_eq!(
            PaneDragBehaviorTuning::new(0, 2, PaneSnapTuning::default())
                .expect_err("activation threshold=0 must fail"),
            PaneInteractionPolicyError::InvalidThreshold {
                field: "activation_threshold",
                value: 0
            }
        );
        assert_eq!(
            PaneDragBehaviorTuning::new(2, 0, PaneSnapTuning::default())
                .expect_err("hysteresis=0 must fail"),
            PaneInteractionPolicyError::InvalidThreshold {
                field: "update_hysteresis",
                value: 0
            }
        );
    }

    fn pointer_down_event(
        sequence: u64,
        target: PaneResizeTarget,
        pointer_id: u32,
        x: i32,
        y: i32,
    ) -> PaneSemanticInputEvent {
        PaneSemanticInputEvent::new(
            sequence,
            PaneSemanticInputEventKind::PointerDown {
                target,
                pointer_id,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(x, y),
            },
        )
    }

    fn pointer_move_event(
        sequence: u64,
        target: PaneResizeTarget,
        pointer_id: u32,
        x: i32,
        y: i32,
    ) -> PaneSemanticInputEvent {
        PaneSemanticInputEvent::new(
            sequence,
            PaneSemanticInputEventKind::PointerMove {
                target,
                pointer_id,
                position: PanePointerPosition::new(x, y),
                delta_x: 0,
                delta_y: 0,
            },
        )
    }

    fn pointer_up_event(
        sequence: u64,
        target: PaneResizeTarget,
        pointer_id: u32,
        x: i32,
        y: i32,
    ) -> PaneSemanticInputEvent {
        PaneSemanticInputEvent::new(
            sequence,
            PaneSemanticInputEventKind::PointerUp {
                target,
                pointer_id,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(x, y),
            },
        )
    }

    #[test]
    fn drag_resize_machine_full_lifecycle_commit() {
        let mut machine = PaneDragResizeMachine::default();
        let target = default_target();

        let down = machine
            .apply_event(&pointer_down_event(1, target, 10, 10, 4))
            .expect("down should arm");
        assert_eq!(down.transition_id, 1);
        assert_eq!(down.sequence, 1);
        assert_eq!(machine.state(), down.to);
        assert!(matches!(
            down.effect,
            PaneDragResizeEffect::Armed {
                target: t,
                pointer_id: 10,
                origin: PanePointerPosition { x: 10, y: 4 }
            } if t == target
        ));

        let below_threshold = machine
            .apply_event(&pointer_move_event(2, target, 10, 11, 4))
            .expect("small move should not start drag");
        assert_eq!(
            below_threshold.effect,
            PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::ThresholdNotReached
            }
        );
        assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));

        let drag_start = machine
            .apply_event(&pointer_move_event(3, target, 10, 13, 4))
            .expect("large move should start drag");
        assert!(matches!(
            drag_start.effect,
            PaneDragResizeEffect::DragStarted {
                target: t,
                pointer_id: 10,
                total_delta_x: 3,
                total_delta_y: 0,
                ..
            } if t == target
        ));
        assert!(matches!(
            machine.state(),
            PaneDragResizeState::Dragging { .. }
        ));

        let drag_update = machine
            .apply_event(&pointer_move_event(4, target, 10, 15, 6))
            .expect("drag move should update");
        assert!(matches!(
            drag_update.effect,
            PaneDragResizeEffect::DragUpdated {
                target: t,
                pointer_id: 10,
                delta_x: 2,
                delta_y: 2,
                total_delta_x: 5,
                total_delta_y: 2,
                ..
            } if t == target
        ));

        let commit = machine
            .apply_event(&pointer_up_event(5, target, 10, 16, 6))
            .expect("up should commit drag");
        assert!(matches!(
            commit.effect,
            PaneDragResizeEffect::Committed {
                target: t,
                pointer_id: 10,
                total_delta_x: 6,
                total_delta_y: 2,
                ..
            } if t == target
        ));
        assert_eq!(machine.state(), PaneDragResizeState::Idle);
    }

    #[test]
    fn drag_resize_machine_cancel_and_blur_paths_are_reason_coded() {
        let target = default_target();

        let mut cancel_machine = PaneDragResizeMachine::default();
        cancel_machine
            .apply_event(&pointer_down_event(1, target, 1, 2, 2))
            .expect("down should arm");
        let cancel = cancel_machine
            .apply_event(&PaneSemanticInputEvent::new(
                2,
                PaneSemanticInputEventKind::Cancel {
                    target: Some(target),
                    reason: PaneCancelReason::FocusLost,
                },
            ))
            .expect("cancel should reset to idle");
        assert_eq!(cancel_machine.state(), PaneDragResizeState::Idle);
        assert_eq!(
            cancel.effect,
            PaneDragResizeEffect::Canceled {
                target: Some(target),
                pointer_id: Some(1),
                reason: PaneCancelReason::FocusLost
            }
        );

        let mut blur_machine = PaneDragResizeMachine::default();
        blur_machine
            .apply_event(&pointer_down_event(3, target, 2, 5, 5))
            .expect("down should arm");
        blur_machine
            .apply_event(&pointer_move_event(4, target, 2, 8, 5))
            .expect("move should start dragging");
        let blur = blur_machine
            .apply_event(&PaneSemanticInputEvent::new(
                5,
                PaneSemanticInputEventKind::Blur {
                    target: Some(target),
                },
            ))
            .expect("blur should cancel active drag");
        assert_eq!(blur_machine.state(), PaneDragResizeState::Idle);
        assert_eq!(
            blur.effect,
            PaneDragResizeEffect::Canceled {
                target: Some(target),
                pointer_id: Some(2),
                reason: PaneCancelReason::Blur
            }
        );
    }

    #[test]
    fn drag_resize_machine_duplicate_end_and_pointer_mismatch_are_safe_noops() {
        let mut machine = PaneDragResizeMachine::default();
        let target = default_target();

        machine
            .apply_event(&pointer_down_event(1, target, 9, 0, 0))
            .expect("down should arm");

        let mismatch = machine
            .apply_event(&pointer_move_event(2, target, 99, 3, 0))
            .expect("mismatch should be ignored");
        assert_eq!(
            mismatch.effect,
            PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::PointerMismatch
            }
        );
        assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));

        machine
            .apply_event(&pointer_move_event(3, target, 9, 3, 0))
            .expect("drag should start");
        machine
            .apply_event(&pointer_up_event(4, target, 9, 3, 0))
            .expect("up should commit");
        assert_eq!(machine.state(), PaneDragResizeState::Idle);

        let duplicate_end = machine
            .apply_event(&pointer_up_event(5, target, 9, 3, 0))
            .expect("duplicate end should noop");
        assert_eq!(
            duplicate_end.effect,
            PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag
            }
        );
    }

    #[test]
    fn drag_resize_machine_discrete_inputs_in_idle_and_validation_errors() {
        let mut machine = PaneDragResizeMachine::default();
        let target = default_target();

        let keyboard = machine
            .apply_event(&PaneSemanticInputEvent::new(
                1,
                PaneSemanticInputEventKind::KeyboardResize {
                    target,
                    direction: PaneResizeDirection::Increase,
                    units: 2,
                },
            ))
            .expect("keyboard resize should apply in idle");
        assert_eq!(
            keyboard.effect,
            PaneDragResizeEffect::KeyboardApplied {
                target,
                direction: PaneResizeDirection::Increase,
                units: 2
            }
        );
        assert_eq!(machine.state(), PaneDragResizeState::Idle);

        let wheel = machine
            .apply_event(&PaneSemanticInputEvent::new(
                2,
                PaneSemanticInputEventKind::WheelNudge { target, lines: -1 },
            ))
            .expect("wheel nudge should apply in idle");
        assert_eq!(
            wheel.effect,
            PaneDragResizeEffect::WheelApplied { target, lines: -1 }
        );

        let invalid_pointer = PaneSemanticInputEvent::new(
            3,
            PaneSemanticInputEventKind::PointerDown {
                target,
                pointer_id: 0,
                button: PanePointerButton::Primary,
                position: PanePointerPosition::new(0, 0),
            },
        );
        let err = machine
            .apply_event(&invalid_pointer)
            .expect_err("invalid input should be rejected");
        assert_eq!(
            err,
            PaneDragResizeMachineError::InvalidEvent(PaneSemanticInputEventError::ZeroPointerId)
        );

        assert_eq!(
            PaneDragResizeMachine::new(0).expect_err("zero threshold should fail"),
            PaneDragResizeMachineError::InvalidDragThreshold { threshold: 0 }
        );
    }

    #[test]
    fn drag_resize_machine_hysteresis_suppresses_micro_jitter() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::new_with_hysteresis(2, 2)
            .expect("explicit machine tuning should construct");
        machine
            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
            .expect("down should arm");
        machine
            .apply_event(&pointer_move_event(2, target, 22, 2, 0))
            .expect("move should start dragging");

        let jitter = machine
            .apply_event(&pointer_move_event(3, target, 22, 3, 0))
            .expect("small move should be ignored");
        assert_eq!(
            jitter.effect,
            PaneDragResizeEffect::Noop {
                reason: PaneDragResizeNoopReason::BelowHysteresis
            }
        );

        let update = machine
            .apply_event(&pointer_move_event(4, target, 22, 4, 0))
            .expect("larger move should update drag");
        assert!(matches!(
            update.effect,
            PaneDragResizeEffect::DragUpdated { .. }
        ));
        assert_eq!(
            PaneDragResizeMachine::new_with_hysteresis(2, 0)
                .expect_err("zero hysteresis must fail"),
            PaneDragResizeMachineError::InvalidUpdateHysteresis { hysteresis: 0 }
        );
    }

    // -----------------------------------------------------------------------
    // force_cancel lifecycle robustness (bd-24v9m)
    // -----------------------------------------------------------------------

    #[test]
    fn force_cancel_idle_is_noop() {
        let mut machine = PaneDragResizeMachine::default();
        assert!(!machine.is_active());
        assert!(machine.force_cancel().is_none());
        assert_eq!(machine.state(), PaneDragResizeState::Idle);
    }

    #[test]
    fn force_cancel_from_armed_resets_to_idle() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();
        machine
            .apply_event(&pointer_down_event(1, target, 22, 5, 5))
            .expect("down should arm");
        assert!(machine.is_active());

        let transition = machine
            .force_cancel()
            .expect("armed machine should produce transition");
        assert_eq!(transition.to, PaneDragResizeState::Idle);
        assert!(matches!(
            transition.effect,
            PaneDragResizeEffect::Canceled {
                reason: PaneCancelReason::Programmatic,
                ..
            }
        ));
        assert!(!machine.is_active());
        assert_eq!(machine.state(), PaneDragResizeState::Idle);
    }

    #[test]
    fn force_cancel_from_dragging_resets_to_idle() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();
        machine
            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
            .expect("down");
        machine
            .apply_event(&pointer_move_event(2, target, 22, 5, 0))
            .expect("move past threshold to start drag");
        assert!(matches!(
            machine.state(),
            PaneDragResizeState::Dragging { .. }
        ));
        assert!(machine.is_active());

        let transition = machine
            .force_cancel()
            .expect("dragging machine should produce transition");
        assert_eq!(transition.to, PaneDragResizeState::Idle);
        assert!(matches!(
            transition.effect,
            PaneDragResizeEffect::Canceled {
                target: Some(_),
                pointer_id: Some(22),
                reason: PaneCancelReason::Programmatic,
            }
        ));
        assert!(!machine.is_active());
    }

    #[test]
    fn force_cancel_is_idempotent() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();
        machine
            .apply_event(&pointer_down_event(1, target, 22, 5, 5))
            .expect("down should arm");

        let first = machine.force_cancel();
        assert!(first.is_some());
        let second = machine.force_cancel();
        assert!(second.is_none());
        assert_eq!(machine.state(), PaneDragResizeState::Idle);
    }

    #[test]
    fn force_cancel_preserves_transition_counter_monotonicity() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();

        let t1 = machine
            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
            .expect("arm");
        let t2 = machine.force_cancel().expect("force cancel from armed");
        assert!(t2.transition_id > t1.transition_id);

        // Re-arm and force cancel again to confirm counter keeps incrementing
        let t3 = machine
            .apply_event(&pointer_down_event(2, target, 22, 10, 10))
            .expect("re-arm");
        let t4 = machine.force_cancel().expect("second force cancel");
        assert!(t3.transition_id > t2.transition_id);
        assert!(t4.transition_id > t3.transition_id);
    }

    #[test]
    fn force_cancel_records_prior_state_in_from_field() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();
        machine
            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
            .expect("arm");

        let armed_state = machine.state();
        let transition = machine.force_cancel().expect("force cancel");
        assert_eq!(transition.from, armed_state);
    }

    #[test]
    fn machine_usable_after_force_cancel() {
        let target = default_target();
        let mut machine = PaneDragResizeMachine::default();

        // Full lifecycle: arm → force cancel → arm again → normal commit
        machine
            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
            .expect("arm");
        machine.force_cancel();

        machine
            .apply_event(&pointer_down_event(2, target, 22, 10, 10))
            .expect("re-arm after force cancel");
        machine
            .apply_event(&pointer_move_event(3, target, 22, 15, 10))
            .expect("move to drag");
        let commit = machine
            .apply_event(&pointer_up_event(4, target, 22, 15, 10))
            .expect("commit");
        assert!(matches!(
            commit.effect,
            PaneDragResizeEffect::Committed { .. }
        ));
        assert_eq!(machine.state(), PaneDragResizeState::Idle);
    }

    proptest! {
        #[test]
        fn ratio_is_always_reduced(numerator in 1u32..100_000, denominator in 1u32..100_000) {
            let ratio = PaneSplitRatio::new(numerator, denominator).expect("positive ratio must be valid");
            let gcd = gcd_u32(ratio.numerator(), ratio.denominator());
            prop_assert_eq!(gcd, 1);
        }

        #[test]
        fn allocator_produces_monotonic_ids(
            start in 1u64..1_000_000,
            count in 1usize..64,
        ) {
            let mut allocator = PaneIdAllocator::with_next(PaneId::new(start).expect("start must be valid"));
            let mut prev = 0u64;
            for _ in 0..count {
                let current = allocator.allocate().expect("allocation must succeed").get();
                prop_assert!(current > prev);
                prev = current;
            }
        }

        #[test]
        fn split_solver_preserves_available_space(
            numerator in 1u32..64,
            denominator in 1u32..64,
            first_min in 0u16..40,
            second_min in 0u16..40,
            available in 0u16..80,
        ) {
            let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
            prop_assume!(first_min.saturating_add(second_min) <= available);

            let (first_size, second_size) = solve_split_sizes(
                id(1),
                SplitAxis::Horizontal,
                available,
                ratio,
                AxisBounds { min: first_min, max: None },
                AxisBounds { min: second_min, max: None },
            ).expect("feasible split should solve");

            prop_assert_eq!(first_size.saturating_add(second_size), available);
            prop_assert!(first_size >= first_min);
            prop_assert!(second_size >= second_min);
        }

        #[test]
        fn split_then_close_round_trip_preserves_validity(
            numerator in 1u32..32,
            denominator in 1u32..32,
            incoming_first in any::<bool>(),
        ) {
            let mut tree = PaneTree::singleton("root");
            let placement = if incoming_first {
                PanePlacement::IncomingFirst
            } else {
                PanePlacement::ExistingFirst
            };
            let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");

            tree.apply_operation(
                1,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio,
                    placement,
                    new_leaf: PaneLeaf::new("extra"),
                },
            ).expect("split should succeed");

            let split_root_id = tree.root();
            let split_root = tree.node(split_root_id).expect("split root exists");
            let PaneNodeKind::Split(split) = &split_root.kind else {
                unreachable!("root should be split");
            };
            let extra_leaf_id = if split.first == id(1) {
                split.second
            } else {
                split.first
            };

            tree.apply_operation(2, PaneOperation::CloseNode { target: extra_leaf_id })
                .expect("close should succeed");

            prop_assert_eq!(tree.root(), id(1));
            prop_assert!(matches!(
                tree.node(id(1)).map(|node| &node.kind),
                Some(PaneNodeKind::Leaf(_))
            ));
            prop_assert!(tree.validate().is_ok());
        }

        #[test]
        fn transaction_rollback_restores_initial_state_hash(
            numerator in 1u32..64,
            denominator in 1u32..64,
            incoming_first in any::<bool>(),
        ) {
            let base = PaneTree::singleton("root");
            let initial_hash = base.state_hash();
            let mut tx = base.begin_transaction(90);
            let placement = if incoming_first {
                PanePlacement::IncomingFirst
            } else {
                PanePlacement::ExistingFirst
            };

            tx.apply_operation(
                1,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(numerator, denominator).expect("valid ratio"),
                    placement,
                    new_leaf: PaneLeaf::new("new"),
                },
            ).expect("split should succeed");

            let rolled_back = tx.rollback();
            prop_assert_eq!(rolled_back.tree.state_hash(), initial_hash);
            prop_assert_eq!(rolled_back.tree.root(), id(1));
            prop_assert!(rolled_back.tree.validate().is_ok());
        }

        #[test]
        fn repair_safe_is_deterministic_under_recoverable_damage(
            numerator in 1u32..32,
            denominator in 1u32..32,
            add_orphan in any::<bool>(),
            mismatch_parent in any::<bool>(),
        ) {
            let mut snapshot = make_valid_snapshot();
            for node in &mut snapshot.nodes {
                if node.id == id(1) {
                    let PaneNodeKind::Split(split) = &mut node.kind else {
                        unreachable!("root should be split");
                    };
                    split.ratio = PaneSplitRatio {
                        numerator: numerator.saturating_mul(2),
                        denominator: denominator.saturating_mul(2),
                    };
                }
                if mismatch_parent && node.id == id(2) {
                    node.parent = Some(id(3));
                }
            }
            if add_orphan {
                snapshot
                    .nodes
                    .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
                snapshot.next_id = id(11);
            }

            let first = snapshot.clone().repair_safe().expect("first repair should succeed");
            let second = snapshot.repair_safe().expect("second repair should succeed");

            prop_assert_eq!(first.tree.state_hash(), second.tree.state_hash());
            prop_assert_eq!(first.actions, second.actions);
            prop_assert_eq!(first.report_after, second.report_after);
        }
    }

    #[test]
    fn set_split_ratio_operation_updates_existing_split() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        tree.apply_operation(
            900,
            PaneOperation::SetSplitRatio {
                split: id(1),
                ratio: PaneSplitRatio::new(5, 3).expect("valid ratio"),
            },
        )
        .expect("set split ratio should succeed");

        let root = tree.node(id(1)).expect("root exists");
        let PaneNodeKind::Split(split) = &root.kind else {
            unreachable!("root should be split");
        };
        assert_eq!(split.ratio.numerator(), 5);
        assert_eq!(split.ratio.denominator(), 3);
    }

    #[test]
    fn layout_classifies_any_edge_grips_and_edge_resize_plans_apply() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 120, 48))
            .expect("layout should solve");
        let left_rect = layout.rect(id(2)).expect("leaf 2 rect");
        let pointer = PanePointerPosition::new(
            i32::from(
                left_rect
                    .x
                    .saturating_add(left_rect.width.saturating_sub(1)),
            ),
            i32::from(left_rect.y.saturating_add(left_rect.height / 2)),
        );
        let grip = layout
            .classify_resize_grip(id(2), pointer, PANE_EDGE_GRIP_INSET_CELLS)
            .expect("grip should classify");
        assert!(matches!(
            grip,
            PaneResizeGrip::Right | PaneResizeGrip::TopRight | PaneResizeGrip::BottomRight
        ));

        let plan = tree
            .plan_edge_resize(
                id(2),
                &layout,
                grip,
                pointer,
                PanePressureSnapProfile {
                    strength_bps: 8_000,
                    hysteresis_bps: 250,
                },
            )
            .expect("edge resize plan should build");
        assert!(!plan.operations.is_empty());
        tree.apply_edge_resize_plan(901, &plan)
            .expect("edge resize plan should apply");
        assert!(tree.validate().is_ok());
    }

    #[test]
    fn pane_layout_visual_rect_applies_default_margin_and_padding() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 120, 48))
            .expect("layout should solve");
        let raw = layout.rect(id(2)).expect("leaf rect exists");
        let visual = layout.visual_rect(id(2)).expect("visual rect exists");
        assert!(visual.width <= raw.width);
        assert!(visual.height <= raw.height);
        assert!(visual.width > 0);
        assert!(visual.height > 0);
    }

    #[test]
    fn magnetic_docking_preview_and_reflow_plan_are_generated() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 100, 40))
            .expect("layout should solve");
        let right_rect = layout.rect(id(3)).expect("leaf 3 rect");
        let pointer = PanePointerPosition::new(
            i32::from(right_rect.x),
            i32::from(right_rect.y.saturating_add(right_rect.height / 2)),
        );
        let preview = tree
            .choose_dock_preview(&layout, pointer, PANE_MAGNETIC_FIELD_CELLS)
            .expect("magnetic preview should exist");
        assert!(preview.score > 0.0);

        let plan = tree
            .plan_reflow_move_with_preview(
                id(2),
                &layout,
                pointer,
                PaneMotionVector::from_delta(24, 0, 48, 0),
                Some(PaneInertialThrow::from_motion(
                    PaneMotionVector::from_delta(24, 0, 48, 0),
                )),
                PANE_MAGNETIC_FIELD_CELLS,
            )
            .expect("reflow plan should build");
        assert!(!plan.operations.is_empty());
    }

    #[test]
    fn group_move_and_group_resize_plan_generation() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 100, 40))
            .expect("layout should solve");
        let mut selection = PaneSelectionState::default();
        selection.shift_toggle(id(2));
        assert_eq!(selection.selected.len(), 1);

        let move_plan = tree
            .plan_group_move(
                &selection,
                &layout,
                PanePointerPosition::new(80, 4),
                PaneMotionVector::from_delta(30, 2, 64, 1),
                None,
                PANE_MAGNETIC_FIELD_CELLS,
            )
            .expect("group move plan should build");
        assert!(!move_plan.operations.is_empty());

        let resize_plan = tree
            .plan_group_resize(
                &selection,
                &layout,
                PaneResizeGrip::Right,
                PanePointerPosition::new(70, 20),
                PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(40, 1, 32, 0)),
            )
            .expect("group resize plan should build");
        assert!(!resize_plan.operations.is_empty());
    }

    #[test]
    fn classify_resize_grip_handles_small_panes() {
        // 1x1 pane at (10, 10)
        let rect = Rect::new(10, 10, 1, 1);
        let pointer = PanePointerPosition::new(10, 10);
        let grip = classify_resize_grip(rect, pointer, 1.5).expect("should classify");
        // Tie-break prefers BottomRight (Right/Bottom)
        assert_eq!(grip, PaneResizeGrip::BottomRight);

        // 2x1 pane at (10, 10)
        let rect2 = Rect::new(10, 10, 2, 1);
        // Left pixel (10, 10)
        let ptr_left = PanePointerPosition::new(10, 10);
        let grip_left = classify_resize_grip(rect2, ptr_left, 1.5).expect("left pixel");
        assert_eq!(grip_left, PaneResizeGrip::BottomLeft);

        // Right pixel (11, 10)
        let ptr_right = PanePointerPosition::new(11, 10);
        let grip_right = classify_resize_grip(rect2, ptr_right, 1.5).expect("right pixel");
        assert_eq!(grip_right, PaneResizeGrip::BottomRight);
    }

    #[test]
    fn pressure_sensitive_snap_prefers_fast_straight_drags() {
        let slow = PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(4, 1, 300, 3));
        let fast = PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(40, 2, 48, 0));
        assert!(fast.strength_bps > slow.strength_bps);
        assert!(fast.hysteresis_bps >= slow.hysteresis_bps);
    }

    #[test]
    fn pressure_sensitive_snap_penalizes_direction_noise() {
        let stable =
            PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(32, 2, 60, 0));
        let noisy =
            PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(32, 2, 60, 7));
        assert!(stable.strength_bps > noisy.strength_bps);
    }

    #[test]
    fn dock_zone_motion_intent_prefers_directionally_aligned_zones() {
        let rightward = PaneMotionVector::from_delta(36, 2, 50, 0);
        let left_bias = dock_zone_motion_intent(PaneDockZone::Left, rightward);
        let right_bias = dock_zone_motion_intent(PaneDockZone::Right, rightward);
        assert!(right_bias > left_bias);

        let downward = PaneMotionVector::from_delta(2, 32, 52, 0);
        let top_bias = dock_zone_motion_intent(PaneDockZone::Top, downward);
        let bottom_bias = dock_zone_motion_intent(PaneDockZone::Bottom, downward);
        assert!(bottom_bias > top_bias);
    }

    #[test]
    fn dock_zone_motion_intent_noise_reduces_alignment_confidence() {
        let stable = dock_zone_motion_intent(
            PaneDockZone::Right,
            PaneMotionVector::from_delta(40, 1, 45, 0),
        );
        let noisy = dock_zone_motion_intent(
            PaneDockZone::Right,
            PaneMotionVector::from_delta(40, 1, 45, 8),
        );
        assert!(stable > noisy);
    }

    #[test]
    fn elastic_ratio_bps_resists_extreme_edges_more_at_low_confidence() {
        let near_edge = 350;
        let low_confidence = elastic_ratio_bps(
            near_edge,
            PanePressureSnapProfile {
                strength_bps: 1_800,
                hysteresis_bps: 120,
            },
        );
        let high_confidence = elastic_ratio_bps(
            near_edge,
            PanePressureSnapProfile {
                strength_bps: 8_600,
                hysteresis_bps: 520,
            },
        );
        assert!(low_confidence > near_edge);
        assert!(high_confidence <= low_confidence);
    }

    #[test]
    fn ranked_dock_previews_with_motion_returns_descending_scores() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 100, 40))
            .expect("layout should solve");
        let right_rect = layout.rect(id(3)).expect("leaf 3 rect");
        let pointer = PanePointerPosition::new(
            i32::from(right_rect.x),
            i32::from(right_rect.y.saturating_add(right_rect.height / 2)),
        );
        let ranked = tree.ranked_dock_previews_with_motion(
            &layout,
            pointer,
            PaneMotionVector::from_delta(28, 2, 48, 0),
            PANE_MAGNETIC_FIELD_CELLS,
            Some(id(2)),
            3,
        );
        assert!(!ranked.is_empty());
        for pair in ranked.windows(2) {
            assert!(pair[0].score >= pair[1].score);
        }
    }

    #[test]
    fn inertial_throw_projects_farther_for_faster_motion() {
        let start = PanePointerPosition::new(40, 12);
        let slow = PaneInertialThrow::from_motion(PaneMotionVector::from_delta(6, 0, 220, 1))
            .projected_pointer(start);
        let fast = PaneInertialThrow::from_motion(PaneMotionVector::from_delta(42, 0, 40, 0))
            .projected_pointer(start);
        assert!(fast.x > slow.x);
    }

    #[test]
    fn affordance_hover_emphasis_ramps_monotonically_to_full() {
        let motion = PaneAffordanceMotion::default();
        let mut prev = 0u16;
        for frame in 0..=motion.fade_in_frames {
            let bps = motion.hover_emphasis_bps(frame);
            assert!(bps >= prev, "ramp must be monotonic non-decreasing");
            assert!(bps <= PANE_AFFORDANCE_EMPHASIS_FULL_BPS);
            prev = bps;
        }
        // Reaches full at the end and saturates beyond it.
        assert_eq!(
            motion.hover_emphasis_bps(motion.fade_in_frames),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
        assert_eq!(
            motion.hover_emphasis_bps(motion.fade_in_frames + 50),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
        // Ease-out: more progress is made in the first half than the second.
        let half = motion.fade_in_frames / 2;
        let first_half_gain = motion.hover_emphasis_bps(half);
        let second_half_gain = PANE_AFFORDANCE_EMPHASIS_FULL_BPS - first_half_gain;
        assert!(
            first_half_gain >= second_half_gain,
            "ease-out should front-load the ramp ({first_half_gain} vs {second_half_gain})"
        );
    }

    #[test]
    fn affordance_reduced_motion_steps_instantly_without_losing_the_cue() {
        let reduced = PaneAffordanceMotion::reduced();
        assert!(reduced.reduced_motion);
        // Hover: full emphasis on the very first frame — the state change is
        // still expressed, just without an in-between ramp.
        assert_eq!(
            reduced.hover_emphasis_bps(0),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
        // Active pulse: steady full emphasis at every phase (no oscillation).
        for phase in [0u64, 1, 12, 24, 47, 1_000_000] {
            assert_eq!(
                reduced.active_pulse_bps(phase),
                PANE_AFFORDANCE_EMPHASIS_FULL_BPS
            );
        }
        // with_reduced_motion derives the same stepped behavior from a flag.
        let derived = PaneAffordanceMotion::default().with_reduced_motion(true);
        assert_eq!(derived.hover_emphasis_bps(0), reduced.hover_emphasis_bps(0));
    }

    #[test]
    fn affordance_active_pulse_oscillates_within_bounds_and_is_periodic() {
        let motion = PaneAffordanceMotion::default();
        let period = u64::from(motion.pulse_period_frames);
        let mut min = u16::MAX;
        let mut max = 0u16;
        for phase in 0..period {
            let bps = motion.active_pulse_bps(phase);
            assert!(bps >= motion.pulse_floor_bps, "never below the floor");
            assert!(bps <= PANE_AFFORDANCE_EMPHASIS_FULL_BPS, "never above full");
            min = min.min(bps);
            max = max.max(bps);
            // Periodicity: phase and phase+period agree.
            assert_eq!(bps, motion.active_pulse_bps(phase + period));
            assert_eq!(bps, motion.active_pulse_bps(phase + 5 * period));
        }
        assert_eq!(min, motion.pulse_floor_bps, "trough sits at the floor");
        assert_eq!(max, PANE_AFFORDANCE_EMPHASIS_FULL_BPS, "peak reaches full");
        // Peak is at the midpoint of the period.
        assert_eq!(
            motion.active_pulse_bps(u64::from(motion.pulse_period_frames / 2)),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
    }

    #[test]
    fn affordance_motion_is_deterministic_and_clock_free() {
        let motion = PaneAffordanceMotion::default();
        // Two independent evaluations of the same phase agree exactly — the
        // policy reads only the supplied tick, never a wall clock.
        for frame in 0..200u64 {
            assert_eq!(
                motion.active_pulse_bps(frame),
                motion.active_pulse_bps(frame)
            );
        }
        for frame in 0..50u16 {
            assert_eq!(
                motion.hover_emphasis_bps(frame),
                motion.hover_emphasis_bps(frame)
            );
        }
    }

    #[test]
    fn affordance_zero_timing_fields_degrade_to_full() {
        let instant = PaneAffordanceMotion {
            fade_in_frames: 0,
            pulse_period_frames: 0,
            ..PaneAffordanceMotion::default()
        };
        assert_eq!(
            instant.hover_emphasis_bps(0),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
        assert_eq!(
            instant.active_pulse_bps(7),
            PANE_AFFORDANCE_EMPHASIS_FULL_BPS
        );
    }

    #[test]
    fn intelligence_mode_compact_emits_ratio_normalization_ops() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let ops = tree
            .plan_intelligence_mode(PaneLayoutIntelligenceMode::Compact, id(2))
            .expect("compact mode should plan");
        assert!(
            ops.iter()
                .any(|op| matches!(op, PaneOperation::NormalizeRatios))
        );
        assert!(
            ops.iter()
                .any(|op| matches!(op, PaneOperation::SetSplitRatio { .. }))
        );
    }

    #[test]
    fn interaction_timeline_supports_undo_redo_and_replay() {
        let mut tree = PaneTree::singleton("root");
        let mut timeline = PaneInteractionTimeline::default();

        timeline
            .apply_and_record(
                &mut tree,
                1,
                1000,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                    new_leaf: PaneLeaf::new("aux"),
                },
            )
            .expect("split should apply");
        let split_hash = tree.state_hash();
        assert_eq!(timeline.applied_len(), 1);

        let undone = timeline.undo(&mut tree).expect("undo should succeed");
        assert!(undone);
        assert_eq!(tree.root(), id(1));

        let redone = timeline.redo(&mut tree).expect("redo should succeed");
        assert!(redone);
        assert_eq!(tree.state_hash(), split_hash);

        let replayed = timeline.replay().expect("replay should succeed");
        assert_eq!(replayed.state_hash(), tree.state_hash());
    }

    #[test]
    fn interaction_timeline_replay_matches_recorded_ratio_updates() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let split_ids: Vec<_> = tree
            .nodes()
            .filter_map(|node| match node.kind {
                PaneNodeKind::Split(_) => Some(node.id),
                PaneNodeKind::Leaf(_) => None,
            })
            .collect();
        assert!(!split_ids.is_empty());
        let ratios = [
            PaneSplitRatio::new(3, 2).expect("valid ratio"),
            PaneSplitRatio::new(2, 3).expect("valid ratio"),
            PaneSplitRatio::new(5, 4).expect("valid ratio"),
            PaneSplitRatio::new(4, 5).expect("valid ratio"),
        ];

        for idx in 0..16u64 {
            timeline
                .apply_and_record(
                    &mut tree,
                    idx,
                    20_000 + idx,
                    PaneOperation::SetSplitRatio {
                        split: split_ids[idx as usize % split_ids.len()],
                        ratio: ratios[idx as usize % ratios.len()],
                    },
                )
                .expect("ratio update should apply");
        }

        let replayed = timeline.replay().expect("replay should succeed");
        assert_eq!(replayed.state_hash(), tree.state_hash());
        assert_eq!(replayed.to_snapshot(), tree.to_snapshot());
    }

    #[test]
    fn interaction_timeline_coalesces_resize_deltas_for_same_split() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let initial_hash = tree.state_hash();
        let split = id(1);
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let ratios = [
            PaneSplitRatio::new(4, 6).expect("valid ratio"),
            PaneSplitRatio::new(7, 3).expect("valid ratio"),
            PaneSplitRatio::new(2, 5).expect("valid ratio"),
        ];

        for (index, ratio) in ratios.into_iter().enumerate() {
            timeline
                .apply_and_record_coalesced_resize_delta(
                    &mut tree,
                    index as u64 + 1,
                    100 + index as u64,
                    PaneOperation::SetSplitRatio { split, ratio },
                    0,
                )
                .expect("ratio update should apply");
        }

        assert_eq!(timeline.entries.len(), 1);
        assert_eq!(timeline.cursor, 1);
        assert_eq!(timeline.entries[0].operation_id, 102);
        assert_eq!(timeline.entries[0].before_hash, initial_hash);
        assert_eq!(split_ratio(&tree, split), ratios[2]);
        assert_eq!(timeline.next_operation_id(), 103);

        let replayed = timeline.replay().expect("replay should succeed");
        assert_eq!(replayed.to_snapshot(), tree.to_snapshot());

        assert!(timeline.undo(&mut tree).expect("undo should succeed"));
        assert_eq!(tree.state_hash(), initial_hash);
        assert!(timeline.redo(&mut tree).expect("redo should succeed"));
        assert_eq!(split_ratio(&tree, split), ratios[2]);
    }

    #[test]
    fn interaction_timeline_keeps_separate_resize_gestures_distinct() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let split = id(1);
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let first_ratio = PaneSplitRatio::new(4, 6).expect("valid ratio");
        let second_ratio = PaneSplitRatio::new(7, 3).expect("valid ratio");

        timeline
            .apply_and_record_coalesced_resize_delta(
                &mut tree,
                1,
                101,
                PaneOperation::SetSplitRatio {
                    split,
                    ratio: first_ratio,
                },
                100,
            )
            .expect("first gesture should apply");
        timeline
            .apply_and_record_coalesced_resize_delta(
                &mut tree,
                2,
                102,
                PaneOperation::SetSplitRatio {
                    split,
                    ratio: second_ratio,
                },
                101,
            )
            .expect("second gesture should apply");

        assert_eq!(timeline.entries.len(), 2);
        assert_eq!(timeline.cursor, 2);
        assert_eq!(split_ratio(&tree, split), second_ratio);
        assert!(timeline.undo(&mut tree).expect("undo should succeed"));
        assert_eq!(split_ratio(&tree, split), first_ratio);
    }

    #[test]
    fn interaction_timeline_refreshes_checkpoint_when_coalescing_head() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let split = id(1);
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        timeline.checkpoint_interval = 1;

        timeline
            .apply_and_record_coalesced_resize_delta(
                &mut tree,
                1,
                201,
                PaneOperation::SetSplitRatio {
                    split,
                    ratio: PaneSplitRatio::new(6, 4).expect("valid ratio"),
                },
                200,
            )
            .expect("first ratio should apply");
        timeline
            .apply_and_record_coalesced_resize_delta(
                &mut tree,
                2,
                202,
                PaneOperation::SetSplitRatio {
                    split,
                    ratio: PaneSplitRatio::new(8, 2).expect("valid ratio"),
                },
                200,
            )
            .expect("second ratio should apply");

        assert_eq!(timeline.entries.len(), 1);
        assert_eq!(timeline.checkpoints.len(), 1);
        assert_eq!(timeline.checkpoints[0].applied_len, 1);
        assert_eq!(timeline.checkpoints[0].snapshot, tree.to_snapshot());
    }

    #[test]
    fn interaction_timeline_enforces_configured_max_entries() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree).with_max_entries(3);
        let split = id(1);
        let ratios = [
            PaneSplitRatio::new(4, 6).expect("valid ratio"),
            PaneSplitRatio::new(5, 5).expect("valid ratio"),
            PaneSplitRatio::new(6, 4).expect("valid ratio"),
            PaneSplitRatio::new(7, 3).expect("valid ratio"),
            PaneSplitRatio::new(8, 2).expect("valid ratio"),
        ];

        for (index, ratio) in ratios.into_iter().enumerate() {
            timeline
                .apply_and_record(
                    &mut tree,
                    index as u64 + 1,
                    300 + index as u64,
                    PaneOperation::SetSplitRatio { split, ratio },
                )
                .expect("ratio update should apply");
        }

        assert_eq!(timeline.entries.len(), 3);
        assert_eq!(timeline.cursor, 3);
        assert_eq!(timeline.entries[0].operation_id, 302);
        assert_eq!(timeline.entries[2].operation_id, 304);
        assert_eq!(timeline.next_operation_id(), 305);
        let replayed = timeline.replay().expect("replay should succeed");
        assert_eq!(replayed.to_snapshot(), tree.to_snapshot());
    }

    #[test]
    fn interaction_timeline_records_checkpoints_at_default_interval() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let split_ids: Vec<_> = tree
            .nodes()
            .filter_map(|node| match node.kind {
                PaneNodeKind::Split(_) => Some(node.id),
                PaneNodeKind::Leaf(_) => None,
            })
            .collect();
        let ratios = [
            PaneSplitRatio::new(3, 2).expect("valid ratio"),
            PaneSplitRatio::new(2, 3).expect("valid ratio"),
            PaneSplitRatio::new(5, 4).expect("valid ratio"),
            PaneSplitRatio::new(4, 5).expect("valid ratio"),
        ];

        for idx in 0..16u64 {
            timeline
                .apply_and_record(
                    &mut tree,
                    idx,
                    30_000 + idx,
                    PaneOperation::SetSplitRatio {
                        split: split_ids[idx as usize % split_ids.len()],
                        ratio: ratios[idx as usize % ratios.len()],
                    },
                )
                .expect("ratio update should apply");
        }

        assert_eq!(timeline.checkpoints.len(), 1);
        assert_eq!(timeline.checkpoints[0].applied_len, 16);
        assert_eq!(timeline.checkpoints[0].snapshot, tree.to_snapshot());
    }

    #[test]
    fn interaction_timeline_discards_stale_checkpoints_after_branching() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let split_ids: Vec<_> = tree
            .nodes()
            .filter_map(|node| match node.kind {
                PaneNodeKind::Split(_) => Some(node.id),
                PaneNodeKind::Leaf(_) => None,
            })
            .collect();
        let ratios = [
            PaneSplitRatio::new(3, 2).expect("valid ratio"),
            PaneSplitRatio::new(2, 3).expect("valid ratio"),
            PaneSplitRatio::new(5, 4).expect("valid ratio"),
            PaneSplitRatio::new(4, 5).expect("valid ratio"),
        ];

        for idx in 0..32u64 {
            timeline
                .apply_and_record(
                    &mut tree,
                    idx,
                    40_000 + idx,
                    PaneOperation::SetSplitRatio {
                        split: split_ids[idx as usize % split_ids.len()],
                        ratio: ratios[idx as usize % ratios.len()],
                    },
                )
                .expect("ratio update should apply");
        }

        assert_eq!(timeline.checkpoints.len(), 2);
        timeline.undo(&mut tree).expect("undo should succeed");
        timeline.undo(&mut tree).expect("undo should succeed");
        timeline.undo(&mut tree).expect("undo should succeed");
        timeline.undo(&mut tree).expect("undo should succeed");
        timeline.undo(&mut tree).expect("undo should succeed");

        timeline
            .apply_and_record(
                &mut tree,
                99,
                99_999,
                PaneOperation::SetSplitRatio {
                    split: split_ids[0],
                    ratio: PaneSplitRatio::new(7, 5).expect("valid ratio"),
                },
            )
            .expect("branching update should apply");

        assert_eq!(timeline.cursor, 28);
        assert_eq!(timeline.entries.len(), 28);
        assert_eq!(timeline.checkpoints.len(), 1);
        assert_eq!(timeline.checkpoints[0].applied_len, 16);
    }

    #[test]
    fn interaction_timeline_replay_diagnostics_report_checkpoint_hit_and_depth() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let split_ids: Vec<_> = tree
            .nodes()
            .filter_map(|node| match node.kind {
                PaneNodeKind::Split(_) => Some(node.id),
                PaneNodeKind::Leaf(_) => None,
            })
            .collect();

        for idx in 0..20u64 {
            timeline
                .apply_and_record(
                    &mut tree,
                    idx,
                    50_000 + idx,
                    PaneOperation::SetSplitRatio {
                        split: split_ids[idx as usize % split_ids.len()],
                        ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
                    },
                )
                .expect("ratio update should apply");
        }

        let diagnostics = timeline.replay_diagnostics();
        assert_eq!(diagnostics.entry_count, 20);
        assert_eq!(diagnostics.cursor, 20);
        assert_eq!(diagnostics.checkpoint_interval, 16);
        assert_eq!(diagnostics.checkpoint_count, 1);
        assert!(diagnostics.checkpoint_hit);
        assert_eq!(diagnostics.replay_start_idx, 16);
        assert_eq!(diagnostics.replay_depth, 4);
    }

    #[test]
    fn interaction_timeline_retention_diagnostics_reports_entry_and_snapshot_footprint() {
        let mut tree = PaneTree::singleton("root");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        timeline.checkpoint_interval = 1;

        timeline
            .apply_and_record(
                &mut tree,
                1,
                60_000,
                PaneOperation::SplitLeaf {
                    target: id(1),
                    axis: SplitAxis::Horizontal,
                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
                    placement: PanePlacement::ExistingFirst,
                    new_leaf: PaneLeaf::new("aux"),
                },
            )
            .expect("split should apply");

        let diagnostics = timeline.retention_diagnostics();
        assert_eq!(diagnostics.entry_count, 1);
        assert_eq!(diagnostics.cursor, 1);
        assert_eq!(diagnostics.redo_entry_count, 0);
        assert_eq!(diagnostics.checkpoint_count, 1);
        assert_eq!(diagnostics.checkpoint_interval, 1);
        assert!(diagnostics.baseline_present);
        assert_eq!(diagnostics.retained_snapshot_count, 2);
        assert_eq!(diagnostics.baseline_node_count, 1);
        assert_eq!(diagnostics.checkpoint_node_count, 3);
        assert_eq!(diagnostics.retained_snapshot_node_count, 4);
        assert_eq!(diagnostics.retained_operation_payload_bytes, "aux".len());
        assert!(
            diagnostics.retained_leaf_payload_bytes >= "root".len() + "aux".len(),
            "leaf payload bytes should include retained surface keys"
        );
        assert!(
            diagnostics.estimated_total_retained_bytes
                >= diagnostics.estimated_entry_struct_bytes
                    + diagnostics.estimated_checkpoint_struct_bytes
                    + diagnostics.estimated_snapshot_struct_bytes
        );
    }

    #[test]
    fn interaction_timeline_retention_diagnostics_separates_leaf_keys_from_extensions() {
        let mut snapshot = make_valid_snapshot();
        snapshot
            .extensions
            .insert("tree".to_string(), "wide".to_string());

        let left_node = snapshot
            .nodes
            .iter_mut()
            .find(|node| node.id == id(2))
            .expect("left leaf should be present");
        left_node
            .extensions
            .insert("node".to_string(), "hot".to_string());
        let PaneNodeKind::Leaf(leaf) = &mut left_node.kind else {
            let expected_leaf_node = false;
            assert!(expected_leaf_node, "left node should be a leaf");
            return;
        };
        leaf.extensions
            .insert("leaf".to_string(), "memo".to_string());

        let tree = PaneTree::from_snapshot(snapshot).expect("snapshot should validate");
        let timeline = PaneInteractionTimeline::with_baseline(&tree);
        let diagnostics = timeline.retention_diagnostics();

        assert_eq!(diagnostics.retained_snapshot_count, 1);
        assert_eq!(
            diagnostics.retained_leaf_payload_bytes,
            "left".len() + "right".len()
        );
        assert_eq!(diagnostics.retained_extension_entry_count, 3);
        assert_eq!(
            diagnostics.retained_extension_payload_bytes,
            "tree".len() + "wide".len() + "node".len() + "hot".len() + "leaf".len() + "memo".len()
        );
    }

    #[test]
    fn interaction_timeline_retention_diagnostics_reports_redo_entries() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
        let split = id(1);

        for (idx, ratio) in [
            PaneSplitRatio::new(4, 6).expect("valid ratio"),
            PaneSplitRatio::new(6, 4).expect("valid ratio"),
        ]
        .into_iter()
        .enumerate()
        {
            timeline
                .apply_and_record(
                    &mut tree,
                    idx as u64 + 1,
                    70_000 + idx as u64,
                    PaneOperation::SetSplitRatio { split, ratio },
                )
                .expect("ratio update should apply");
        }

        assert!(timeline.undo(&mut tree).expect("undo should succeed"));
        let diagnostics = timeline.retention_diagnostics();
        assert_eq!(diagnostics.entry_count, 2);
        assert_eq!(diagnostics.cursor, 1);
        assert_eq!(diagnostics.redo_entry_count, 1);
    }

    #[test]
    fn interaction_timeline_checkpoint_decision_prefers_shorter_interval_for_expensive_replay_steps()
     {
        let slow_replay =
            PaneInteractionTimeline::checkpoint_decision(10_000, 2_500).checkpoint_interval;
        let cheap_replay =
            PaneInteractionTimeline::checkpoint_decision(10_000, 100).checkpoint_interval;

        assert!(slow_replay < cheap_replay);
        assert!(slow_replay >= 1);
        assert!(cheap_replay >= 1);
    }

    #[test]
    fn set_split_ratio_uses_local_validation_strategy() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        assert_eq!(
            tree.validation_strategy_for_operation(PaneOperationKind::SetSplitRatio),
            PaneValidationStrategy::LocalClosure
        );
        assert_eq!(
            tree.validation_strategy_for_operation(PaneOperationKind::NormalizeRatios),
            PaneValidationStrategy::FullTree
        );
    }

    #[test]
    fn local_validation_closure_rejects_parent_mismatch_for_touched_split() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let split_id = id(1);
        let child_id = id(2);
        tree.nodes.get_mut(&child_id).expect("child present").parent = None;

        let err = tree
            .validate_local_closure(&[split_id])
            .expect_err("local closure should detect broken child parent");
        assert!(matches!(
            err,
            PaneModelError::ParentMismatch {
                node_id,
                expected: Some(expected),
                actual: None,
            } if node_id == child_id && expected == split_id
        ));
    }

    #[test]
    fn operation_family_classifier_partitions_local_and_structural_kinds() {
        assert_eq!(
            PaneOperationKind::SetSplitRatio.family(),
            PaneOperationFamily::Local
        );
        for kind in [
            PaneOperationKind::SplitLeaf,
            PaneOperationKind::CloseNode,
            PaneOperationKind::MoveSubtree,
            PaneOperationKind::SwapNodes,
            PaneOperationKind::NormalizeRatios,
        ] {
            assert_eq!(
                kind.family(),
                PaneOperationFamily::Structural,
                "operation kind {kind:?} must be structural"
            );
        }
        // PaneOperation::family() delegates to its kind.
        let op = PaneOperation::SetSplitRatio {
            split: id(1),
            ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
        };
        assert_eq!(op.family(), PaneOperationFamily::Local);
        assert_eq!(op.family(), op.kind().family());
    }

    #[test]
    fn validation_strategy_is_derived_from_operation_family() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        assert_eq!(
            tree.validation_strategy_for_operation(PaneOperationKind::SetSplitRatio),
            PaneValidationStrategy::LocalClosure
        );
        for kind in [
            PaneOperationKind::SplitLeaf,
            PaneOperationKind::CloseNode,
            PaneOperationKind::MoveSubtree,
            PaneOperationKind::SwapNodes,
            PaneOperationKind::NormalizeRatios,
        ] {
            assert_eq!(
                tree.validation_strategy_for_operation(kind),
                PaneValidationStrategy::FullTree,
                "structural kind {kind:?} must use whole-tree validation"
            );
        }
    }

    #[test]
    fn always_full_validation_mode_catches_corruption_outside_touched_closure() {
        // A corruption far from the touched node is invisible to local-closure
        // validation but caught by the forced whole-tree validator. This proves
        // the conservative override is genuinely distinct and easy to force.
        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
        // Break the parent pointer of a leaf (id 5) that is NOT in the touched
        // closure of the root split (id 1).
        let far_leaf = id(5);
        tree.nodes.get_mut(&far_leaf).expect("present").parent = None;

        let touched = [id(1)];

        // Adaptive validation for the Local family only inspects the touched
        // closure (the root split and its direct children), so it misses the
        // far corruption.
        assert!(
            tree.validate_after_operation_with_mode(
                PaneOperationKind::SetSplitRatio,
                &touched,
                PaneValidationMode::Adaptive,
            )
            .is_ok(),
            "local closure over the root split must not see a far leaf corruption"
        );

        // Forcing whole-tree validation catches it regardless of touched set.
        assert!(
            tree.validate_after_operation_with_mode(
                PaneOperationKind::SetSplitRatio,
                &touched,
                PaneValidationMode::AlwaysFull,
            )
            .is_err(),
            "forced full validation must detect the far leaf corruption"
        );
    }

    fn neutral_pressure() -> PanePressureSnapProfile {
        PanePressureSnapProfile {
            strength_bps: 5_000,
            hysteresis_bps: 100,
        }
    }

    fn first_share_bps(op: &PaneOperation) -> u32 {
        match op {
            PaneOperation::SetSplitRatio { ratio, .. } => {
                ratio.numerator() * 10_000 / (ratio.numerator() + ratio.denominator())
            }
            other => panic!("expected SetSplitRatio, got {other:?}"),
        }
    }

    fn split_target(split: PaneId, axis: SplitAxis) -> PaneResizeTarget {
        PaneResizeTarget {
            split_id: split,
            axis,
        }
    }

    fn drag_updated_transition(
        target: PaneResizeTarget,
        current: PanePointerPosition,
    ) -> PaneDragResizeTransition {
        PaneDragResizeTransition {
            transition_id: 1,
            sequence: 1,
            from: PaneDragResizeState::Idle,
            to: PaneDragResizeState::Idle,
            effect: PaneDragResizeEffect::DragUpdated {
                target,
                pointer_id: 1,
                previous: PanePointerPosition::new(current.x - 1, current.y),
                current,
                delta_x: 1,
                delta_y: 0,
                total_delta_x: 1,
                total_delta_y: 0,
            },
        }
    }

    #[test]
    fn plan_splitter_resize_tracks_pointer_along_axis() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let rect = layout.rect(id(1)).expect("root split rect");
        let target = split_target(id(1), SplitAxis::Horizontal);

        let left_pointer = PanePointerPosition::new(i32::from(rect.x) + 2, i32::from(rect.y) + 4);
        let right_pointer = PanePointerPosition::new(
            i32::from(rect.x) + i32::from(rect.width) - 3,
            i32::from(rect.y) + 4,
        );

        let left_op = tree
            .plan_splitter_resize(target, &layout, left_pointer, neutral_pressure())
            .expect("left plan");
        let right_op = tree
            .plan_splitter_resize(target, &layout, right_pointer, neutral_pressure())
            .expect("right plan");

        assert!(matches!(left_op, PaneOperation::SetSplitRatio { split, .. } if split == id(1)));
        assert!(matches!(right_op, PaneOperation::SetSplitRatio { split, .. } if split == id(1)));
        // Dragging the splitter rightward grows the first (left) child's share.
        assert!(
            first_share_bps(&right_op) > first_share_bps(&left_op),
            "right={} left={}",
            first_share_bps(&right_op),
            first_share_bps(&left_op)
        );
    }

    #[test]
    fn plan_splitter_resize_rejects_non_split_target() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        // id(2) is the left leaf, not a split.
        let err = tree
            .plan_splitter_resize(
                split_target(id(2), SplitAxis::Horizontal),
                &layout,
                PanePointerPosition::new(5, 5),
                neutral_pressure(),
            )
            .expect_err("leaf target must be rejected");
        assert_eq!(err, PaneSplitterResizePlanError::NotASplit { node: id(2) });
    }

    #[test]
    fn plan_splitter_resize_rejects_axis_mismatch() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let err = tree
            .plan_splitter_resize(
                split_target(id(1), SplitAxis::Vertical),
                &layout,
                PanePointerPosition::new(5, 5),
                neutral_pressure(),
            )
            .expect_err("axis mismatch must be rejected");
        assert_eq!(
            err,
            PaneSplitterResizePlanError::AxisMismatch {
                split: id(1),
                expected: SplitAxis::Vertical,
                actual: SplitAxis::Horizontal,
            }
        );
    }

    #[test]
    fn plan_splitter_resize_rejects_missing_split() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let err = tree
            .plan_splitter_resize(
                split_target(id(99), SplitAxis::Horizontal),
                &layout,
                PanePointerPosition::new(5, 5),
                neutral_pressure(),
            )
            .expect_err("missing split must be rejected");
        assert_eq!(
            err,
            PaneSplitterResizePlanError::MissingSplit { split: id(99) }
        );
    }

    #[test]
    fn plan_splitter_nudge_steps_current_ratio() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        // make_valid_snapshot's root ratio is 3/2 -> a first-child share of 6000 bps.
        let target = split_target(id(1), SplitAxis::Horizontal);
        let up = tree
            .plan_splitter_nudge(target, i32::from(PANE_SNAP_DEFAULT_STEP_BPS))
            .expect("nudge up");
        let down = tree
            .plan_splitter_nudge(target, -i32::from(PANE_SNAP_DEFAULT_STEP_BPS))
            .expect("nudge down");
        assert_eq!(first_share_bps(&up), 6_500);
        assert_eq!(first_share_bps(&down), 5_500);
    }

    #[test]
    fn operations_for_transition_drag_updated_mutates_ratio() {
        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let rect = layout.rect(id(1)).expect("root split rect");
        let target = split_target(id(1), SplitAxis::Horizontal);
        let pointer = PanePointerPosition::new(
            i32::from(rect.x) + i32::from(rect.width) - 3,
            i32::from(rect.y) + 4,
        );
        let transition = drag_updated_transition(target, pointer);

        let before = split_ratio(&tree, id(1));
        let before_share =
            before.numerator() * 10_000 / (before.numerator() + before.denominator());

        let ops = tree.operations_for_transition(&transition, &layout, neutral_pressure());
        assert_eq!(ops.len(), 1);
        for (offset, op) in ops.iter().cloned().enumerate() {
            tree.apply_operation(1_000 + offset as u64, op)
                .expect("operation applies");
        }

        let after = split_ratio(&tree, id(1));
        let after_share = after.numerator() * 10_000 / (after.numerator() + after.denominator());
        assert!(
            after_share > before_share,
            "after={after_share} before={before_share}"
        );
    }

    #[test]
    fn operations_for_transition_keyboard_applied_nudges() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let target = split_target(id(1), SplitAxis::Horizontal);
        let transition = PaneDragResizeTransition {
            transition_id: 2,
            sequence: 2,
            from: PaneDragResizeState::Idle,
            to: PaneDragResizeState::Idle,
            effect: PaneDragResizeEffect::KeyboardApplied {
                target,
                direction: PaneResizeDirection::Increase,
                units: 2,
            },
        };
        let ops = tree.operations_for_transition(&transition, &layout, neutral_pressure());
        assert_eq!(ops.len(), 1);
        // 6000 (3/2) + 2 * 500 = 7000.
        assert_eq!(first_share_bps(&ops[0]), 7_000);
    }

    #[test]
    fn operations_for_transition_is_empty_for_non_geometric_and_unresolvable() {
        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
        let layout = tree
            .solve_layout(Rect::new(0, 0, 60, 16))
            .expect("layout solves");
        let target = split_target(id(1), SplitAxis::Horizontal);

        let armed = PaneDragResizeTransition {
            transition_id: 3,
            sequence: 3,
            from: PaneDragResizeState::Idle,
            to: PaneDragResizeState::Idle,
            effect: PaneDragResizeEffect::Armed {
                target,
                pointer_id: 1,
                origin: PanePointerPosition::new(5, 5),
            },
        };
        assert!(
            tree.operations_for_transition(&armed, &layout, neutral_pressure())
                .is_empty()
        );

        let canceled = PaneDragResizeTransition {
            transition_id: 4,
            sequence: 4,
            from: PaneDragResizeState::Idle,
            to: PaneDragResizeState::Idle,
            effect: PaneDragResizeEffect::Canceled {
                target: Some(target),
                pointer_id: Some(1),
                reason: PaneCancelReason::EscapeKey,
            },
        };
        assert!(
            tree.operations_for_transition(&canceled, &layout, neutral_pressure())
                .is_empty()
        );

        // A drag targeting a split that no longer exists is swallowed (no panic,
        // no operation), matching the documented best-effort bridge contract.
        let stale = drag_updated_transition(
            split_target(id(99), SplitAxis::Horizontal),
            PanePointerPosition::new(10, 5),
        );
        assert!(
            tree.operations_for_transition(&stale, &layout, neutral_pressure())
                .is_empty()
        );
    }
}
