use std::collections::{HashMap, HashSet};

use crate::types::PoolId;
use crate::types::{EntityTypeId, PlayerId, SeatId};
use crate::settings::SettingsValue;
use crate::predicate::types::SourceKey;

/// S25 Phase 3 — per-session plugin pins.
///
/// A `PluginPins` value lists, for each plugin kind, the exact
/// `(canonical_id, content_hash, interface_hash)` triples the session
/// is bound to. The engine constructs a [`crate::PluginManifest`] view
/// of these at session start (via the engine's registries). An empty
/// `PluginPins` means "auto-resolve to whatever is installed at session
/// construction time" — the legacy path used by every plugin until
/// migration completes.
///
/// `PluginPins` lives on the SDK side (rather than under the
/// linkme-gated `plugin` module) so it can travel through the WASM
/// client and through `SessionConfig` snapshots without pulling in
/// linkme; the actual resolution against installed registries happens
/// in `maat-engine`.
///
/// Stored on `SessionConfig` is deferred to Phase 6 — for Phase 3 the
/// engine's session constructor takes an `Option<PluginPins>` directly.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg(not(target_arch = "wasm32"))]
pub struct PluginPins {
    pub model: Vec<crate::plugin_version::PluginRef>,
    pub view: Vec<crate::plugin_version::PluginRef>,
    pub capability: Vec<crate::plugin_version::PluginRef>,
}

#[cfg(not(target_arch = "wasm32"))]
impl PluginPins {
    pub fn new() -> Self { Self::default() }
    pub fn is_empty(&self) -> bool {
        self.model.is_empty() && self.view.is_empty() && self.capability.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionId(pub u64);

#[derive(Debug, Clone)]
pub struct PlayerConfig {
    pub id: SeatId,
    pub display_name: String,
}

#[allow(dead_code)]
const _: Option<PlayerId> = None; // PlayerId used in seat_assignments at higher layers

/// Controls how a handler's Deny result is interpreted by the host.
/// Handlers always return Deny(reason) — enforcement mode is host policy.
//
// Intentionally NOT #[non_exhaustive]: the pipeline matches exhaustively
// on EnforcementMode to decide whether to commit a Denied event. Adding
// a new mode must force the engine to define its semantics.
//
// **Conceptual gradient** (designer-facing wording — Enforce / Adjudicate / Trust):
// - `Enforce`     — system blocks the action; the player sees the error.
// - `Adjudicate`  — system raises a Suspension to the narrator/director who
//                   decides "Allow it" or "Deny." (Uses the S07
//                   `pipeline_result` mechanism.) An adjudication-mode session
//                   without a resolved decider parks the suspension in
//                   `pending_suspension` until someone resolves it.
// - `Advise`      — "Trust, with a warning." Deny commits; a warning is logged
//                   to the narrator. Behaviorally part of the "Trust" family.
// - `Permissive`  — "Trust, silently." Deny commits, no warning. Behaviorally
//                   part of the "Trust" family.
//
// `Advise` and `Permissive` are intentionally retained as distinct variants
// (rather than collapsed into a single `Trust` rename) to preserve API
// compatibility with existing plugins and snapshots. Designer UIs may surface
// them as a single "Trust" option whose suboption is "warn vs silent."
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum EnforcementMode {
    /// Deny blocks the event. Default behavior.
    #[default]
    Enforce,
    /// Deny is converted into a Suspension addressed to the narrator/director,
    /// who decides "Allow it" or "Deny" in real time. Uses S07's
    /// `SuspensionOption.pipeline_result` to short-circuit the original event.
    /// The pipeline halts until the suspension is resolved.
    Adjudicate,
    /// Deny logs a warning and notifies the narrator, but the event commits anyway.
    /// (Conceptually part of the "Trust" family — see enum-level docs.)
    Advise,
    /// Deny is logged silently. Event commits. Fully permissive.
    /// (Conceptually part of the "Trust" family — see enum-level docs.)
    Permissive,
}

/// S23 — strongly-typed seat role.
///
/// Permission model for the table. Multiple seats can hold the same role
/// (e.g. co-Gamemasters). Default for new seats is `Player`; the first seat
/// in a session takes `SessionConfig::initial_role_for_creator` (default
/// `Gamemaster`). `Arbiter` is reserved for a future neutral-party referee
/// and is treated as `Observer` for permission purposes until specced.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub enum SeatRole {
    /// Server operator privileges. Cross-actor authority plus operator-only
    /// actions (force-close, eject, edit role assignments).
    Sysadmin,
    /// The GM of the table. Cross-actor authority over gameplay (undo,
    /// adjudicate, scene transitions, NPC control). Multiple supported.
    Gamemaster,
    /// Controls their own seat. Default for non-creator seats.
    #[default]
    Player,
    /// Read-only spectator. Cannot Propose, UndoRequest, RedoRequest.
    Observer,
    /// Reserved for a future neutral-party referee role. Treated as
    /// `Observer` for permission purposes until specced.
    Arbiter,
}

impl SeatRole {
    /// `true` when this role may act on behalf of other seats — the
    /// typed-role analogue of [`SessionConfig::has_cross_actor_authority`]
    /// for callers that hold a `SeatRole` directly (capability dispatch,
    /// FrameContext, broadcast-recipient resolution).
    ///
    /// Sysadmin and Gamemaster qualify; Player / Observer / Arbiter do not.
    pub fn has_cross_actor_authority(&self) -> bool {
        matches!(self, SeatRole::Sysadmin | SeatRole::Gamemaster)
    }

    /// `true` when this role is read-only (Observer or the placeholder
    /// Arbiter). Read-only roles cannot Propose, UndoRequest, RedoRequest.
    pub fn is_observer(&self) -> bool {
        matches!(self, SeatRole::Observer | SeatRole::Arbiter)
    }
}

// ---------------------------------------------------------------------------
// UC-08 F6 — adjudication scoping
// ---------------------------------------------------------------------------

/// Per-GM-seat scope describing which suspensions a GM is allowed to
/// see and resolve. Default [`GmScope::All`] preserves single-GM
/// ergonomics; co-GMs narrow explicitly via the EnforcementPanel.
///
/// `Seats(set)` covers suspensions whose [`crate::events::Suspension::affected_seat`]
/// is in `set`. `Pools(set)` covers suspensions whose
/// [`crate::events::Suspension::affected_pool`] is in `set`. The two
/// variants are deliberately disjoint: a GM scoped to specific seats
/// does NOT cover pool-only suspensions and vice versa.
///
/// Routed by `runtime/server/src/broadcast.rs::broadcast_suspension_to_gms`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum GmScope {
    #[default]
    /// Default: GM authorizes any suspension. Single-GM ergonomics.
    All,
    /// GM authorizes only suspensions affecting one of these seats.
    Seats(HashSet<SeatId>),
    /// GM authorizes only suspensions touching one of these pools
    /// (e.g. "GM of combat pool").
    Pools(HashSet<PoolId>),
}

impl GmScope {
    /// Does this scope cover a suspension affecting `affected_seat`?
    pub fn covers_seat(&self, affected_seat: Option<SeatId>) -> bool {
        match self {
            Self::All => true,
            Self::Seats(set) => affected_seat.is_some_and(|s| set.contains(&s)),
            // Pool scope covers pool ops, not seat ops.
            Self::Pools(_) => false,
        }
    }

    /// Does this scope cover a suspension touching `affected_pool`?
    pub fn covers_pool(&self, affected_pool: Option<&PoolId>) -> bool {
        match self {
            Self::All => true,
            Self::Pools(set) => affected_pool.is_some_and(|p| set.contains(p)),
            Self::Seats(_) => false,
        }
    }
}

// 5650: CreationPolicy, AdjudicateFallback, SheetEditPolicy DELETED.
// Adjudication is now a per-EventKind PermissionTier resolved through
// D27 EnforcementMode. See `core/sdk-types/src/adjudication.rs` and
// `core/engine/src/adjudication.rs`.

/// Per-plugin-slot rule override, applied on top of session defaults.
#[derive(Debug, Clone)]
pub enum RuleOverride {
    Disabled,
    PriorityOverride(i32),
    EnforcementOverride(EnforcementMode),
}

#[derive(Debug, Clone)]
pub struct PluginSlot {
    pub plugin_id: String,
    pub enabled: bool,
    pub rule_overrides: HashMap<String, RuleOverride>,
}

impl PluginSlot {
    pub fn new(plugin_id: impl Into<String>) -> Self {
        Self { plugin_id: plugin_id.into(), enabled: true, rule_overrides: HashMap::new() }
    }
}

#[derive(Debug, Clone)]
pub struct SessionConfig {
    pub session_id: SessionId,
    pub rng_seed: u64,
    pub max_cascade_depth: u32,
    pub max_modify_restarts: u32,
    pub players: Vec<PlayerConfig>,
    pub ruleset_stack: Vec<PluginSlot>,
    pub model_settings: HashMap<String, SettingsValue>,
    pub view_settings: HashMap<String, SettingsValue>,
    pub default_enforcement: EnforcementMode,
    /// Per-seat enforcement override. When a seat is the *acting player* for
    /// an event and a handler returns Deny, the pipeline checks this map
    /// first, falling back to `default_enforcement`.
    ///
    /// Per-handler `RuleOverride::EnforcementOverride` (declared on a
    /// `PluginSlot`) wins over both this map and `default_enforcement` when
    /// the handler in question is responsible for the Deny.
    pub enforcement_overrides: HashMap<SeatId, EnforcementMode>,

    /// UC-08 F6 — per-GM-seat adjudication scope. Absent entries default
    /// to [`GmScope::All`] (the single-GM ergonomic default). Suspension
    /// routing in `broadcast.rs::broadcast_suspension_to_gms` filters
    /// through this map before sending `ServerFrame::Suspended` to
    /// non-actor GMs.
    pub gm_scope: HashMap<SeatId, GmScope>,

    /// Sourcebooks / content packs active in this session. Predicates that
    /// gate selectables on `Value::SourceLoaded(...)` evaluate against this
    /// set (plus per-seat `source_overrides`).
    pub active_sources: HashSet<SourceKey>,

    /// Per-seat exceptions. A seat may be granted access to a `SourceKey`
    /// that is not in `active_sources`, or to specific entity types whose
    /// sources are blocked. Resolution rule: a `SourceLoaded(k)` predicate
    /// returns true if `k ∈ active_sources` OR the predicate's `Self_` entity
    /// is owned by a seat whose override set contains `Source(k)`.
    pub source_overrides: HashMap<SeatId, HashSet<SourceOverrideKey>>,

    /// 0502: per-session aggregate WASM-plugin memory cap, in bytes.
    /// `None` falls back to `DEFAULT_PLUGINS_PER_SESSION_MEMORY` (256 MiB).
    /// Clamped to `MAX_PLUGINS_PER_SESSION_MEMORY` (1 GiB) at install time.
    /// The cap covers the sum of all wasm-plugin instance memories in this
    /// session; per-plugin caps are enforced separately via the manifest's
    /// `requested_memory_bytes`.
    pub wasm_session_memory_max: Option<u64>,

    /// Public session settings — readable by all clients via snapshot.
    /// Predicates that gate on `Value::Setting(...)` read here. Keys are
    /// plugin-namespaced strings (e.g. `"myplugin:rule:my_setting"`); values are
    /// integers (booleans encoded as 0/1).
    pub public_settings: HashMap<String, i64>,

    /// UC-08: how far back undo can reach. Default [`UndoHorizon::Turn`].
    /// Permissions (who is allowed to invoke undo) are gated separately by
    /// `UndoRequested` filter handlers; this only controls reachability.
    ///
    /// The `default_undo_horizon` constructor below is exposed so future
    /// serde derives on `SessionConfig` can wire `#[serde(default = "...")]`
    /// without breaking pre-UC-08 config payloads.
    pub undo_horizon: UndoHorizon,

    /// UC-08: who sees toast notifications when an undo executes.
    /// Default [`UndoNotifyVisibility::All`].
    pub undo_notify_visibility: UndoNotifyVisibility,

    /// S23 — per-seat role assignments. Seats absent from the map default
    /// to [`SeatRole::Player`] via [`SessionConfig::role_for`]. The first
    /// seat in the session takes [`Self::initial_role_for_creator`].
    pub seat_roles: HashMap<SeatId, SeatRole>,

    /// S23 — role granted to the session creator (first seat). Default
    /// [`SeatRole::Gamemaster`].
    pub initial_role_for_creator: SeatRole,

    /// S25 Phase 6 — pinned plugin versions. Persisted as part of the
    /// config so saved sessions and campaign templates remember which
    /// `(canonical_id, content_hash)` triples the session was bound
    /// to. Empty / `None` means "auto-resolve at session construction"
    /// — preserving the legacy free-bind behaviour for code that
    /// hasn't migrated.
    ///
    /// Gated off wasm32 because `PluginPins` references
    /// `maat_sdk::plugin::PluginRef`, which is itself gated.
    /// WASM clients use `SessionConfig` for snapshot decoding only and
    /// don't need to carry the pin set.
    #[cfg(not(target_arch = "wasm32"))]
    pub pinned_plugins: Option<PluginPins>,

    /// AR-1 -- system-wide default distance metric for `RangedData` checks.
    /// Used when an ability's `RangedData.metric` is `None`. If both are
    /// `None`, range checks deny with a typed error. Set per game system
    /// (e.g. D&D 5e: `Some(Chebyshev)`; chess/checkers: `Some(Manhattan)`).
    pub default_distance_metric: Option<crate::distance::DistanceMetric>,

    /// UC-32 -- optional Discord / Zoom / Meet / Jitsi voice channel
    /// URL. Validated against [`is_allowed_voice_url`] when set; the
    /// client renders a "Join Voice" button in the lobby when present.
    /// Per-session for v1; future revisions may add per-campaign
    /// inheritance with per-session override.
    
    pub voice_url: Option<String>,
}

/// UC-32 -- domain whitelist for `SessionConfig.voice_url`. Returns
/// `true` iff `url` is a valid HTTPS URL whose host matches one of the
/// permitted voice-chat providers exactly (or as a sub-domain).
///
/// Whitelist (case-insensitive on host):
/// - `discord.gg` / `discord.com`
/// - `zoom.us`
/// - `meet.google.com`
/// - `jit.si` / `*.jit.si` / `meet.jit.si`
/// - `jitsi.org` / `*.jitsi.org`
///
/// HTTP (non-TLS) URLs are rejected. Bare hostnames without a scheme
/// are rejected.
pub fn is_allowed_voice_url(url: &str) -> bool {
    let Some(rest) = url.strip_prefix("https://") else {
        return false;
    };
    let host = rest.split(['/', '?', '#']).next().unwrap_or("");
    let host = host.split(':').next().unwrap_or("");
    let host = host.split('@').next_back().unwrap_or("");
    let host = host.to_ascii_lowercase();
    if host.is_empty() {
        return false;
    }
    const ALLOW: &[&str] = &[
        "discord.gg",
        "discord.com",
        "zoom.us",
        "meet.google.com",
        "jit.si",
        "jitsi.org",
    ];
    ALLOW.iter().any(|d| host == *d || host.ends_with(&format!(".{}", d)))
}

#[cfg(test)]
mod voice_url_tests {
    use super::is_allowed_voice_url as ok;

    #[test]
    fn discord_zoom_meet_jit_si_all_accepted() {
        assert!(ok("https://discord.gg/abc"));
        assert!(ok("https://discord.com/channels/1/2"));
        assert!(ok("https://zoom.us/j/123"));
        assert!(ok("https://meet.google.com/abc-defg-hij"));
        assert!(ok("https://meet.jit.si/myroom"));
        assert!(ok("https://jitsi.org/anything"));
    }

    #[test]
    fn http_or_arbitrary_domain_rejected() {
        assert!(!ok("http://discord.gg/abc"), "must be https");
        assert!(!ok("https://example.com/discord.gg/abc"), "must match host, not path");
        assert!(!ok("https://evilcorp.com/"));
        assert!(!ok("discord.gg/abc"), "must have scheme");
        assert!(!ok(""));
    }

    #[test]
    fn subdomains_of_jit_si_accepted() {
        assert!(ok("https://prod.jit.si/abc"));
        assert!(ok("https://eu-west.jitsi.org/abc"));
    }
}

/// Per-seat source override variants used in
/// [`SessionConfig::source_overrides`]. Either grant the whole sourcebook
/// to one seat, or grant a specific entity type when the wider source
/// remains blocked.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SourceOverrideKey {
    /// This seat may use any entity from this sourcebook.
    Source(SourceKey),
    /// This seat may use this specific entity type even if its source is
    /// blocked (per-entity grant for hand-picked exceptions).
    Entity(EntityTypeId),
}

impl SessionConfig {
    pub fn new(session_id: u64, rng_seed: u64) -> Self {
        Self {
            session_id: SessionId(session_id),
            rng_seed,
            max_cascade_depth: 16,
            max_modify_restarts: 8,
            players: vec![],
            ruleset_stack: vec![],
            model_settings: HashMap::new(),
            view_settings: HashMap::new(),
            default_enforcement: EnforcementMode::Enforce,
            enforcement_overrides: HashMap::new(),
            gm_scope: HashMap::new(),
            active_sources: HashSet::new(),
            source_overrides: HashMap::new(),
            public_settings: HashMap::new(),
            undo_horizon: default_undo_horizon(),
            undo_notify_visibility: default_undo_notify_visibility(),
            seat_roles: HashMap::new(),
            initial_role_for_creator: SeatRole::Gamemaster,
            #[cfg(not(target_arch = "wasm32"))]
            pinned_plugins: None,
            default_distance_metric: None,
            voice_url: None,
            wasm_session_memory_max: None,
        }
    }

    // ----- S23: SeatRole helpers ----------------------------------------

    /// Role assigned to `seat`. Returns [`SeatRole::Player`] when the seat
    /// has no entry — the safe default for new joiners.
    pub fn role_for(&self, seat: SeatId) -> SeatRole {
        self.seat_roles.get(&seat).copied().unwrap_or_default()
    }

    // 5650: resolve_creation_policy + resolve_sheet_edit_policy DELETED.
    // Gating moved to the engine PermissionRegistry + D27 routing.
    // See `core/engine/src/adjudication.rs`.

    /// Insert / overwrite the role for `seat`. Caller is responsible for
    /// any authorization checks (e.g. the wire-layer SetSeatRole guard).
    pub fn set_role(&mut self, seat: SeatId, role: SeatRole) {
        self.seat_roles.insert(seat, role);
    }

    /// Iterator over every seat currently holding the `Gamemaster` role.
    pub fn gamemaster_seats(&self) -> impl Iterator<Item = SeatId> + '_ {
        self.seat_roles
            .iter()
            .filter_map(|(s, r)| (*r == SeatRole::Gamemaster).then_some(*s))
    }

    /// `true` when `seat` may act on behalf of other seats (undo their
    /// decisions, adjudicate, drive NPCs). Sysadmin and Gamemaster both
    /// qualify.
    pub fn has_cross_actor_authority(&self, seat: SeatId) -> bool {
        matches!(
            self.role_for(seat),
            SeatRole::Sysadmin | SeatRole::Gamemaster
        )
    }

    /// `true` when `seat` is read-only (`Observer` or the placeholder
    /// `Arbiter`). Read-only seats cannot Propose, UndoRequest, or
    /// RedoRequest; they still receive snapshots and chat.
    pub fn is_observer_role(&self, seat: SeatId) -> bool {
        matches!(self.role_for(seat), SeatRole::Observer | SeatRole::Arbiter)
    }
}

// ---------------------------------------------------------------------------
// UC-08: Undo with configurable scope
// ---------------------------------------------------------------------------

/// How far back the undo button can reach in the event log.
///
/// Default for v1 is [`UndoHorizon::Turn`] — the most permissive horizon
/// that still bounds replay cost. Sessions that want stricter or looser
/// behavior change this on `SessionConfig`. Permissions (who is allowed
/// to *invoke* undo) are gated separately by `UndoRequested` filter
/// handlers; the horizon only controls what is *reachable*.
///
/// `floor_seq()` (Phase B2) translates the variant into a concrete
/// log-sequence floor by consulting structural log tags.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum UndoHorizon {
    /// Undo button does nothing.
    Disabled,
    /// Most-recent player decision only — depth-1 stack. Newbie-safe.
    LastAction,
    /// Current turn only. **Default for v1.**
    Turn,
    /// Current round (event-level for current turn; turn-level for past turns).
    ///
    /// **v1 caveat:** plugins do not yet emit `LogTag::RoundStart`, so
    /// `floor_seq()` falls back to `0` (whole session) for this variant.
    /// Behavior tightens automatically once plugins opt in.
    Round,
    /// Current combat.
    ///
    /// **v1 caveat:** plugins do not yet emit `LogTag::CombatStart`, so
    /// `floor_seq()` falls back to `0` (whole session) for this variant.
    /// Behavior tightens automatically once plugins opt in.
    Combat,
    /// Whole session.
    Unlimited,
}

/// Who sees toast notifications when an undo executes.
///
/// Default for v1 is [`UndoNotifyVisibility::All`] — full transparency at
/// the table. Privacy-sensitive sessions (e.g. solo journaling, GM-only
/// rewinds) tighten this on `SessionConfig`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum UndoNotifyVisibility {
    /// Only the seat that invoked the undo sees the notification.
    PlayerOnly,
    /// Only GMs / narrators see the notification.
    GMOnly,
    /// The invoking seat plus all GMs / narrators see the notification.
    PlayerAndGM,
    /// Every connected client sees the notification. **Default.**
    All,
}

/// Trait the engine implements so [`UndoHorizon::floor_seq`] can consult
/// the live event log without the SDK depending on the engine crate.
///
/// Each method returns `None` when the corresponding structural tag has
/// never been emitted (e.g. the session has no recorded turn yet).
/// `floor_seq` then falls back to `0` (full session reach) — see the
/// per-variant docs on [`UndoHorizon`] for v1 caveats.
pub trait UndoHorizonHelper {
    /// Seq of the most recent `LogTag::TurnStart` entry, or `None` if
    /// no turn has started yet.
    fn current_turn_start_seq(&self) -> Option<u64>;
    /// Seq of the most recent `LogTag::RoundStart` entry, or `None`.
    fn current_round_start_seq(&self) -> Option<u64>;
    /// Seq of the most recent `LogTag::CombatStart` entry, or `None`.
    fn current_combat_start_seq(&self) -> Option<u64>;
    /// Seq of the most recent top-level player-initiated event, or `None`.
    /// "Player-initiated" is a v1 heuristic — see the engine impl for
    /// the current rules. Refine when complex event types arise.
    fn most_recent_player_decision_seq(&self) -> Option<u64>;
}

impl UndoHorizon {
    /// Earliest log seq an undo target can reference under this horizon.
    ///
    /// * Returns `u64::MAX` for [`UndoHorizon::Disabled`] (nothing reachable).
    /// * Returns `0` for [`UndoHorizon::Unlimited`] (whole session reachable).
    /// * For [`Turn`](Self::Turn) / [`Round`](Self::Round) /
    ///   [`Combat`](Self::Combat), returns the seq of the most recent
    ///   matching structural tag, or `0` if no such tag has been emitted
    ///   in this session yet (v1 caveat for Round/Combat — plugins have
    ///   not opted in to those tags). Behavior tightens automatically as
    ///   plugins start emitting them.
    /// * For [`LastAction`](Self::LastAction), returns the seq of the
    ///   most recent player-initiated top-level event, or `0` if none.
    pub fn floor_seq(self, helper: &dyn UndoHorizonHelper) -> u64 {
        match self {
            UndoHorizon::Disabled => u64::MAX,
            UndoHorizon::LastAction => {
                helper.most_recent_player_decision_seq().unwrap_or(0)
            }
            UndoHorizon::Turn => helper.current_turn_start_seq().unwrap_or(0),
            UndoHorizon::Round => helper.current_round_start_seq().unwrap_or(0),
            UndoHorizon::Combat => helper.current_combat_start_seq().unwrap_or(0),
            UndoHorizon::Unlimited => 0,
        }
    }
}

/// Default value for [`SessionConfig::undo_horizon`]. Public so future
/// `#[serde(default = "maat_sdk::config::default_undo_horizon")]`
/// annotations can reference it once `SessionConfig` becomes
/// serde-derivable.
pub fn default_undo_horizon() -> UndoHorizon { UndoHorizon::Turn }

/// Default value for [`SessionConfig::undo_notify_visibility`]. Public for
/// the same reason as [`default_undo_horizon`].
pub fn default_undo_notify_visibility() -> UndoNotifyVisibility { UndoNotifyVisibility::All }

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

    #[test]
    fn session_config_new_populates_undo_defaults() {
        let cfg = SessionConfig::new(0, 0);
        assert_eq!(cfg.undo_horizon, UndoHorizon::Turn);
        assert_eq!(cfg.undo_notify_visibility, UndoNotifyVisibility::All);
    }

    // 5650: cl1_1_creation_policy_* tests DELETED — exercised the
    // resolve_creation_policy / CreationPolicy / AdjudicateFallback
    // machinery that's been removed. Permission gating now lives in
    // core/engine/src/adjudication.rs with its own test coverage.

    #[cfg(feature = "wire")]
    #[test]
    fn undo_horizon_postcard_round_trip() {
        for value in [
            UndoHorizon::Disabled,
            UndoHorizon::LastAction,
            UndoHorizon::Turn,
            UndoHorizon::Round,
            UndoHorizon::Combat,
            UndoHorizon::Unlimited,
        ] {
            let bytes = postcard::to_allocvec(&value).expect("encode");
            let decoded: UndoHorizon = postcard::from_bytes(&bytes).expect("decode");
            assert_eq!(decoded, value);
        }
    }

    #[cfg(feature = "wire")]
    #[test]
    fn undo_notify_visibility_postcard_round_trip() {
        for value in [
            UndoNotifyVisibility::PlayerOnly,
            UndoNotifyVisibility::GMOnly,
            UndoNotifyVisibility::PlayerAndGM,
            UndoNotifyVisibility::All,
        ] {
            let bytes = postcard::to_allocvec(&value).expect("encode");
            let decoded: UndoNotifyVisibility = postcard::from_bytes(&bytes).expect("decode");
            assert_eq!(decoded, value);
        }
    }

    #[cfg(feature = "serde")]
    #[test]
    fn undo_horizon_json_round_trip_explicit_non_default() {
        let value = UndoHorizon::Combat;
        let json = serde_json::to_string(&value).expect("encode");
        let decoded: UndoHorizon = serde_json::from_str(&json).expect("decode");
        assert_eq!(decoded, value);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn undo_notify_visibility_json_round_trip_explicit_non_default() {
        let value = UndoNotifyVisibility::GMOnly;
        let json = serde_json::to_string(&value).expect("encode");
        let decoded: UndoNotifyVisibility = serde_json::from_str(&json).expect("decode");
        assert_eq!(decoded, value);
    }

    /// Fake helper used to exercise `floor_seq` without an engine dep.
    struct FakeHelper {
        turn: Option<u64>,
        round: Option<u64>,
        combat: Option<u64>,
        last: Option<u64>,
    }

    impl UndoHorizonHelper for FakeHelper {
        fn current_turn_start_seq(&self) -> Option<u64> { self.turn }
        fn current_round_start_seq(&self) -> Option<u64> { self.round }
        fn current_combat_start_seq(&self) -> Option<u64> { self.combat }
        fn most_recent_player_decision_seq(&self) -> Option<u64> { self.last }
    }

    fn fake() -> FakeHelper {
        FakeHelper { turn: Some(10), round: Some(5), combat: Some(2), last: Some(42) }
    }

    fn empty() -> FakeHelper {
        FakeHelper { turn: None, round: None, combat: None, last: None }
    }

    #[test]
    fn floor_disabled_is_max() {
        assert_eq!(UndoHorizon::Disabled.floor_seq(&fake()), u64::MAX);
    }

    #[test]
    fn floor_unlimited_is_zero() {
        assert_eq!(UndoHorizon::Unlimited.floor_seq(&fake()), 0);
    }

    #[test]
    fn floor_turn_returns_helper_value() {
        assert_eq!(UndoHorizon::Turn.floor_seq(&fake()), 10);
    }

    #[test]
    fn floor_round_returns_helper_value() {
        assert_eq!(UndoHorizon::Round.floor_seq(&fake()), 5);
    }

    #[test]
    fn floor_combat_returns_helper_value() {
        assert_eq!(UndoHorizon::Combat.floor_seq(&fake()), 2);
    }

    #[test]
    fn floor_last_action_returns_helper_value() {
        assert_eq!(UndoHorizon::LastAction.floor_seq(&fake()), 42);
    }

    #[test]
    fn floor_falls_back_to_zero_when_helper_returns_none() {
        let h = empty();
        assert_eq!(UndoHorizon::Turn.floor_seq(&h), 0);
        assert_eq!(UndoHorizon::Round.floor_seq(&h), 0);
        assert_eq!(UndoHorizon::Combat.floor_seq(&h), 0);
        assert_eq!(UndoHorizon::LastAction.floor_seq(&h), 0);
    }
}
