//! The v1 **dungeon** recipe (spec §4.8): a validated five-pass pipeline.
//!
//! [`dungeon`] is the project's flagship recipe. It takes a [`DungeonConfig`],
//! validates it (spec §8), and — on success — returns a
//! [`Pipeline`](crate::pass::Pipeline) that assembles the full v1 chain in
//! order:
//!
//! ```text
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
//!   → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer
//! ```
//!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
//!    Room region per leaf).
//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which
//!    [`CaveShaper`] re-sculpts some rooms into organic cellular-automata caves
//!    (each a single connected blob; the rest stay clean polygons).
//! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus
//!    optional loop edges).
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
//!    doors (purely cosmetic — connectivity is already established by the floor).
//! 6. [`PoolDecorator`] floods organic water/lava pools into some larger rooms
//!    (a connectivity-guarded decorator — never orphans part of a room).
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
//!    finishing decorator; isolated obstacles that never break connectivity).
//! 8. [`RoomThemer`] reads the finished terrain and labels each room with a
//!    [`RegionTheme`](crate::region::RegionTheme) — a pure classifier that
//!    changes no tiles, driving the renderer's tint and the entity biases.
//! 9. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and
//!    scattered treasure and monsters — as an overlay layer (no terrain change).
//!
//! ## Why validation matters (spec §8)
//!
//! [`RoomCarver`] *skips* any leaf too small to hold a `min_size` room after its
//! `margin` is reserved (see [`crate::passes::room`]). A skipped leaf stays a
//! placeholder Room with empty `cells`, and [`MstConnect`] only connects *real*
//! rooms (non-empty `cells`) — so a skipped leaf would simply not be carved, not
//! a correctness bug, but it muddies the "every leaf is a room" contract the
//! integration tests lean on. The recipe therefore refuses, up front, any config
//! where the smallest possible leaf cannot hold a room: see
//! [`DungeonConfig::validate`] for the exact inequality. This guarantees every
//! leaf becomes a real room, so the MST spans them all and the whole dungeon is
//! connected.

use std::error::Error;
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::pass::Pipeline;
use crate::passes::{
    BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
    DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
    PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig,
};

/// Configuration for the [`dungeon`] recipe.
///
/// Composes the canvas dimensions with each pass's own config. The [`Default`]
/// produces a recognizable multi-room dungeon on a 64×40 canvas, with per-pass
/// defaults chosen to be mutually consistent so **no** BSP leaf is ever skipped
/// by [`RoomCarver`] (see [`validate`](DungeonConfig::validate)).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DungeonConfig {
    /// Canvas width in cells.
    pub width: u32,
    /// Canvas height in cells.
    pub height: u32,
    /// How the canvas is partitioned into leaf rectangles.
    pub bsp: BspConfig,
    /// How a room is carved inside each leaf.
    pub rooms: RoomConfig,
    /// How some rooms are re-sculpted into organic cellular-automata caves.
    pub caves: CaveConfig,
    /// How rooms are linked into a connectivity graph.
    pub connect: ConnectConfig,
    /// How room↔corridor pierce points are marked as doors.
    pub doors: DoorConfig,
    /// How water/lava pools are flooded into larger rooms (decorator).
    pub pools: PoolConfig,
    /// How free-standing pillars are scattered into larger rooms (decorator).
    pub pillars: PillarConfig,
    /// The relative weights of the plain (non-terrain) room themes (classifier).
    pub themes: ThemeConfig,
    /// How the dungeon is populated with entities (entrance, exit, treasure,
    /// monsters).
    pub entities: EntityConfig,
}

impl Default for DungeonConfig {
    /// A recognizable, interconnected multi-room dungeon on a 64×40 canvas.
    ///
    /// The recipe deliberately picks a richer flavor than the passes' own
    /// primitive defaults: a denser partition with larger rooms (`min_leaf 8`,
    /// `max_depth 5`, rooms `5..=11`) yields ~15–18 rooms, and
    /// `extra_edge_ratio 0.30` adds loop edges so the connectivity graph isn't a
    /// strict tree — a pure MST (the `ConnectConfig` primitive default) reads as a
    /// linear chain of rooms, which is duller to explore. `door_chance` stays at
    /// 0.5 (an even mix of doors and open archways).
    ///
    /// These stay consistent so RoomCarver never skips a leaf: the validation
    /// inequality `min_leaf - 2*margin >= min_size` holds with room to spare
    /// (`8 - 2 >= 5`). See [`validate`](DungeonConfig::validate).
    fn default() -> Self {
        DungeonConfig {
            width: 64,
            height: 40,
            bsp: BspConfig {
                min_leaf: 8,
                max_depth: 5,
            },
            rooms: RoomConfig {
                min_size: 5,
                max_size: 11,
                margin: 1,
                shapes: ShapeWeights::varied(),
            },
            caves: CaveConfig::default(),
            connect: ConnectConfig {
                extra_edge_ratio: 0.30,
            },
            doors: DoorConfig::default(),
            pools: PoolConfig::default(),
            pillars: PillarConfig::default(),
            themes: ThemeConfig::default(),
            entities: EntityConfig::default(),
        }
    }
}

/// Why a [`DungeonConfig`] was rejected at recipe construction (spec §8).
///
/// Returned by [`dungeon`] instead of panicking, so a caller (or a future
/// interactive editor) can surface the problem and let the user fix the config.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConfigError {
    /// The canvas has a zero width or height — nothing could be generated.
    ZeroDimension,
    /// `rooms.min_size` is less than 1: a room needs at least one cell.
    RoomTooSmall,
    /// `rooms.max_size` is smaller than `rooms.min_size`: the size range is
    /// empty/inverted.
    MaxSmallerThanMin,
    /// `rooms.margin` is negative: a margin reserves cells, it cannot be negative.
    NegativeMargin,
    /// The smallest possible BSP leaf cannot hold a `min_size` room after its
    /// margin is reserved — i.e. `bsp.min_leaf - 2*rooms.margin < rooms.min_size`.
    /// Some leaf would be skipped, breaking the "every leaf is a room" contract.
    RoomLargerThanLeaf,
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::ZeroDimension => {
                write!(f, "dungeon width and height must both be greater than zero")
            }
            ConfigError::RoomTooSmall => {
                write!(f, "rooms.min_size must be at least 1")
            }
            ConfigError::MaxSmallerThanMin => {
                write!(f, "rooms.max_size must be >= rooms.min_size")
            }
            ConfigError::NegativeMargin => {
                write!(f, "rooms.margin must be >= 0")
            }
            ConfigError::RoomLargerThanLeaf => write!(
                f,
                "the smallest BSP leaf cannot hold a room: require \
                 bsp.min_leaf - 2*rooms.margin >= rooms.min_size"
            ),
        }
    }
}

impl Error for ConfigError {}

impl DungeonConfig {
    /// Validates this config, returning the first [`ConfigError`] found or `Ok`.
    ///
    /// The load-bearing check is [`RoomLargerThanLeaf`](ConfigError::RoomLargerThanLeaf).
    /// [`BspPartition`] only cuts a leaf when a dimension is at least
    /// `2 * min_leaf`, keeping each resulting half at least `min_leaf` — so
    /// `bsp.min_leaf` is the *smallest* extent any leaf can have on either axis.
    /// [`RoomCarver::pick_room`] then skips a leaf when its margin-deflated
    /// interior (`leaf.inflate(-margin)`, i.e. extent `leaf - 2*margin`) is
    /// smaller than `min_size`. Substituting the worst case `leaf = min_leaf`,
    /// no leaf is skipped iff
    ///
    /// ```text
    /// min_leaf - 2*margin >= min_size
    /// ```
    ///
    /// Enforcing that here guarantees every leaf carves a real room, which is
    /// what makes the assembled dungeon fully connected (the MST spans every
    /// leaf-room).
    pub fn validate(&self) -> Result<(), ConfigError> {
        if self.width == 0 || self.height == 0 {
            return Err(ConfigError::ZeroDimension);
        }
        if self.rooms.min_size < 1 {
            return Err(ConfigError::RoomTooSmall);
        }
        if self.rooms.max_size < self.rooms.min_size {
            return Err(ConfigError::MaxSmallerThanMin);
        }
        if self.rooms.margin < 0 {
            return Err(ConfigError::NegativeMargin);
        }
        // The smallest leaf (extent `min_leaf`) must still fit a `min_size` room
        // after `margin` is reserved on every side.
        if self.bsp.min_leaf - 2 * self.rooms.margin < self.rooms.min_size {
            return Err(ConfigError::RoomLargerThanLeaf);
        }
        Ok(())
    }
}

/// Builds the v1 dungeon [`Pipeline`] from `cfg`, or a [`ConfigError`] if the
/// config is invalid (spec §4.8, §8).
///
/// On success the returned pipeline is sized to `cfg.width`×`cfg.height` and
/// holds the five passes in order: [`BspPartition`], [`RoomCarver`],
/// [`MstConnect`], [`CorridorCarver`], [`DoorPlacer`]. It carries no seed —
/// call [`Pipeline::run`] (or
/// [`run_with_snapshots`](Pipeline::run_with_snapshots)) with a seed to generate.
///
/// Validation runs first and never panics on a bad config.
pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
    cfg.validate()?;

    let pipeline = Pipeline::new(cfg.width, cfg.height)
        .then(BspPartition::new(cfg.bsp))
        .then(RoomCarver::new(cfg.rooms))
        .then(CaveShaper::new(cfg.caves))
        .then(MstConnect::new(cfg.connect))
        .then(CorridorCarver::new())
        .then(DoorPlacer::new(cfg.doors))
        .then(PoolDecorator::new(cfg.pools))
        .then(PillarPlacer::new(cfg.pillars))
        .then(RoomThemer::new(cfg.themes))
        .then(EntityPlacer::new(cfg.entities));

    Ok(pipeline)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::geometry::Point;
    use crate::map::{Map, Tile};
    use crate::region::RegionKind;
    use std::collections::{BTreeSet, VecDeque};

    /// The pass names in pipeline order, used to check snapshot labels.
    const PASS_NAMES: [&str; 10] = [
        "bsp_partition",
        "room_carver",
        "cave_shaper",
        "mst_connect",
        "corridor_carver",
        "door_placer",
        "pool_decorator",
        "pillar_placer",
        "room_themer",
        "entity_placer",
    ];

    /// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)?
    fn is_walkable(map: &Map, p: Point) -> bool {
        matches!(map.tiles.get(p), Some(&Tile::Floor) | Some(&Tile::Door))
    }

    /// The real Room regions of a map: kind == Room AND a non-empty cell set.
    /// (Empty-`cells` Rooms are leaves RoomCarver skipped; our validated config
    /// never produces any, but the helper is honest about the definition.)
    fn real_rooms(map: &Map) -> Vec<&crate::region::Region> {
        map.regions
            .iter()
            .filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty())
            .collect()
    }

    /// Flood-fills the walkable (Floor|Door) component containing `start`,
    /// returning the set of reached cells as `(x, y)` pairs.
    fn walkable_component(map: &Map, start: Point) -> BTreeSet<(i32, i32)> {
        let mut seen: BTreeSet<(i32, i32)> = BTreeSet::new();
        if !is_walkable(map, start) {
            return seen;
        }
        let mut queue: VecDeque<Point> = VecDeque::new();
        seen.insert((start.x, start.y));
        queue.push_back(start);
        while let Some(p) = queue.pop_front() {
            for n in map.tiles.neighbors4(p) {
                if is_walkable(map, n) && seen.insert((n.x, n.y)) {
                    queue.push_back(n);
                }
            }
        }
        seen
    }

    // --- Test 1: determinism ------------------------------------------------

    /// Same seed → equal `Map`s and identical ASCII renders.
    #[test]
    fn same_seed_produces_identical_dungeons() {
        let cfg = DungeonConfig::default();
        let a = dungeon(cfg).unwrap().run(42);
        let b = dungeon(cfg).unwrap().run(42);

        assert_eq!(a, b, "same seed must reproduce a byte-identical Map");
        assert_eq!(
            a.to_ascii(),
            b.to_ascii(),
            "same seed must reproduce an identical ASCII render"
        );
    }

    // --- Test 2: run == run_with_snapshots ----------------------------------

    /// `run` and `run_with_snapshots` agree on the map for a seed; there are
    /// exactly five snapshots and their labels are the five pass names in order.
    #[test]
    fn run_matches_run_with_snapshots_and_labels() {
        let cfg = DungeonConfig::default();
        let plain = dungeon(cfg).unwrap().run(7);
        let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);

        assert_eq!(plain, snapped, "snapshotting must not change the map");
        assert_eq!(snapshots.len(), 10, "one snapshot per pass (ten passes)");

        let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
        assert_eq!(
            labels,
            PASS_NAMES.to_vec(),
            "snapshot labels must be the ten pass names in pipeline order"
        );
    }

    // --- Test 3: connectivity (batch of seeds) ------------------------------

    /// Over a batch of seeds, every real Room region has at least one interior
    /// cell reachable (walking Floor|Door) from the first real room — no
    /// orphaned rooms. This is the whole-map connectivity contract.
    #[test]
    fn every_room_is_reachable_across_many_seeds() {
        let cfg = DungeonConfig::default();
        for seed in 1..=64u64 {
            let map = dungeon(cfg).unwrap().run(seed);
            let rooms = real_rooms(&map);
            assert!(
                rooms.len() >= 2,
                "seed {seed}: expected a multi-room dungeon, got {} rooms",
                rooms.len()
            );

            // Flood-fill from the first real room's first interior cell.
            let start = rooms[0].cells[0];
            let component = walkable_component(&map, start);

            for room in &rooms {
                // A room is reachable if ANY of its interior cells is in the
                // walkable component grown from the start room.
                let reachable = room
                    .cells
                    .iter()
                    .any(|c| component.contains(&(c.x, c.y)));
                assert!(
                    reachable,
                    "seed {seed}: room {:?} (bounds {:?}) is orphaned — \
                     no interior cell reachable from the start room",
                    room.id, room.bounds
                );
            }
        }
    }

    // --- Test 4: bounds -----------------------------------------------------

    /// No non-`Wall` tile lies outside the map, and every Room region's cells lie
    /// within `[0, width) × [0, height)`.
    #[test]
    fn all_floor_and_rooms_are_in_bounds() {
        let cfg = DungeonConfig::default();
        let (w, h) = (cfg.width as i32, cfg.height as i32);
        for seed in [1u64, 2, 3, 99, 12345] {
            let map = dungeon(cfg).unwrap().run(seed);

            // The grid is exactly the configured size...
            assert_eq!((map.width, map.height), (cfg.width, cfg.height));
            assert_eq!((map.tiles.width(), map.tiles.height()), (cfg.width, cfg.height));

            // ...and `iter()` only ever yields in-grid cells, so any non-Wall
            // tile is by construction in bounds. We assert it explicitly to make
            // the bounds contract visible.
            for (p, &t) in map.tiles.iter() {
                if t != Tile::Wall {
                    assert!(
                        p.x >= 0 && p.x < w && p.y >= 0 && p.y < h,
                        "seed {seed}: non-Wall tile at {p:?} is out of bounds"
                    );
                }
            }

            // Every Room region's cells are within the map.
            for room in real_rooms(&map) {
                for &c in &room.cells {
                    assert!(
                        c.x >= 0 && c.x < w && c.y >= 0 && c.y < h,
                        "seed {seed}: room {:?} cell {c:?} out of bounds",
                        room.id
                    );
                }
            }
        }
    }

    // --- Test 5: config validation ------------------------------------------

    /// Zero width and zero height each return `Err(ZeroDimension)` — never a
    /// panic.
    #[test]
    fn zero_dimensions_are_rejected() {
        let zero_w = DungeonConfig {
            width: 0,
            ..DungeonConfig::default()
        };
        let zero_h = DungeonConfig {
            height: 0,
            ..DungeonConfig::default()
        };
        assert_eq!(dungeon(zero_w).err(), Some(ConfigError::ZeroDimension));
        assert_eq!(dungeon(zero_h).err(), Some(ConfigError::ZeroDimension));
    }

    /// A `min_size` too large for `min_leaf` returns `Err(RoomLargerThanLeaf)`.
    #[test]
    fn room_larger_than_leaf_is_rejected() {
        // min_leaf 6, margin 1 => smallest interior is 6 - 2 = 4. A min_size of 5
        // cannot fit, so this must be rejected.
        let cfg = DungeonConfig {
            bsp: BspConfig {
                min_leaf: 6,
                max_depth: 4,
            },
            rooms: RoomConfig {
                min_size: 5,
                max_size: 10,
                margin: 1,
                shapes: ShapeWeights::default(),
            },
            ..DungeonConfig::default()
        };
        assert_eq!(dungeon(cfg).err(), Some(ConfigError::RoomLargerThanLeaf));
    }

    /// The exact boundary of the inequality is accepted: a config where
    /// `min_leaf - 2*margin == min_size` (the tightest legal fit) validates Ok,
    /// guarding the `>=` (not `>`) in the check. The shipped default sits safely
    /// inside this bound (`8 - 2 >= 5`), so we build a boundary config explicitly.
    #[test]
    fn boundary_config_is_accepted() {
        // 6 - 2*1 == 4 == min_size: exactly on the edge, must be Ok.
        let boundary = DungeonConfig {
            bsp: BspConfig {
                min_leaf: 6,
                max_depth: 4,
            },
            rooms: RoomConfig {
                min_size: 4,
                max_size: 10,
                margin: 1,
                shapes: ShapeWeights::default(),
            },
            ..DungeonConfig::default()
        };
        assert!(boundary.validate().is_ok());
        assert!(dungeon(boundary).is_ok());
        // The shipped default validates too.
        assert!(dungeon(DungeonConfig::default()).is_ok());
    }

    /// Other malformed configs are rejected, never panic: an inverted size range,
    /// a sub-1 min_size, and a negative margin.
    #[test]
    fn other_malformed_configs_are_rejected() {
        let inverted = DungeonConfig {
            rooms: RoomConfig {
                min_size: 8,
                max_size: 4,
                margin: 0,
                shapes: ShapeWeights::default(),
            },
            bsp: BspConfig {
                min_leaf: 20,
                max_depth: 3,
            },
            ..DungeonConfig::default()
        };
        assert_eq!(dungeon(inverted).err(), Some(ConfigError::MaxSmallerThanMin));

        let tiny = DungeonConfig {
            rooms: RoomConfig {
                min_size: 0,
                max_size: 4,
                margin: 0,
                shapes: ShapeWeights::default(),
            },
            ..DungeonConfig::default()
        };
        assert_eq!(dungeon(tiny).err(), Some(ConfigError::RoomTooSmall));

        let neg_margin = DungeonConfig {
            rooms: RoomConfig {
                min_size: 2,
                max_size: 4,
                margin: -1,
                shapes: ShapeWeights::default(),
            },
            ..DungeonConfig::default()
        };
        assert_eq!(dungeon(neg_margin).err(), Some(ConfigError::NegativeMargin));
    }

    // --- Test 6: doors ------------------------------------------------------

    /// Counts the `Door` tiles in a map.
    fn door_count(map: &Map) -> usize {
        map.tiles.iter().filter(|&(_, &t)| t == Tile::Door).count()
    }

    /// At the default door_chance (0.5) a multi-room dungeon has at least one
    /// `Door` tile and at least one connected edge with `at == Some(_)`. We scan
    /// a few seeds so the assertion is a robust regression guard, not a coin
    /// flip on one seed.
    #[test]
    fn default_dungeon_has_doors_and_attributed_edges() {
        let cfg = DungeonConfig::default();
        let mut saw_door_tile = false;
        let mut saw_attributed_edge = false;

        for seed in 1..=16u64 {
            let map = dungeon(cfg).unwrap().run(seed);
            if door_count(&map) > 0 {
                saw_door_tile = true;
            }
            if map.graph.edges().iter().any(|e| e.at.is_some()) {
                saw_attributed_edge = true;
            }
        }

        assert!(
            saw_door_tile,
            "a default-door-chance dungeon should place at least one Door tile"
        );
        assert!(
            saw_attributed_edge,
            "at least one connected edge should have at == Some(_)"
        );
    }

    /// door_chance = 0.0 yields ZERO doors yet the dungeon is STILL fully
    /// connected — proving doors are cosmetic and connectivity is via floor.
    #[test]
    fn zero_door_chance_has_no_doors_but_stays_connected() {
        let cfg = DungeonConfig {
            doors: DoorConfig { door_chance: 0.0 },
            ..DungeonConfig::default()
        };
        for seed in 1..=16u64 {
            let map = dungeon(cfg).unwrap().run(seed);
            assert_eq!(
                door_count(&map),
                0,
                "seed {seed}: door_chance=0.0 must place no doors"
            );

            // Still fully connected over Floor alone (no doors exist).
            let rooms = real_rooms(&map);
            let start = rooms[0].cells[0];
            let component = walkable_component(&map, start);
            for room in &rooms {
                assert!(
                    room.cells.iter().any(|c| component.contains(&(c.x, c.y))),
                    "seed {seed}: room {:?} orphaned with no doors — \
                     connectivity must be via floor",
                    room.id
                );
            }
        }
    }

    /// door_chance = 1.0 yields strictly more doors than door_chance = 0.5 on the
    /// same seed (the mix knob actually works). We sum across seeds to avoid a
    /// single seed where the two happen to tie.
    #[test]
    fn higher_door_chance_yields_more_doors() {
        let half = DungeonConfig {
            doors: DoorConfig { door_chance: 0.5 },
            ..DungeonConfig::default()
        };
        let full = DungeonConfig {
            doors: DoorConfig { door_chance: 1.0 },
            ..DungeonConfig::default()
        };

        let mut total_half = 0usize;
        let mut total_full = 0usize;
        for seed in 1..=16u64 {
            total_half += door_count(&dungeon(half).unwrap().run(seed));
            total_full += door_count(&dungeon(full).unwrap().run(seed));
        }
        assert!(
            total_full > total_half,
            "door_chance=1.0 ({total_full} doors) must exceed door_chance=0.5 \
             ({total_half} doors) across seeds"
        );
        // And 1.0 should place a door at every threshold, so there's at least one.
        assert!(total_full > 0, "door_chance=1.0 must place doors");
    }

    // --- Test 7: entities ---------------------------------------------------

    /// The populated default dungeon has exactly one entrance and one exit across
    /// many seeds, and every entity sits on a walkable Floor cell (never a wall,
    /// door, pool, or pillar).
    #[test]
    fn default_dungeon_is_populated_with_entities() {
        use crate::entity::EntityKind;
        let cfg = DungeonConfig::default();
        for seed in 1..=16u64 {
            let map = dungeon(cfg).unwrap().run(seed);
            let entrances = map.entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
            let exits = map.entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
            assert_eq!(entrances, 1, "seed {seed}: expected exactly one entrance");
            assert_eq!(exits, 1, "seed {seed}: expected exactly one exit");
            for e in &map.entities {
                assert_eq!(
                    map.tiles.get(e.at),
                    Some(&Tile::Floor),
                    "seed {seed}: entity {e:?} must sit on Floor"
                );
            }
        }
    }

    // --- Test 7b: room themes -----------------------------------------------

    /// Every real room is themed (none left `None`); corridors are never themed;
    /// each dungeon has *at most* one Throne and one Threshold (the exit/entrance
    /// rooms — unless terrain outranks them); and across many seeds both special
    /// themes do appear.
    #[test]
    fn default_dungeon_themes_every_room_with_special_rooms() {
        use crate::region::RegionTheme;
        let cfg = DungeonConfig::default();
        let (mut total_throne, mut total_threshold) = (0usize, 0usize);
        for seed in 1..=24u64 {
            let map = dungeon(cfg).unwrap().run(seed);

            for room in real_rooms(&map) {
                assert!(
                    room.theme.is_some(),
                    "seed {seed}: real room {:?} was left unthemed",
                    room.id
                );
            }
            // Corridors carry no theme.
            for r in map.regions.iter().filter(|r| r.kind == RegionKind::Corridor) {
                assert_eq!(r.theme, None, "seed {seed}: corridor {:?} was themed", r.id);
            }

            let count = |t: RegionTheme| real_rooms(&map).iter().filter(|r| r.theme == Some(t)).count();
            // Throne/Threshold mark single rooms — terrain may pre-empt them
            // (a lava exit reads Forge), so the count is 0 or 1, never more.
            assert!(count(RegionTheme::Throne) <= 1, "seed {seed}: more than one Throne");
            assert!(count(RegionTheme::Threshold) <= 1, "seed {seed}: more than one Threshold");
            total_throne += count(RegionTheme::Throne);
            total_threshold += count(RegionTheme::Threshold);
        }
        assert!(total_throne > 0, "no Throne appeared across 24 seeds");
        assert!(total_threshold > 0, "no Threshold appeared across 24 seeds");
    }

    /// A room flooded with lava is always a Forge, and one with water a Cistern —
    /// the terrain→theme correspondence the renderer relies on. We scan seeds with
    /// pools forced on until we see each, so the assertion is a real guard.
    #[test]
    fn lava_and_water_rooms_are_forge_and_cistern() {
        use crate::region::RegionTheme;
        let cfg = DungeonConfig {
            pools: crate::passes::PoolConfig { water_chance: 0.6, lava_chance: 0.6, min_room: 7 },
            ..DungeonConfig::default()
        };
        for seed in 1..=24u64 {
            let map = dungeon(cfg).unwrap().run(seed);
            for room in real_rooms(&map) {
                let has = |t: Tile| room.cells.iter().any(|&c| map.tiles.get(c) == Some(&t));
                if has(Tile::Lava) {
                    assert_eq!(room.theme, Some(RegionTheme::Forge), "lava room must be a Forge");
                } else if has(Tile::Water) {
                    assert_eq!(room.theme, Some(RegionTheme::Cistern), "water room must be a Cistern");
                }
            }
        }
    }

    // --- Test 8: eyeball render ---------------------------------------------

    /// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an
    /// assertion (beyond non-empty output); it documents what the recipe makes.
    #[test]
    fn print_one_dungeon_for_eyeballing() {
        let cfg = DungeonConfig::default();
        let map = dungeon(cfg).unwrap().run(2026);
        let ascii = map.to_ascii();
        println!(
            "\n--- dungeon seed=2026 {}x{} ---\n{}\n--- end dungeon ---",
            cfg.width, cfg.height, ascii
        );
        assert!(!ascii.is_empty());
    }
}
