//! The [`RoomCarver`] shaper pass (spec §4.7).
//!
//! A *shaper* carves open space within the regions a partitioner produced. This
//! pass consumes the placeholder [`Room`](crate::region::RegionKind::Room)
//! regions that `BspPartition` left behind — one per BSP leaf, each with its
//! `bounds` set to the leaf rect and empty `cells` — and turns each into an
//! actual room.
//!
//! Per the inter-pass data contract (see [`crate::passes`]), for every existing
//! `Room` region it:
//!
//! 1. Picks a room rectangle that fits inside the leaf, leaving a [`margin`] of
//!    cells from the leaf edge so corridors and walls have somewhere to live.
//!    The room's width and height are random within
//!    [`min_size`](RoomConfig::min_size)..=[`max_size`](RoomConfig::max_size),
//!    capped to what the margin-shrunk leaf can hold.
//! 2. Carves [`Floor`](crate::map::Tile::Floor) over that rectangle in
//!    `ctx.tiles`.
//! 3. Shrinks the region's `bounds` to the actual room rect and fills `cells`
//!    with the carved floor points (row-major order).
//!
//! A leaf too small to hold a `min_size` room — even before the margin — is
//! **skipped gracefully**: its region is left in place with empty `cells`
//! (never removed, which would break the `RegionId`-as-index invariant of
//! [`RegionId`](crate::region::RegionId)). A `Room` region with empty `cells`
//! denotes "not actually a room"; downstream room consumers (`MstConnect`,
//! `CorridorCarver`) treat only `Room` regions with non-empty `cells` as real
//! rooms, and `DoorPlacer` relies on `cells` being exactly the room interior
//! floor so it can tell room-floor from corridor-floor. In the assembled
//! dungeon recipe this skip never fires (Task 12 validates that every leaf fits
//! a room), but the pass must handle it without panicking (spec §8).
//!
//! This pass reads/writes `ctx.regions` and `ctx.tiles` only and does **not**
//! depend on `BspPartition`'s code — only on the documented "placeholder Room
//! regions" precondition, which its tests construct directly.

use serde::{Deserialize, Serialize};

use crate::geometry::{Point, Rect};
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;

/// Configuration for a [`RoomCarver`] pass.
///
/// A room's width and height are each drawn from the inclusive range
/// `min_size..=max_size`, then capped to whatever the leaf can hold after a
/// `margin` of cells is reserved on every side. The margin keeps room floors off
/// the leaf boundary so the surrounding wall — and the corridors that pierce it
/// — have room to exist.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RoomConfig {
    /// The smallest allowed room extent (in cells) on either axis. A leaf that
    /// cannot hold a `min_size`-square room (after the margin) is skipped.
    pub min_size: i32,
    /// The largest desired room extent (in cells) on either axis. The actual
    /// extent is capped to what the margin-shrunk leaf can fit.
    pub max_size: i32,
    /// Cells reserved on every side between the room and its leaf edge.
    pub margin: i32,
    /// Relative weights for the shape each room is carved in (see
    /// [`ShapeWeights`]). The primitive default is rectangle-only.
    pub shapes: ShapeWeights,
}

impl Default for RoomConfig {
    /// A sensible default for the v1 dungeon: rooms between 4 and 10 cells on a
    /// side, set in one cell from each leaf edge, carved as plain rectangles.
    fn default() -> Self {
        RoomConfig {
            min_size: 4,
            max_size: 10,
            margin: 1,
            shapes: ShapeWeights::default(),
        }
    }
}

/// Relative weights for the shape a room is carved in within its chosen
/// rectangle. Each room independently rolls a [`RoomShape`] with probability
/// proportional to these weights; a `0.0` weight disables that shape, and an
/// all-zero set falls back to a plain rectangle.
///
/// The [`Default`] is **rectangle-only** — the original behavior — so a bare
/// [`RoomCarver`] still carves rectangles. The dungeon recipe opts into a richer
/// mix via [`ShapeWeights::varied`] (see [`DungeonConfig`](crate::recipes::dungeon::DungeonConfig)).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShapeWeights {
    /// Weight of a full axis-aligned rectangle (the whole picked rect).
    pub rect: f64,
    /// Weight of an octagon — a rectangle with chamfered (cut) corners.
    pub octagon: f64,
    /// Weight of an inscribed ellipse — a rounded room.
    pub ellipse: f64,
    /// Weight of a plus/cross — central bars spanning the rect's width and height.
    pub plus: f64,
}

impl Default for ShapeWeights {
    /// Rectangle-only — the original room shape. Recipes opt into variety.
    fn default() -> Self {
        ShapeWeights {
            rect: 1.0,
            octagon: 0.0,
            ellipse: 0.0,
            plus: 0.0,
        }
    }
}

impl ShapeWeights {
    /// A balanced mix used by the dungeon recipe: rectangles stay common, with a
    /// healthy share of octagons and ellipses and the occasional plus.
    pub fn varied() -> Self {
        ShapeWeights {
            rect: 2.0,
            octagon: 1.5,
            ellipse: 1.5,
            plus: 1.0,
        }
    }
}

/// The shape a room is carved in within its chosen rectangle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RoomShape {
    /// The full rectangle (every cell).
    Rect,
    /// A rectangle with chamfered corners.
    Octagon,
    /// An inscribed ellipse.
    Ellipse,
    /// A central cross spanning the rect.
    Plus,
}

/// Below this extent on either axis a room is always a plain rectangle — a
/// shape too small to read as anything else (and to keep tiny rooms solid).
const SHAPE_MIN: i32 = 5;

/// A room-carving shaper pass (spec §4.7).
///
/// Turns each placeholder [`Room`](crate::region::RegionKind::Room) region into
/// an actual carved room. Construct one with [`RoomCarver::new`]; it implements
/// [`Pass`] with the stable name `"room_carver"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RoomCarver {
    /// The size/margin limits rooms are carved under.
    cfg: RoomConfig,
}

impl RoomCarver {
    /// Creates a room carver with the given configuration.
    pub fn new(cfg: RoomConfig) -> Self {
        RoomCarver { cfg }
    }

    /// Chooses an actual room rectangle inside `leaf`, or [`None`] if `leaf` is
    /// too small to hold a `min_size` room after reserving the margin.
    ///
    /// Randomness is drawn from `rng` in a fixed order — width, height, x-offset,
    /// y-offset — so the chosen rect is fully determined by the seed. Returning
    /// `None` draws nothing, keeping the per-region stream position predictable
    /// (a skipped leaf consumes no randomness).
    fn pick_room(&self, leaf: Rect, rng: &mut Rng) -> Option<Rect> {
        let cfg = self.cfg;

        // The placeable area: the leaf shrunk by `margin` on every side. A
        // negative or zero result means there is no interior to place a room in.
        let inner = leaf.inflate(-cfg.margin);
        if inner.w < cfg.min_size || inner.h < cfg.min_size {
            return None;
        }

        // Cap the desired max to what the inner area can actually hold, then draw
        // a size in `[min_size, cap]` inclusive (`range` is half-open, hence + 1).
        let max_w = cfg.max_size.min(inner.w);
        let max_h = cfg.max_size.min(inner.h);
        let w = rng.range(cfg.min_size, max_w + 1);
        let h = rng.range(cfg.min_size, max_h + 1);

        // Slide the room to a random position within the inner area. The offset
        // ranges keep the whole room inside `inner` (and therefore inside the
        // leaf with the margin intact).
        let x = rng.range(inner.x, inner.x + (inner.w - w) + 1);
        let y = rng.range(inner.y, inner.y + (inner.h - h) + 1);

        Some(Rect::new(x, y, w, h))
    }

    /// Rolls a [`RoomShape`] weighted by the config, drawing exactly one value
    /// from `rng`. Weights are scaled to integer "milli-weights" so the pick is
    /// a single deterministic [`Rng::range`] draw; an all-zero (or negative) set
    /// degenerates to [`RoomShape::Rect`].
    fn pick_shape(&self, rng: &mut Rng) -> RoomShape {
        let w = self.cfg.shapes;
        let table = [
            (RoomShape::Rect, w.rect),
            (RoomShape::Octagon, w.octagon),
            (RoomShape::Ellipse, w.ellipse),
            (RoomShape::Plus, w.plus),
        ];
        let scaled: [(RoomShape, i32); 4] = table.map(|(s, wt)| (s, (wt.max(0.0) * 1000.0) as i32));
        let total: i32 = scaled.iter().map(|(_, n)| n).sum();
        if total <= 0 {
            return RoomShape::Rect;
        }
        let mut pick = rng.range(0, total);
        for (s, n) in scaled {
            if pick < n {
                return s;
            }
            pick -= n;
        }
        RoomShape::Rect
    }
}

/// The interior floor cells for `shape` within `rect`, in row-major order.
///
/// Every shape is built from per-row **contiguous** column spans, which makes
/// the cell set 4-connected and convex enough that a flood-fill reaches every
/// cell from any other (so a shaped room never strands part of itself). As a
/// hard invariant the result always contains `rect.center()` — the cell
/// `MstConnect`/`CorridorCarver` wire corridors to — and a rect too small to
/// read as a shape (or, defensively, any shape that failed to cover the center)
/// falls back to the full rectangle.
fn shape_cells(rect: Rect, shape: RoomShape) -> Vec<Point> {
    if shape == RoomShape::Rect || rect.w < SHAPE_MIN || rect.h < SHAPE_MIN {
        return rect.iter().collect();
    }
    let cells = match shape {
        RoomShape::Rect => unreachable!("handled above"),
        RoomShape::Octagon => octagon_cells(rect),
        RoomShape::Ellipse => ellipse_cells(rect),
        RoomShape::Plus => plus_cells(rect),
    };
    if cells.contains(&rect.center()) {
        cells
    } else {
        rect.iter().collect()
    }
}

/// Collect row-major cells where row `r` (0-based within `rect`) spans the
/// inclusive *local* column range returned by `span(r)` — clamped to the rect.
/// A row whose `right < left` contributes nothing.
fn span_cells(rect: Rect, mut span: impl FnMut(i32) -> (i32, i32)) -> Vec<Point> {
    let mut cells = Vec::new();
    for r in 0..rect.h {
        let (l, right) = span(r);
        let lo = l.max(0);
        let hi = right.min(rect.w - 1);
        for c in lo..=hi {
            cells.push(Point::new(rect.x + c, rect.y + r));
        }
    }
    cells
}

/// A rectangle with chamfered corners: each corner has a triangular bite of
/// side `min(w, h) / 3` removed.
fn octagon_cells(rect: Rect) -> Vec<Point> {
    let (w, h) = (rect.w, rect.h);
    let cut = (w.min(h) / 3).max(1);
    span_cells(rect, |r| {
        let top = (cut - r).max(0);
        let bottom = (cut - (h - 1 - r)).max(0);
        let inset = top.max(bottom);
        (inset, w - 1 - inset)
    })
}

/// An ellipse inscribed in the rect, filled by per-row half-widths so each row
/// is one contiguous span (the center row spans the full width, so the bounding
/// box stays exactly `rect`).
fn ellipse_cells(rect: Rect) -> Vec<Point> {
    let a = rect.w as f64 / 2.0;
    let b = rect.h as f64 / 2.0;
    let ecx = (rect.w - 1) as f64 / 2.0;
    let ecy = (rect.h - 1) as f64 / 2.0;
    span_cells(rect, |r| {
        let dy = (r as f64 - ecy) / b;
        let s = 1.0 - dy * dy;
        if s < 0.0 {
            return (1, 0); // empty row
        }
        let hw = a * s.sqrt();
        ((ecx - hw).ceil() as i32, (ecx + hw).floor() as i32)
    })
}

/// A plus/cross: a vertical bar (thickness `w/3`) and a horizontal bar
/// (thickness `h/3`), both centered on `rect.center()` so the center is always
/// carved and the bars reach all four edges.
fn plus_cells(rect: Rect) -> Vec<Point> {
    let (w, h) = (rect.w, rect.h);
    let tw = (w / 3).max(1);
    let th = (h / 3).max(1);
    let cx = w / 2; // local center column, matching Rect::center
    let cy = h / 2; // local center row
    let vx0 = (cx - tw / 2).max(0);
    let vx1 = (vx0 + tw - 1).min(w - 1);
    let hy0 = (cy - th / 2).max(0);
    let hy1 = (hy0 + th - 1).min(h - 1);
    span_cells(rect, |r| {
        if r >= hy0 && r <= hy1 {
            (0, w - 1) // a full-width row through the horizontal bar
        } else {
            (vx0, vx1) // the vertical bar only
        }
    })
}

impl Pass for RoomCarver {
    fn name(&self) -> &str {
        "room_carver"
    }

    fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
        // Visit regions in index (id) order so the draw sequence is deterministic
        // and the id-as-index invariant is untouched (we mutate in place, never
        // add or remove). Only existing Room placeholders are shaped.
        for idx in 0..ctx.regions.len() {
            if ctx.regions[idx].kind != RegionKind::Room {
                continue;
            }
            let leaf = ctx.regions[idx].bounds;

            let Some(room) = self.pick_room(leaf, rng) else {
                // Leaf too small: leave the placeholder in place with empty
                // cells. Never removed — that would break RegionId-as-index.
                continue;
            };
            // Roll the room's shape and compute its interior cells. The rect is
            // picked first (above) exactly as before, so room positions/sizes —
            // and therefore the corridors wired between room centers — are
            // unchanged by shaping; only the carved interior differs.
            let shape = self.pick_shape(rng);
            let cells = shape_cells(room, shape);

            // Carve Floor over the shaped cells. Writes route through the
            // bounds-safe accessor, so any cell outside the grid is silently
            // skipped.
            for &p in &cells {
                ctx.tiles.set(p, Tile::Floor);
            }

            // Finalize the region: bounds is the picked rect (the AABB enclosing
            // the shape, whose center is guaranteed carved), and cells are
            // exactly the carved interior floor cells. DoorPlacer relies on
            // `cells` being the room interior only.
            let region = &mut ctx.regions[idx];
            region.bounds = room;
            region.cells = cells;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::blackboard::Blackboard;
    use crate::geometry::Point;
    use crate::grid::Grid;
    use crate::region::{ConnGraph, Region, RegionId};

    /// Builds a `GenContext` in this pass's required precondition: an all-`Wall`
    /// grid plus a set of placeholder `Room` regions (bounds = leaf rect, empty
    /// cells), exactly the state `BspPartition` would leave. Constructed by hand
    /// so the test depends on no other pass.
    fn ctx_with_leaves(width: u32, height: u32, leaves: &[Rect]) -> GenContext {
        let mut ctx = GenContext {
            tiles: Grid::new(width, height, Tile::Wall),
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        for &leaf in leaves {
            ctx.add_region(RegionKind::Room, leaf, Vec::new());
        }
        ctx
    }

    /// Runs `RoomCarver` with the given config/seed over `ctx`, keyed exactly as
    /// the pipeline would (`"room_carver#0"` sub-stream).
    fn run(ctx: &mut GenContext, cfg: RoomConfig, seed: u64) {
        let mut rng = Rng::from_seed(seed).fork("room_carver#0");
        RoomCarver::new(cfg).apply(ctx, &mut rng);
    }

    /// The pass identifies itself with the exact contract name.
    #[test]
    fn name_is_room_carver() {
        assert_eq!(RoomCarver::new(RoomConfig::default()).name(), "room_carver");
    }

    /// Each carved room's bounds lie within its originating leaf and within the
    /// map, and the floor cell count equals the bounds area.
    #[test]
    fn carved_rooms_fit_leaves_and_match_floor_count() {
        let cfg = RoomConfig::default();
        let leaves = [Rect::new(0, 0, 20, 16), Rect::new(20, 0, 20, 16)];
        let mut ctx = ctx_with_leaves(40, 16, &leaves);
        run(&mut ctx, cfg, 0xABCD);

        let map = Rect::new(0, 0, 40, 16);
        for (region, &leaf) in ctx.regions.iter().zip(leaves.iter()) {
            let b = region.bounds;
            // Non-empty: with these generous leaves no room is skipped.
            assert!(!region.cells.is_empty(), "expected a carved room for {leaf:?}");

            // Room rect sits inside its leaf.
            assert!(
                b.x >= leaf.x && b.y >= leaf.y && b.right() <= leaf.right() && b.bottom() <= leaf.bottom(),
                "room {b:?} escapes its leaf {leaf:?}"
            );
            // ...and inside the map.
            assert!(
                b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(),
                "room {b:?} escapes the map {map:?}"
            );

            // The cell set is exactly the bounds area, all distinct, all carved.
            assert_eq!(region.cells.len() as i32, b.w * b.h, "cell count must equal area");
            let mut unique = region.cells.clone();
            unique.sort_by_key(|p| (p.y, p.x));
            unique.dedup();
            assert_eq!(unique.len(), region.cells.len(), "cells must be distinct");
            for &p in &region.cells {
                assert!(b.contains(p), "cell {p:?} outside bounds {b:?}");
                assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cell {p:?} must be Floor");
            }
        }
    }

    /// The carved size and margin are respected: each room dimension stays within
    /// `[min_size, max_size]` and the room keeps `margin` cells from every leaf
    /// edge (so no floor extends into the margin or outside the leaf).
    #[test]
    fn size_and_margin_are_respected() {
        let cfg = RoomConfig {
            min_size: 3,
            max_size: 6,
            margin: 2,
            shapes: ShapeWeights::default(),
        };
        let leaves = [Rect::new(2, 2, 18, 18)];
        let mut ctx = ctx_with_leaves(24, 24, &leaves);
        run(&mut ctx, cfg, 0x1234_5678);

        let leaf = leaves[0];
        let b = ctx.regions[0].bounds;

        // Size within configured limits.
        assert!(b.w >= cfg.min_size && b.w <= cfg.max_size, "width {} out of range", b.w);
        assert!(b.h >= cfg.min_size && b.h <= cfg.max_size, "height {} out of range", b.h);

        // Margin honored on every side: the room sits inside the deflated leaf.
        let inner = leaf.inflate(-cfg.margin);
        assert!(
            b.x >= inner.x && b.y >= inner.y && b.right() <= inner.right() && b.bottom() <= inner.bottom(),
            "room {b:?} violates margin (inner area {inner:?})"
        );

        // No Floor tile lies outside the leaf (margin keeps the carve interior).
        for (p, &t) in ctx.tiles.iter() {
            if t == Tile::Floor {
                assert!(leaf.contains(p), "floor at {p:?} escaped leaf {leaf:?}");
            }
        }
    }

    /// A leaf smaller than `min_size` (after the margin) is skipped gracefully:
    /// its region stays in place with empty cells, no tile is carved, and the
    /// id-as-index invariant is preserved. No panic.
    #[test]
    fn undersized_leaf_is_skipped_without_panic() {
        let cfg = RoomConfig {
            min_size: 6,
            max_size: 10,
            margin: 1,
            shapes: ShapeWeights::default(),
        };
        // 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6.
        let leaves = [Rect::new(1, 1, 4, 4)];
        let mut ctx = ctx_with_leaves(8, 8, &leaves);
        run(&mut ctx, cfg, 7);

        // Region preserved, untouched, still a placeholder.
        assert_eq!(ctx.regions.len(), 1, "region must not be removed");
        assert_eq!(ctx.regions[0].id, RegionId(0), "id-as-index preserved");
        assert_eq!(ctx.regions[0].kind, RegionKind::Room);
        assert_eq!(ctx.regions[0].bounds, leaves[0], "skipped leaf keeps its bounds");
        assert!(ctx.regions[0].cells.is_empty(), "skipped leaf carries no cells");

        // Nothing was carved.
        assert!(
            ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall),
            "an undersized leaf must carve no floor"
        );
    }

    /// A mix of carvable and undersized leaves: the carvable ones become rooms,
    /// the undersized one is skipped, and every region is preserved at its index.
    #[test]
    fn mixed_leaves_carve_some_skip_others() {
        let cfg = RoomConfig {
            min_size: 4,
            max_size: 8,
            margin: 1,
            shapes: ShapeWeights::default(),
        };
        let leaves = [
            Rect::new(0, 0, 16, 16),  // big: carved
            Rect::new(16, 0, 4, 4),   // tiny: skipped
            Rect::new(0, 16, 16, 16), // big: carved
        ];
        let mut ctx = ctx_with_leaves(32, 32, &leaves);
        run(&mut ctx, cfg, 0xFEED);

        assert_eq!(ctx.regions.len(), 3, "all regions preserved");
        assert!(!ctx.regions[0].cells.is_empty(), "leaf 0 should be carved");
        assert!(ctx.regions[1].cells.is_empty(), "leaf 1 (tiny) should be skipped");
        assert!(!ctx.regions[2].cells.is_empty(), "leaf 2 should be carved");
        for (i, r) in ctx.regions.iter().enumerate() {
            assert_eq!(r.id, RegionId(i), "id must equal index");
        }
    }

    /// Determinism: the same seed/sub-stream reproduces identical carved rooms —
    /// same bounds, same cells, same tile grid.
    #[test]
    fn same_seed_yields_identical_rooms() {
        let cfg = RoomConfig::default();
        let leaves = [
            Rect::new(0, 0, 20, 20),
            Rect::new(20, 0, 20, 20),
            Rect::new(0, 20, 20, 20),
        ];

        let mut a = ctx_with_leaves(40, 40, &leaves);
        let mut b = ctx_with_leaves(40, 40, &leaves);
        run(&mut a, cfg, 0x5EED);
        run(&mut b, cfg, 0x5EED);

        let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
        let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
        assert_eq!(bounds_a, bounds_b, "same seed must reproduce the same room rects");

        let cells_a: Vec<&Vec<Point>> = a.regions.iter().map(|r| &r.cells).collect();
        let cells_b: Vec<&Vec<Point>> = b.regions.iter().map(|r| &r.cells).collect();
        assert_eq!(cells_a, cells_b, "same seed must reproduce the same cells");

        assert_eq!(a.tiles, b.tiles, "same seed must reproduce the same tile grid");
    }

    /// Different seeds (overwhelmingly) carve different rooms.
    #[test]
    fn distinct_seeds_diverge() {
        let cfg = RoomConfig::default();
        let leaves = [Rect::new(0, 0, 24, 24), Rect::new(24, 0, 24, 24)];

        let mut a = ctx_with_leaves(48, 24, &leaves);
        let mut b = ctx_with_leaves(48, 24, &leaves);
        run(&mut a, cfg, 1);
        run(&mut b, cfg, 2);

        let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
        let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
        assert_ne!(bounds_a, bounds_b);
    }

    /// Bounds-safety: a leaf whose rect extends past the grid edge must not panic.
    /// Only the in-grid portion is carved; cells are still exactly the room rect.
    #[test]
    fn leaf_partly_off_grid_does_not_panic() {
        let cfg = RoomConfig {
            min_size: 4,
            max_size: 8,
            margin: 1,
            shapes: ShapeWeights::default(),
        };
        // Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall
        // outside the grid. The carve must clip silently.
        let leaves = [Rect::new(4, 0, 12, 8)];
        let mut ctx = ctx_with_leaves(8, 8, &leaves);
        run(&mut ctx, cfg, 0xDEAD);

        let r = &ctx.regions[0];
        // The room rect is recorded faithfully even where it spills off-grid.
        assert_eq!(r.cells.len() as i32, r.bounds.w * r.bounds.h);
        // Every in-grid cell of the room rect is Floor; off-grid writes were
        // no-ops, so no panic and no spurious Floor outside the grid.
        for &p in &r.cells {
            if ctx.tiles.in_bounds(p) {
                assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor));
            }
        }
    }

    /// Non-Room regions are ignored entirely (this pass only shapes Rooms).
    #[test]
    fn non_room_regions_are_left_untouched() {
        let mut ctx = GenContext {
            tiles: Grid::new(24, 24, Tile::Wall),
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        ctx.add_region(RegionKind::Room, Rect::new(0, 0, 16, 16), Vec::new());
        let corridor_bounds = Rect::new(16, 0, 8, 2);
        ctx.regions.push(Region {
            id: RegionId(1),
            kind: RegionKind::Corridor,
            bounds: corridor_bounds,
            cells: vec![Point::new(16, 0)],
            theme: None,
        });

        run(&mut ctx, RoomConfig::default(), 99);

        // The corridor region is unchanged.
        assert_eq!(ctx.regions[1].kind, RegionKind::Corridor);
        assert_eq!(ctx.regions[1].bounds, corridor_bounds);
        assert_eq!(ctx.regions[1].cells, vec![Point::new(16, 0)]);
        // The room region was carved.
        assert!(!ctx.regions[0].cells.is_empty());
    }

    /// Each non-rectangular shape, when forced, carves a room that: stays inside
    /// its bounds (all Floor), contains its `bounds.center()` as a carved cell
    /// (the corridor-target invariant), is strictly smaller than its bounding
    /// area (genuinely not a rectangle), and is 4-connected (no stranded cells).
    #[test]
    fn forced_shapes_fit_bounds_contain_center_and_are_connected() {
        use std::collections::BTreeSet;
        let only = |octagon, ellipse, plus| ShapeWeights { rect: 0.0, octagon, ellipse, plus };
        let cases = [
            ("octagon", only(1.0, 0.0, 0.0)),
            ("ellipse", only(0.0, 1.0, 0.0)),
            ("plus", only(0.0, 0.0, 1.0)),
        ];
        for (name, weights) in cases {
            let cfg = RoomConfig { min_size: 9, max_size: 13, margin: 1, shapes: weights };
            let leaves = [Rect::new(0, 0, 20, 20)];
            let mut ctx = ctx_with_leaves(20, 20, &leaves);
            run(&mut ctx, cfg, 0x9001);
            let r = &ctx.regions[0];

            assert!(!r.cells.is_empty(), "{name}: carved nothing");
            for &p in &r.cells {
                assert!(r.bounds.contains(p), "{name}: cell {p:?} outside bounds {:?}", r.bounds);
                assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "{name}: cell {p:?} not Floor");
            }

            let center = r.bounds.center();
            assert!(r.cells.contains(&center), "{name}: center {center:?} must be carved");
            assert_eq!(ctx.tiles.get(center), Some(&Tile::Floor));

            assert!(
                (r.cells.len() as i32) < r.bounds.w * r.bounds.h,
                "{name}: a shaped room must carve fewer cells than its bounding area"
            );

            // Flood-fill from the first cell must reach every cell (4-connected).
            let set: BTreeSet<(i32, i32)> = r.cells.iter().map(|p| (p.x, p.y)).collect();
            let mut seen = BTreeSet::new();
            let mut stack = vec![(r.cells[0].x, r.cells[0].y)];
            while let Some((x, y)) = stack.pop() {
                if !set.contains(&(x, y)) || !seen.insert((x, y)) {
                    continue;
                }
                stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
            }
            assert_eq!(seen.len(), r.cells.len(), "{name}: shape must be 4-connected");
        }
    }
}
