//! UC-08-F7 — typed undo error reasons surfaced through the pipeline.
//!
//! Each variant corresponds to a specific pre-commit `Decision::Deny`
//! path in the undo filter handlers (`core/engine/src/undo_handlers.rs`).
//! Variants are parsed back from the structured reason string carried
//! on `ProposeResult::Denied { reason }` — each filter prefixes its
//! reason with a stable `[code]: detail` envelope, so client code can
//! pattern-match the typed shape rather than parse the human-readable
//! string.
//!
//! Lives in `sdk-types` (always-on) rather than `core/sdk/wire/` (which
//! is feature-gated behind `wire`) so the engine-side filter handlers
//! can emit `UndoError::format()` without dragging in postcard +
//! serde-on-the-wire. Re-exported from `maat_sdk::wire::UndoError`
//! when the `wire` feature is enabled.

/// Typed undo error — the set of pre-commit denials emitted by the
/// `undo:*` filter handlers.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum UndoError {
    /// The target entry exists but is already marked `undone`. Surfaces
    /// from the `undo:already_undone` Filter — covers the
    /// "Bob undid this 200ms ago" race window.
    TargetAlreadyUndone { target_seq: u64 },
    /// `target_seq` is below the configured `UndoHorizon` floor; the
    /// session has already retired snapshots that would be needed to
    /// reconstruct state at that point. Surfaces from
    /// `undo:horizon_check`.
    TargetOutsideHorizon {
        target_seq: u64,
        current_horizon_floor: u64,
    },
    /// Race window between propose and commit — another undo for the
    /// same target is mid-flight. Not currently emitted (no plural-undo
    /// race window exists with the single-threaded pipeline), but kept
    /// for forward-compat with future propose-batch / multi-actor races.
    ConcurrentUndoInProgress { other_seq: u64 },
    /// `target_seq` is not present in the event log. Surfaces from
    /// `undo:target_exists`.
    TargetNotFound { target_seq: u64 },
}

impl UndoError {
    /// Stable error code, used as the `[code]` prefix on the canonical
    /// "[code]: detail" deny reason string.
    pub fn code(&self) -> &'static str {
        match self {
            UndoError::TargetAlreadyUndone { .. } => "undo:already_undone",
            UndoError::TargetOutsideHorizon { .. } => "undo:horizon_check",
            UndoError::ConcurrentUndoInProgress { .. } => "undo:concurrent_in_progress",
            UndoError::TargetNotFound { .. } => "undo:target_not_found",
        }
    }

    /// Canonical "[code]: detail" string — the form emitted by the
    /// undo filter handlers via `Decision::Deny`. Pair with
    /// [`parse_from_deny_reason`](Self::parse_from_deny_reason) to
    /// round-trip through `ProposeResult::Denied`'s `String` reason.
    pub fn format(&self) -> String {
        let detail = match self {
            UndoError::TargetAlreadyUndone { target_seq } => {
                format!("target_seq={target_seq} is already marked undone")
            }
            UndoError::TargetOutsideHorizon {
                target_seq,
                current_horizon_floor,
            } => {
                format!(
                    "target_seq={target_seq} below undo horizon floor {current_horizon_floor}"
                )
            }
            UndoError::ConcurrentUndoInProgress { other_seq } => {
                format!("another undo (seq={other_seq}) is currently in progress")
            }
            UndoError::TargetNotFound { target_seq } => {
                format!("target_seq={target_seq} not present in event log")
            }
        };
        format!("[{}]: {}", self.code(), detail)
    }

    /// Best-effort parse of a `Decision::Deny` reason string back into
    /// a typed `UndoError`. Returns `None` if the reason does not match
    /// any recognised undo error shape (e.g. it's a permission denial,
    /// or a custom filter's reason). Callers that need exhaustive
    /// typed errors should fall back to the human-readable string.
    ///
    /// Accepts both the raw `"[code]: detail"` form and the
    /// pipeline-wrapped `"[handler:name] [code]: detail"` form (the
    /// pipeline prefixes every Filter denial with its handler id).
    /// Finds the *last* `"[code]: detail"` occurrence in the string.
    pub fn parse_from_deny_reason(reason: &str) -> Option<Self> {
        // Walk from right-to-left looking for the last `]: ` then a
        // matching `[` start. The pipeline-wrapped form has TWO `[...]`
        // prefixes; we want the inner (typed) one which is closer to
        // the detail.
        let close = reason.rfind("]: ")?;
        let open = reason[..close].rfind('[')?;
        let code = &reason[open + 1..close];
        let detail = &reason[close + 3..];
        match code {
            "undo:already_undone" => {
                let seq = parse_kv_u64(detail, "target_seq=")?;
                Some(UndoError::TargetAlreadyUndone { target_seq: seq })
            }
            "undo:horizon_check" => {
                let target = parse_kv_u64(detail, "target_seq=")?;
                let floor_idx = detail.find("floor ")?;
                let floor_str = &detail[floor_idx + "floor ".len()..];
                let floor = floor_str
                    .trim_end_matches(|c: char| !c.is_ascii_digit())
                    .parse()
                    .ok()?;
                Some(UndoError::TargetOutsideHorizon {
                    target_seq: target,
                    current_horizon_floor: floor,
                })
            }
            "undo:concurrent_in_progress" => {
                let seq = parse_kv_u64(detail, "seq=")?;
                Some(UndoError::ConcurrentUndoInProgress { other_seq: seq })
            }
            "undo:target_not_found" => {
                let seq = parse_kv_u64(detail, "target_seq=")?;
                Some(UndoError::TargetNotFound { target_seq: seq })
            }
            _ => None,
        }
    }
}

fn parse_kv_u64(haystack: &str, key: &str) -> Option<u64> {
    let idx = haystack.find(key)?;
    let rest = &haystack[idx + key.len()..];
    let end = rest
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(rest.len());
    rest[..end].parse().ok()
}

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

    #[test]
    fn target_not_found_format_includes_user_facing_reason_string() {
        let err = UndoError::TargetNotFound { target_seq: 42 };
        let s = err.format();
        assert!(s.starts_with("[undo:target_not_found]: "), "got {s}");
        assert!(s.contains("target_seq=42"), "got {s}");
    }

    #[test]
    fn target_outside_horizon_round_trips_through_deny_reason() {
        let err = UndoError::TargetOutsideHorizon {
            target_seq: 7,
            current_horizon_floor: 20,
        };
        let s = err.format();
        let parsed = UndoError::parse_from_deny_reason(&s).expect("parse");
        assert_eq!(parsed, err);
    }

    #[test]
    fn target_already_undone_round_trips() {
        let err = UndoError::TargetAlreadyUndone { target_seq: 99 };
        let s = err.format();
        let parsed = UndoError::parse_from_deny_reason(&s).expect("parse");
        assert_eq!(parsed, err);
    }

    #[test]
    fn concurrent_undo_round_trips() {
        let err = UndoError::ConcurrentUndoInProgress { other_seq: 12 };
        let s = err.format();
        let parsed = UndoError::parse_from_deny_reason(&s).expect("parse");
        assert_eq!(parsed, err);
    }

    #[test]
    fn target_not_found_round_trips() {
        let err = UndoError::TargetNotFound { target_seq: 5 };
        let s = err.format();
        let parsed = UndoError::parse_from_deny_reason(&s).expect("parse");
        assert_eq!(parsed, err);
    }

    #[test]
    fn unrelated_reason_string_does_not_parse() {
        assert!(UndoError::parse_from_deny_reason("Cannot undo an undo or a redo").is_none());
        assert!(UndoError::parse_from_deny_reason(
            "[undo:permission]: Cross-actor undo: seat ..."
        )
        .is_none());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn undo_error_round_trips_through_serde() {
        let err = UndoError::TargetOutsideHorizon {
            target_seq: 50,
            current_horizon_floor: 100,
        };
        let json = serde_json::to_string(&err).expect("serialize");
        let decoded: UndoError = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(decoded, err);
    }
}
