use std::hash::{Hash, Hasher};

/// Opaque entity handle. Identity values come from the engine's allocator
/// (`session.spawn_blueprint(...)`); plugin code should treat the inner
/// `u64` as opaque.
///
/// **CF2 §4** (2026-05-08): the tuple field is `pub(crate)` so external
/// crates cannot write `EntityId(0)` and silently collide with the
/// reserved sentinel. Explicit construction goes through
/// [`EntityId::from_raw`] (loud, deliberate); read via [`EntityId::raw`].
/// Tests, fixtures, and wire-deserialization paths use `from_raw` —
/// blocking synthetic id construction would couple the engine to its
/// own allocator and prevent plugin tests from building mock entities.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EntityId(pub(crate) u64);

impl EntityId {
    /// Construct an `EntityId` from a raw `u64`. Use this for tests,
    /// wire-deserialization, and other contexts where the id originates
    /// outside the engine's allocator. The deliberate name discourages
    /// accidental construction in production handler code.
    #[inline]
    pub const fn from_raw(n: u64) -> Self {
        Self(n)
    }

    /// Read the raw `u64`. Use for serialization, log output, and
    /// debugging. Plugin authors should treat the value as opaque.
    #[inline]
    pub const fn raw(self) -> u64 {
        self.0
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PlayerId(pub(crate) u64);

impl PlayerId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// A game-side seat: owns pieces and takes turns.
/// Distinct from PlayerId (connection identity).
/// A player may control one or more seats (e.g. for solo testing both sides).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SeatId(pub(crate) u64);

impl SeatId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// Grouping of seats (e.g. "red team" vs "blue team"). Teams are opaque to the
/// engine — plugins define them via entity ownership or external registries.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TeamId(pub(crate) u64);

impl TeamId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// Opaque identifier for a "combat" or other plugin-defined structural episode.
///
/// The engine treats this as a transparent newtype around `u64`. Plugins assign
/// IDs and emit `LogTag::CombatStart`/`CombatEnd` to anchor undo scopes.
/// The engine does not model combat semantically — it just scopes log
/// navigation/undo against the most recent matching tag.
///
/// See `docs/superpowers/specs/2026-05-01-uc-08-undo-with-configurable-scope.md`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CombatId(pub(crate) u64);

impl CombatId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// How the client should present the board (pan/zoom policy).
///
/// Declared per game plugin via `ModelPlugin::board_fit()`. The client
/// combines this with runtime signals (loaded-map dimensions, grid extent,
/// touch vs. mouse input) to decide whether to enable pan/zoom.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BoardFit {
    /// Board always fits the canvas; pan/zoom off on desktop. Chess, checkers.
    /// Touch devices may still enable pinch-zoom for accessibility.
    Fixed,
    /// Content routinely exceeds the canvas; pan/zoom on always. TTRPG maps,
    /// wargames, large hex boards.
    Explorable,
    /// Infer from content at runtime. Enabled if a loaded map is larger than
    /// the canvas, or the grid/hex extent at a minimum legible cell size
    /// doesn't fit, or the device is touch-primary. Default.
    #[default]
    Auto,
}

/// Ownership of an entity: a specific seat, a team, or no one.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Owner {
    Seat(SeatId),
    Team(TeamId),
    /// Un-owned (neutral pieces, board fixtures, commodities).
    Neutral,
}

impl Owner {
    /// Returns the owning seat, if any. `Team`-owned returns `None`.
    pub fn as_seat(&self) -> Option<SeatId> {
        match self {
            Owner::Seat(s) => Some(*s),
            _ => None,
        }
    }

    /// Returns the owning team, if any.
    pub fn as_team(&self) -> Option<TeamId> {
        match self {
            Owner::Team(t) => Some(*t),
            _ => None,
        }
    }

    /// True if this owner is the given seat.
    pub fn is_seat(&self, seat: SeatId) -> bool {
        matches!(self, Owner::Seat(s) if *s == seat)
    }

    /// True if this owner is the given team.
    pub fn is_team(&self, team: TeamId) -> bool {
        matches!(self, Owner::Team(t) if *t == team)
    }

    pub fn is_owned(&self) -> bool {
        !matches!(self, Owner::Neutral)
    }

    pub fn is_neutral(&self) -> bool {
        matches!(self, Owner::Neutral)
    }
}

impl From<SeatId> for Owner {
    fn from(s: SeatId) -> Self { Owner::Seat(s) }
}

impl From<TeamId> for Owner {
    fn from(t: TeamId) -> Self { Owner::Team(t) }
}

/// Convenience: `Option<SeatId>` → `Owner` (None = Neutral).
impl From<Option<SeatId>> for Owner {
    fn from(o: Option<SeatId>) -> Self {
        match o {
            Some(s) => Owner::Seat(s),
            None => Owner::Neutral,
        }
    }
}

/// Stable identifier for an entity type. The numeric value is meaningful
/// only relative to the `(canonical_id, entity_name)` pair that produced
/// it via [`entity_type_id_for`].
///
/// **Save-format invariant (TN1):** the entity-name string is part of
/// the persisted save format. Renaming an entity (e.g. `BarbarianClass`
/// → `Barbarian`) changes its `EntityTypeId` hash and breaks save
/// compatibility — old saves that reference the entity by id will fail
/// to resolve. If a rename is required, register a deprecation alias
/// that retains the old id rather than letting the hash drift.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EntityTypeId(pub(crate) u64);

impl EntityTypeId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// Derive a deterministic `EntityTypeId` from a plugin's canonical identifier
/// and the entity's local Rust type name.
///
/// Uses FNV-1a 64-bit over `canonical_id + NUL + entity_name`. The NUL byte
/// separator prevents `("ab", "c")` from aliasing `("a", "bc")`. Two entities
/// from different plugins with the same local name receive different IDs;
/// renaming an entity changes its ID (a rename is a breaking change for save
/// files that reference entity names).
///
/// `const fn` — usable in `const` and `static` contexts:
/// ```ignore
/// const MY_TYPE: EntityTypeId =
///     entity_type_id_for("github.com/owner/my-plugin", "Character");
/// ```
pub const fn entity_type_id_for(canonical_id: &str, entity_name: &str) -> EntityTypeId {
    EntityTypeId(fnv1a64_concat(canonical_id.as_bytes(), entity_name.as_bytes()))
}

#[allow(clippy::indexing_slicing)] // const fn: index is loop-bounded (`while i < a/b.len()`)
const fn fnv1a64_concat(a: &[u8], b: &[u8]) -> u64 {
    const BASIS: u64 = 14695981039346656037;
    const PRIME: u64 = 1099511628211;
    let mut hash = BASIS;
    let mut i = 0;
    while i < a.len() {
        hash ^= a[i] as u64;
        hash = hash.wrapping_mul(PRIME);
        i += 1;
    }
    // NUL separator prevents ("ab","c") == ("a","bc")
    hash ^= 0u64;
    hash = hash.wrapping_mul(PRIME);
    let mut j = 0;
    while j < b.len() {
        hash ^= b[j] as u64;
        hash = hash.wrapping_mul(PRIME);
        j += 1;
    }
    hash
}

/// A collision detected between two entity type names sharing the same
/// `EntityTypeId`. Returned by [`find_type_id_conflicts`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeIdConflict {
    pub type_id: EntityTypeId,
    /// Lexicographically first of the two colliding names.
    pub name_a: String,
    /// Lexicographically second of the two colliding names.
    pub name_b: String,
}

/// Scan a set of `(entity_name, type_id)` pairs and return every collision
/// (two distinct names that share the same numeric ID). Catches accidental
/// hand-assigned range overlaps at plugin-load time rather than at gameplay time.
///
/// Results are sorted by `type_id` for deterministic test assertions.
pub fn find_type_id_conflicts<S: AsRef<str>>(
    declarations: &[(S, EntityTypeId)],
) -> Vec<TypeIdConflict> {
    use std::collections::HashMap;
    let mut seen: HashMap<EntityTypeId, String> = HashMap::new();
    let mut conflicts = Vec::new();
    for (name, id) in declarations {
        let name = name.as_ref().to_string();
        if let Some(prev) = seen.get(id) {
            let (name_a, name_b) = if prev.as_str() <= name.as_str() {
                (prev.clone(), name)
            } else {
                (name, prev.clone())
            };
            conflicts.push(TypeIdConflict { type_id: *id, name_a, name_b });
        } else {
            seen.insert(*id, name);
        }
    }
    conflicts.sort_by_key(|c| c.type_id);
    conflicts
}

/// Phase identifier — `"plugin:phase_name"` form. Compared and hashed
/// by the inner `&'static str`; treat as opaque.
///
/// **CF2 §2 (revised 2026-05-08):** the inner field is `pub(crate)`.
/// External crates construct via [`PhaseId::new`] (a `const fn` with a
/// `':'` namespace assert) and read via [`PhaseId::as_str`]. The bare
/// `PhaseId::new("test:play")` literal — which silently bypassed the namespace
/// convention — no longer compiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PhaseId(pub(crate) &'static str);

impl PhaseId {
    /// Construct a `PhaseId`. The string MUST contain `':'` —
    /// the convention is `"plugin:phase_name"`. A non-namespaced
    /// argument produces a const-eval panic at the call site.
    ///
    /// ```ignore
    /// // ✅ compiles
    /// pub const PLAY: PhaseId = PhaseId::new("checkers:play");
    /// // ❌ const-eval panic: "PhaseId must be namespaced (\"plugin:name\")"
    /// pub const BAD: PhaseId = PhaseId::new("test:play");
    /// ```
    pub const fn new(s: &'static str) -> Self {
        assert!(
            contains_byte(s.as_bytes(), b':'),
            "PhaseId must be namespaced (\"plugin:name\")",
        );
        PhaseId(s)
    }

    /// Read the inner `&'static str` — for serialization, log output,
    /// debug formatting. Plugin authors should treat the value as
    /// opaque otherwise.
    #[inline]
    pub const fn as_str(self) -> &'static str {
        self.0
    }
}

/// Const-time substring check for a single byte. Used by
/// [`PhaseId::new`] / [`PoolId::new`] (the standard library's
/// `contains` is not `const`-stable as of rustc 1.84).
#[allow(
    clippy::indexing_slicing,
    reason = "const-fn byte search; `i < bytes.len()` loop bound \
              guarantees bytes[i] is in range. `.get(i)` is not \
              const-stable for slices, so the indexed form is the \
              only available option in const context."
)]
const fn contains_byte(bytes: &[u8], target: u8) -> bool {
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == target { return true; }
        i += 1;
    }
    false
}

#[cfg(feature = "serde")]
impl serde::Serialize for PhaseId {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.0)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PhaseId {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        // Leak the string to obtain a `'static` reference.
        // PhaseIds are few in number (one per phase per plugin) so this is acceptable.
        let leaked: &'static str = Box::leak(s.into_boxed_str());
        Ok(PhaseId(leaked))
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TraitId(pub u16);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(pub(crate) u64);

impl NodeId {
    #[inline] pub const fn from_raw(n: u64) -> Self { Self(n) }
    #[inline] pub const fn raw(self) -> u64 { self.0 }
}

/// String-backed identifier with stable key and human-readable display.
/// Compared and hashed by key only; display can change freely.
///
/// CF2-4: `key` is `Cow<'static, str>`. Plugin code uses
/// [`PoolId::const_new`] for static literals — that constructor is a
/// `const fn` with a compile-time `':'` namespacing check, so a
/// non-namespaced `"hp"` fails at const-eval time before binary
/// emission. Wire-deserialization and runtime-built keys land as
/// `Cow::Owned(String)` via [`PoolId::from_owned`].
///
/// **CF2-4** (2026-05-08, revised): the canonical constructor
/// [`PoolId::new`] is a `const fn` over `&'static str` that asserts on
/// the `':'` namespace separator at const-eval time. Plugin authors
/// writing `PoolId::new("hp", "HP")` (un-namespaced) get a compile-
/// error, not a runtime variant — the silent-namespace-collision
/// footgun is closed at the common-case API name. Runtime / wire-
/// deserialized keys land via [`PoolId::from_owned`], a deliberately
/// distinct entry point with no compile-time check.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone)]
pub struct PoolId {
    pub(crate) key: std::borrow::Cow<'static, str>,
    pub(crate) display: std::borrow::Cow<'static, str>,
}

impl PoolId {
    /// CF2-4 const constructor with compile-time namespace check.
    ///
    /// Both arguments are `&'static str`. The `key` MUST contain `':'`
    /// — the convention is `"plugin:pool_name"`. A non-namespaced key
    /// produces a const-eval panic at the call site, surfaced by rustc
    /// as `error[E0080]: evaluation of constant value failed`.
    ///
    /// ```ignore
    /// // ✅ compiles
    /// pub fn pool_hp() -> PoolId { PoolId::new("myplugin:counter", "Counter") }
    /// // ❌ const-eval panic at the call site
    /// pub fn bad() -> PoolId { PoolId::new("hp", "HP") }
    /// ```
    pub const fn new(key: &'static str, display: &'static str) -> Self {
        assert!(
            contains_byte(key.as_bytes(), b':'),
            "PoolId key must be namespaced: use \"plugin:pool_name\"",
        );
        Self {
            key: std::borrow::Cow::Borrowed(key),
            display: std::borrow::Cow::Borrowed(display),
        }
    }

    /// Runtime constructor for keys assembled at runtime (per-level
    /// spell slots: `format!("myplugin:slot:{}", level)`) or
    /// received over the wire from server-built state. No compile-time
    /// namespacing check — the deliberate name signals that the caller
    /// is responsible for ensuring the key is well-formed.
    pub fn from_owned(
        key: impl Into<std::borrow::Cow<'static, str>>,
        display: impl Into<std::borrow::Cow<'static, str>>,
    ) -> Self {
        Self { key: key.into(), display: display.into() }
    }

    /// Read the key as a `&str`. Plugin code should treat this as the
    /// stable identity comparator (eq/hash compare by key).
    #[inline]
    pub fn key(&self) -> &str {
        self.key.as_ref()
    }

    /// Read the display label as a `&str`.
    #[inline]
    pub fn display(&self) -> &str {
        self.display.as_ref()
    }
}

impl PartialEq for PoolId {
    fn eq(&self, other: &Self) -> bool {
        self.key == other.key
    }
}

impl Eq for PoolId {}

impl Hash for PoolId {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.key.hash(state);
    }
}

// Intentionally NOT #[non_exhaustive]: SpatialIndex (engine) matches
// exhaustively to bucket positions. Adding a new variant must force
// every spatial-aware system to handle it.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Position {
    /// Entity sits at a graph node.
    Node(crate::board::BoardNodeId),
    /// Entity sits on a graph edge (e.g. a road or bridge mid-point).
    Edge(crate::board::EdgeId),
    /// Entity occupies a graph face (e.g. a hex tile, a room).
    Face(crate::board::FaceId),
}

// AR-1 (2026-05-04): the `Position::grid(col, row)` constructor was
// REMOVED. It hardcoded stride 8 in its body (`Face((row * 8) + col)`),
// causing silent miscomputed distances on any non-8×8 grid. The
// architectural commitment is now: `BoardGraph` is the spatial source
// of truth.
//
// Replacement patterns:
//   - Spatially meaningful code/test → set up a `BoardGraph`, then use
//     `bg.face_at_cartesian(col, row).unwrap()`. The builder
//     `BoardBuilder::square_grid(cols, rows)` registers Cartesian face
//     coords automatically so this lookup works O(1).
//   - Placeholder Position with no spatial meaning → use
//     `Position::Face(FaceId(N))` with any unique `N`.
//
// The mechanical migration applied at AR-1 time was
// `Position::grid(c, r) → Position::Face(FaceId(((r * 8 + c) as u32)))`,
// which preserves bit-identity for legacy 8×8 callers. Sites that needed
// graph-aware semantics were promoted in the same sweep.

impl Position {
    /// Construct a `Position::Face` from grid coordinates with an explicit
    /// row-major stride. Filed as **PA-CG1-4** in the BACKLOG.
    ///
    /// **Prefer `BoardGraph::face_at_cartesian(col, row)` for live game
    /// state** — it stays in sync with the registered board topology.
    /// Use this helper for synthetic-position constructions in tests,
    /// fixtures, or initial-setup paths where a `BoardGraph` is not yet
    /// in hand. The explicit `cols` parameter sidesteps the hardcoded-
    /// stride footgun that motivated AR-1's removal of `Position::grid`.
    ///
    /// ```
    /// use maat_sdk_types::types::Position;
    /// use maat_sdk_types::board::FaceId;
    ///
    /// // 8×8 grid, top-left
    /// assert_eq!(Position::face_in_grid(0, 0, 8), Position::Face(FaceId(0)));
    /// // 8×8 grid, (col=3, row=2) on a row-major stride
    /// assert_eq!(Position::face_in_grid(3, 2, 8), Position::Face(FaceId(2 * 8 + 3)));
    /// ```
    pub const fn face_in_grid(col: u32, row: u32, cols: u32) -> Self {
        Self::Face(crate::board::FaceId(row * cols + col))
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiceSpec {
    pub count: u32,
    pub sides: u32,
    pub modifier: i32,
    pub tag: String,
    pub exploding: bool,
    pub wild_die: Option<u32>,
}

impl DiceSpec {
    pub fn new(count: u32, sides: u32, tag: impl Into<String>) -> Self {
        Self { count, sides, modifier: 0, tag: tag.into(), exploding: false, wild_die: None }
    }

    pub fn with_modifier(mut self, m: i32) -> Self { self.modifier = m; self }
    pub fn with_exploding(mut self) -> Self { self.exploding = true; self }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
    pub a: u8,
}

/// Semantic, pack-agnostic asset reference.
///
/// `name` is the primary identifier in a flat global namespace. Any pack can
/// provide an asset with a given name. The resolver walks a priority chain at
/// lookup time:
///
/// 1. Exact-name match in the catalog.
/// 2. Best-match against the *entity's own* tags (e.g. `FeaturedData.tags`) —
///    the author gets fallback for free without writing anything extra.
/// 3. Best-match against this tag's `fallback_tags` — the opt-in escape hatch
///    for when entity context isn't expressive enough (rare).
/// 4. Placeholder icon + director-visible warning.
///
/// Typical call sites stay one line: `AssetTag::new("goblin-01")`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone)]
pub struct AssetTag {
    pub name: String,
    /// Extra descriptors the resolver may match against, ordered by preference.
    /// Default empty; the plugin author's entity tags are consulted first.
    ///
    /// NOTE: `skip_serializing_if = "Vec::is_empty"` was REMOVED here on
    /// 2026-04-26 because it broke postcard wire compat. postcard is
    /// positional binary — skipping a field on encode produces fewer
    /// bytes than the decoder reads, surfacing as
    /// "Hit the end of buffer, expected more data" on every frame
    /// containing an AssetTag with empty fallback_tags (every audio
    /// upload, most token spawns, etc.). `default` stays for JSON
    /// tolerance of missing fields; postcard always encodes the empty
    /// Vec as a 0-length prefix and decodes it round-trip.
    #[cfg_attr(feature = "serde", serde(default))]
    pub fallback_tags: Vec<String>,
}

// Identity is by `name` only — two refs naming the same asset are equal
// regardless of the hint metadata they carry. This keeps theme-remap and
// catalog lookups keyed by name.
impl PartialEq for AssetTag {
    fn eq(&self, other: &Self) -> bool { self.name == other.name }
}
impl Eq for AssetTag {}
impl std::hash::Hash for AssetTag {
    fn hash<H: std::hash::Hasher>(&self, h: &mut H) { self.name.hash(h) }
}

impl AssetTag {
    pub fn new(name: impl Into<String>) -> Self {
        Self { name: name.into(), fallback_tags: Vec::new() }
    }

    /// Author-side escape hatch: attach extra fallback descriptors to a ref.
    /// Only reach for this when the entity's own tags can't describe the
    /// resolution intent.
    pub fn with_fallback(mut self, tag: impl Into<String>) -> Self {
        self.fallback_tags.push(tag.into());
        self
    }

    pub fn with_fallbacks<I, S>(mut self, tags: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.fallback_tags.extend(tags.into_iter().map(Into::into));
        self
    }

    /// Back-compat accessor for call sites that used to read `AssetTag(String)` via `.0`.
    pub fn as_str(&self) -> &str { &self.name }

    /// Y2.2 -- wildcard constructor. The trailing `*` is the wildcard
    /// sentinel; resolvers (e.g. `CatalogFixture` in the engine) MUST
    /// treat `AssetTag` whose name ends in `*` as a **prefix match**:
    /// collect every asset whose name starts with the portion before
    /// the `*`, then pick one deterministically from the entity id
    /// (`hash(entity_id) % matches.len()`).
    ///
    /// `AssetTag::wildcard("goblin_")` produces an asset tag named
    /// `"goblin_*"` that matches any of `goblin_01`, `goblin_02`,
    /// `goblin_skirmisher`, etc. The same entity always resolves to
    /// the same concrete asset across reloads.
    ///
    /// Reach for this only when the spawner doesn't care which
    /// specific asset it gets -- typically NPC mob spawns where any
    /// "goblin" sprite is acceptable.
    pub fn wildcard(prefix: impl Into<String>) -> Self {
        let mut name = prefix.into();
        if !name.ends_with('*') { name.push('*'); }
        Self { name, fallback_tags: Vec::new() }
    }

    /// Y2.2 -- `true` iff this tag is a wildcard (name ends in `*`).
    pub fn is_wildcard(&self) -> bool {
        self.name.ends_with('*')
    }

    /// Y2.2 -- for a wildcard tag, return the prefix (everything before
    /// the trailing `*`). For a non-wildcard tag, returns the full
    /// name. Empty prefix (`AssetTag::wildcard("")` -> `"*"`) matches
    /// every asset.
    pub fn wildcard_prefix(&self) -> &str {
        self.name.strip_suffix('*').unwrap_or(&self.name)
    }

    /// Pick one asset from `candidates` using `seed` (typically an entity's raw id)
    /// as the selection key. The same `(seed, candidates)` pair always resolves to
    /// the same asset — deterministic wildcard selection without storing all variants
    /// on the wire format. Returns `None` when `candidates` is empty.
    ///
    /// Typical usage in a view plugin render closure:
    /// ```no_run
    /// # let entity_id_raw: u64 = 0;
    /// # use maat_sdk_types::types::AssetTag;
    /// let asset = AssetTag::wildcard_for(entity_id_raw, &["goblin-01", "goblin-02", "goblin-03"])
    ///     .unwrap_or_else(|| AssetTag::new("goblin-default"));
    /// ```
    pub fn wildcard_for(seed: u64, candidates: &[impl AsRef<str>]) -> Option<Self> {
        if candidates.is_empty() {
            return None;
        }
        let idx = (seed as usize) % candidates.len();
        candidates.get(idx).map(|c| Self::new(c.as_ref()))
    }
}

impl std::fmt::Display for AssetTag {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.name)
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AssetRef(pub String);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct VoteId(pub u64);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AuctionId(pub u64);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DecisionId(pub u64);

/// UC-08-F4 — unique identity for a boundary `LogTag` (TurnStart/TurnEnded
/// /RoundStart/RoundEnd/CombatStart/CombatEnd/PhaseTransition).
///
/// Snapshots anchor to `TagId` (immutable across undo cycles), not to
/// sequence numbers (mutable across undo cycles). Allows the
/// `SnapshotManager` to keep tag-anchored snapshots alive after
/// `clear_after(seq)`, dramatically reducing recomputation on undo.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TagId(pub uuid::Uuid);

impl TagId {
    /// Generate a fresh random TagId (UUID v4). Collisions are
    /// astronomically unlikely.
    #[allow(clippy::new_without_default)]
    pub fn new() -> Self {
        Self(uuid::Uuid::new_v4())
    }

    /// The nil TagId — useful as a placeholder in tests where the value
    /// isn't observed.
    pub const fn nil() -> Self {
        Self(uuid::Uuid::nil())
    }
}

// ---------------------------------------------------------------------------
// S17 — Tags: boolean entity state markers
// ---------------------------------------------------------------------------

/// String-keyed boolean state markers on entities.
///
/// # Design: why strings, not bitflags?
///
/// `TagSet` is `BTreeSet<String>` rather than a `bitflags!`-style `u64`. The
/// per-`has()` cost is one string comparison instead of one bit-test, but the
/// design buys two properties that matter more for a plugin marketplace:
///
/// 1. **No central bit registry.** Plugin authors invent tags freely; two
///    unrelated plugins using `"is_stunned"` mean the same thing without
///    coordination. A bitflags model would require allocating bits across
///    plugin authors that have never met.
/// 2. **Save-format stability.** Tags appear in the immutable event log
///    (`TagAdded { tag: String }`). String tags survive plugin renumbering;
///    bit positions would not.
///
/// The cost is real on hot paths. Use [`TagSet::contains_any`] /
/// [`TagSet::contains_all`] to amortise k lookups in one call. If your
/// handler runs millions of times per game and tag-checking dominates,
/// mirror the relevant tags into a plugin-side `bitflags!` cached at spawn
/// time — the centralisation is acceptable inside a plugin.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TagSet(std::collections::BTreeSet<String>);

impl TagSet {
    pub fn new() -> Self { Self::default() }
    pub fn has(&self, tag: &str) -> bool { self.0.contains(tag) }
    pub fn add(&mut self, tag: &str) { self.0.insert(tag.to_string()); }
    pub fn remove(&mut self, tag: &str) { self.0.remove(tag); }
    pub fn is_empty(&self) -> bool { self.0.is_empty() }
    pub fn iter(&self) -> impl Iterator<Item = &String> { self.0.iter() }

    /// Returns true iff at least one of the given tags is present.
    /// Empty needle slice → false (vacuously, "any of nothing" is false).
    pub fn contains_any(&self, needles: &[&str]) -> bool {
        needles.iter().any(|t| self.0.contains(*t))
    }

    /// Returns true iff every given tag is present.
    /// Empty needle slice → true (vacuously, "all of nothing" is true).
    pub fn contains_all(&self, needles: &[&str]) -> bool {
        needles.iter().all(|t| self.0.contains(*t))
    }
}

#[cfg(test)]
mod tc1_tag_set_tests {
    use super::TagSet;

    fn make(tags: &[&str]) -> TagSet {
        let mut s = TagSet::new();
        for t in tags { s.add(t); }
        s
    }

    #[test]
    fn contains_any_first_match() {
        assert!(make(&["a", "b", "c"]).contains_any(&["a", "x", "y"]));
    }

    #[test]
    fn contains_any_last_match() {
        assert!(make(&["a", "b", "c"]).contains_any(&["x", "y", "c"]));
    }

    #[test]
    fn contains_any_empty_needles_is_false() {
        assert!(!make(&["a", "b"]).contains_any(&[]));
    }

    #[test]
    fn contains_any_no_match() {
        assert!(!make(&["a", "b"]).contains_any(&["x", "y", "z"]));
    }

    #[test]
    fn contains_all_returns_true_when_all_present() {
        assert!(make(&["a", "b", "c"]).contains_all(&["a", "b"]));
    }

    #[test]
    fn contains_all_returns_false_when_one_missing() {
        assert!(!make(&["a", "b"]).contains_all(&["a", "z"]));
    }

    #[test]
    fn contains_all_empty_needles_is_vacuously_true() {
        assert!(make(&["a"]).contains_all(&[]));
    }
}

// ---------------------------------------------------------------------------
// S12 — TurnState: skip / extra-turn state per seat
// ---------------------------------------------------------------------------

// CC5: plugins read session.turn_state(seat) and match on TurnState; new skip/extra shapes are likely.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum TurnState {
    #[default]
    Normal,
    Skipped { turns_remaining: u32 },
    Extra   { actions_remaining: u32 },
}

// ---------------------------------------------------------------------------
// S15 — Zone types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ZoneFace { FaceDown, FaceUp, OwnerFacing }

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ZoneTopology {
    Ordered { face: ZoneFace },
    Unordered,
    Stack,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ZoneVisibility { Public, OwnerOnly, Hidden, CountPublic }

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct ZoneConfig {
    pub topology:   ZoneTopology,
    pub visibility: ZoneVisibility,
    pub capacity:   Option<u32>,
}

// ---------------------------------------------------------------------------
// S16 — GroupDecision types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum DecisionInput {
    Choice  { options: Vec<String> },
    Bid     { pool: PoolId, min: u32 },
    Numeric { min: i32, max: i32 },
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum DecisionResolution {
    Plurality, PluralityOrSuspend, HighestBid, Auction, Average, HandlerDriven,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum DecisionClose { AllSubmitted, HandlerDriven }

// CC5: plugins read submitted DecisionInputValues in on_vote_resolved handlers.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum DecisionInputValue { Choice(usize), Bid(i64), Numeric(i32) }

// CC5: plugins match on DecisionOutcome in on_vote_resolved_react to apply game effects.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum DecisionOutcome {
    Choice { winner: usize },
    Bid    { winner: SeatId, amount: i64 },
    Tie,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct GroupDecisionConfig {
    pub input:        DecisionInput,
    pub close:        DecisionClose,
    pub resolution:   DecisionResolution,
    pub participants: Vec<SeatId>,
}

// ---------------------------------------------------------------------------
// S14 — Simultaneous play types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloseCondition { AllSubmitted, HandlerDriven }

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum ResolutionStrategy {
    Simultaneous,
    Ordered,
    SealedBid { pool: PoolId },
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmissionVisibility { Sealed, Open, Private }

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct SubmissionPool {
    pub close:      CloseCondition,
    pub resolution: ResolutionStrategy,
    pub visibility: SubmissionVisibility,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RollerId {
    Player(PlayerId),
    ActivePlayer,
    AllPlayers,  // one DiceRolled per player; for initiative
    Director,
    Host,        // always automated, ignores DiceSource session config
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiceSource {
    HostRng,
    PlayerInput,
}

#[cfg(test)]
mod asset_tag_tests {
    use super::AssetTag;
    use std::collections::HashMap;

    #[test]
    fn identity_is_by_name_only() {
        let a = AssetTag::new("goblin-01");
        let b = AssetTag::new("goblin-01").with_fallback("humanoid");
        assert_eq!(a, b, "fallback_tags must not affect equality");
    }

    #[test]
    fn hash_is_by_name_only() {
        // Two tags that share a name must collide as HashMap keys — otherwise
        // theme-remap lookups would miss when the caller attaches fallbacks.
        let mut map: HashMap<AssetTag, &'static str> = HashMap::new();
        map.insert(AssetTag::new("goblin-01"), "first");
        map.insert(AssetTag::new("goblin-01").with_fallback("humanoid"), "second");
        assert_eq!(map.len(), 1);
        assert_eq!(map[&AssetTag::new("goblin-01")], "second");
    }

    #[test]
    fn different_names_are_unequal() {
        assert_ne!(AssetTag::new("goblin-01"), AssetTag::new("goblin-02"));
    }

    #[test]
    fn fluent_fallback_accumulates() {
        let t = AssetTag::new("whispering-gulch-goblin")
            .with_fallback("goblin")
            .with_fallback("small");
        assert_eq!(t.fallback_tags, vec!["goblin".to_string(), "small".to_string()]);
    }

    #[test]
    fn with_fallbacks_bulk() {
        let t = AssetTag::new("sword").with_fallbacks(["weapon", "martial"]);
        assert_eq!(t.fallback_tags.len(), 2);
    }

    // Y2.2 — wildcard_for
    #[test]
    fn y2_2_a_wildcard_picks_first_on_seed_zero() {
        let tag = AssetTag::wildcard_for(0, &["a", "b", "c"]);
        assert_eq!(tag, Some(AssetTag::new("a")));
    }

    #[test]
    fn y2_2_b_wildcard_wraps_modulo() {
        // seed 5, len 3 → 5 % 3 = 2 → "c"
        let tag = AssetTag::wildcard_for(5, &["a", "b", "c"]);
        assert_eq!(tag, Some(AssetTag::new("c")));
    }

    #[test]
    fn y2_2_c_empty_candidates_returns_none() {
        let tag = AssetTag::wildcard_for(0, &[""; 0]);
        assert_eq!(tag, None);
    }

    #[test]
    fn y2_2_d_same_seed_and_candidates_always_same() {
        let candidates = ["goblin-01", "goblin-02", "goblin-03"];
        let t1 = AssetTag::wildcard_for(42, &candidates);
        let t2 = AssetTag::wildcard_for(42, &candidates);
        assert_eq!(t1, t2, "wildcard_for must be deterministic");
    }

    #[test]
    fn y2_2_e_different_seeds_can_produce_different_results() {
        let candidates = ["a", "b", "c"];
        let picks: std::collections::HashSet<String> = (0u64..6)
            .filter_map(|s| AssetTag::wildcard_for(s, &candidates))
            .map(|t| t.name)
            .collect();
        // 6 seeds over 3 candidates should cover all 3 variants
        assert_eq!(picks.len(), 3, "all candidates should be reachable across different seeds");
    }
}

#[cfg(test)]
mod board_position_tests {
    use crate::types::Position;
    use crate::board::{BoardNodeId, EdgeId, FaceId};

    #[test]
    fn position_node_is_constructible() {
        let p = Position::Node(BoardNodeId(7));
        assert_eq!(p, Position::Node(BoardNodeId(7)));
        assert_ne!(p, Position::Node(BoardNodeId(8)));
    }

    #[test]
    fn position_edge_distinct_from_node() {
        let n = Position::Node(BoardNodeId(1));
        let e = Position::Edge(EdgeId(1));
        assert_ne!(n, e);
    }

    #[test]
    fn position_face_distinct_from_edge() {
        let f = Position::Face(FaceId(1));
        let e = Position::Edge(EdgeId(1));
        assert_ne!(f, e);
    }

}
