//! The [`DoorPlacer`] placer pass (spec §4.7).
//!
//! A *placer* drops semantic markers onto an already-carved map. This is the
//! **final** pass of the v1 dungeon recipe: it reads the finished
//! [`Tile`](crate::map::Tile) grid, the [`Region`](crate::region::Region) set,
//! and the connectivity [`ConnGraph`](crate::region::ConnGraph), then marks the
//! cells where a corridor pierces a room's wall — converting a chosen fraction
//! of them into [`Door`](crate::map::Tile::Door) tiles and leaving the rest as
//! open archways.
//!
//! ## Why pierce-aware, not wall-gap (the model change)
//!
//! `CorridorCarver` carves [`Floor`](crate::map::Tile::Floor) from one room
//! center straight to another room center. That run does **not** stop at the
//! room's surrounding wall — it carves right through it. So the cell just
//! outside a room rect that *would* have been a wall is now corridor `Floor`:
//! the corridor **pierces** the wall rather than meeting it at a sealed gap.
//!
//! The previous model hunted for a `Wall` cell flanked by room floor on one
//! side and corridor floor on the other. That wall no longer exists in the real
//! pipeline (a 2000-seed experiment found ~88% of room↔corridor edges got no
//! door at all), so doors were effectively never placed.
//!
//! Connectivity does **not** depend on doors: the corridor floor already joins
//! the rooms, and a `Door` and a `Floor` are both walkable. A door here is
//! therefore a purely **cosmetic threshold marker**. This pass detects the
//! pierce points and, per cell, flips a coin with probability
//! [`door_chance`](DoorConfig::door_chance) to decide door vs. open archway —
//! so a recipe can dial in any mix without affecting reachability.
//!
//! Per the inter-pass data contract (see [`crate::passes`]), this pass:
//!
//! 1. Builds a per-cell membership lookup from the regions: for each `(x, y)`,
//!    whether some [`Room`](crate::region::RegionKind::Room) region claims it
//!    (and which room id) and whether some
//!    [`Corridor`](crate::region::RegionKind::Corridor) region claims it. Stored
//!    in a [`BTreeMap`] so no `HashMap` iteration order can leak into the result.
//! 2. Detects **threshold** cells. A cell `C` is a threshold iff `C` is corridor
//!    floor and is *not* in any room (the pierced wall cell just outside a room),
//!    and on one orthogonal axis — `(N, S)` or `(W, E)` — one neighbor is *in
//!    some room* (room membership, which includes the shared room-edge cell the
//!    corridor also runs through) while the **opposite** neighbor is corridor
//!    floor that is *not* in any room (the corridor continuing outward).
//! 3. For each threshold, in a fixed deterministic order, draws
//!    `rng.chance(door_chance)`: a hit sets `C = Door` (via the bounds-safe
//!    [`Grid::set`](crate::grid::Grid::set)) and records the door + the room it
//!    borders; a miss leaves `C` as `Floor` (an open archway).
//! 4. Attributes a representative door cell to each graph edge's `at` (see
//!    [edge attribution](#edge-attribution) below).
//!
//! All tile writes route through the bounds-safe
//! [`Grid::set`](crate::grid::Grid::set); all neighbor probing reads the
//! membership map (off-grid neighbors are simply absent from it), so a threshold
//! on the very edge of the map is handled without panic (spec §8).
//!
//! ## Edge attribution
//!
//! `CorridorCarver` appends exactly one `Corridor` region per graph edge, in
//! `ctx.graph.edges()` order, so the *i*-th corridor region corresponds to graph
//! edge *i*. For each edge we look at its own corridor's thresholds: if any of
//! them placed a door bordering one of the edge's two endpoint rooms
//! (`edge.a` / `edge.b`), the edge's `at` is set to that door cell; if both ends
//! became open archways (no door for that edge), `at` stays `None`. A corridor
//! that pierces a *non-endpoint* room (it passes through a third room's wall on
//! its way) may place a door there, but that door is **not** attributed to the
//! edge, because it does not border `edge.a` or `edge.b`.
//!
//! ## Determinism (spec §7)
//!
//! Thresholds are decided in a fixed order: corridor regions in append order,
//! and within each region its `cells` in stored order. A
//! [`BTreeSet`](std::collections::BTreeSet) of already-decided cells guards
//! against deciding the same cell twice when two corridors overlap it, so each
//! threshold consumes exactly one coin flip. Membership is a `BTreeMap` and
//! edges are visited in index order, so nothing depends on `HashMap`/`HashSet`
//! iteration. Given the same seed and the same carved input, the placed `Door`
//! tiles and every `edge.at` are byte-identical on every machine. The tests
//! build the tiles + regions + graph precondition by hand, depending on no
//! other pass.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

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

/// Configuration for a [`DoorPlacer`] pass.
///
/// `door_chance` is the probability that a given room↔corridor pierce threshold
/// becomes a [`Door`](crate::map::Tile::Door) rather than an open archway
/// (plain [`Floor`](crate::map::Tile::Floor)). It is clamped to `[0.0, 1.0]` by
/// the underlying [`Rng::chance`]: `0.0` places no doors at all, `1.0` makes
/// every threshold a door. Connectivity is via the corridor floor and is
/// unaffected by this knob either way.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DoorConfig {
    /// Probability in `[0.0, 1.0]` that a pierce threshold becomes a `Door`
    /// instead of an open archway.
    pub door_chance: f64,
}

impl Default for DoorConfig {
    /// A sensible default for the v1 dungeon: an even mix of doors and open
    /// archways at the pierce points.
    fn default() -> Self {
        DoorConfig { door_chance: 0.5 }
    }
}

/// A door-placing placer pass (spec §4.7).
///
/// Marks a configurable fraction of room↔corridor pierce thresholds as
/// [`Door`](crate::map::Tile::Door) tiles (the rest stay open
/// [`Floor`](crate::map::Tile::Floor) archways) and records a representative
/// door into each connected edge's `at`. Construct one with
/// [`DoorPlacer::new`] (or [`Default`]); it implements [`Pass`] with the stable
/// name `"door_placer"`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DoorPlacer {
    /// The door/archway mix this pass places under.
    cfg: DoorConfig,
}

impl DoorPlacer {
    /// Creates a door placer with the given configuration.
    pub fn new(cfg: DoorConfig) -> Self {
        DoorPlacer { cfg }
    }
}

/// What a floor cell belongs to, for pierce-threshold detection.
///
/// Tracked per cell because a corridor is carved *over* the cells it shares with
/// a room (the corridor runs from inside one room out through its wall), so a
/// single cell can be both room and corridor. A threshold is the cell that is
/// corridor floor but in **no** room — the pierced wall — with a room on one
/// side and the corridor continuing on the opposite side. Stored in a
/// [`BTreeMap`] so detection and edge attribution are deterministic.
#[derive(Clone, Copy, Debug, Default)]
struct Membership {
    /// Some [`Room`](RegionKind::Room) region claims this cell. Carries the
    /// owning room's id, used to attribute a door to an edge endpoint.
    room: Option<RegionId>,
    /// Some [`Corridor`](RegionKind::Corridor) region claims this cell.
    corridor: bool,
}

impl Pass for DoorPlacer {
    fn name(&self) -> &str {
        "door_placer"
    }

    fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
        // Build the per-cell membership lookup from the regions. Rooms are
        // recorded first so a cell that a corridor later carves over still
        // remembers its owning room id; `corridor` is OR-ed in independently.
        let mut membership: BTreeMap<(i32, i32), Membership> = BTreeMap::new();
        for region in &ctx.regions {
            match region.kind {
                RegionKind::Room => {
                    for &c in &region.cells {
                        let entry = membership.entry((c.x, c.y)).or_default();
                        // Keep the first (lowest-id) room that claims the cell.
                        if entry.room.is_none() {
                            entry.room = Some(region.id);
                        }
                    }
                }
                RegionKind::Corridor => {
                    for &c in &region.cells {
                        membership.entry((c.x, c.y)).or_default().corridor = true;
                    }
                }
            }
        }

        // Membership probes. A "pierced wall" cell is corridor floor that no
        // room claims; `room_side` reports the room id of a cell some room
        // claims (membership, NOT clean-room — the shared room-edge cell that
        // the corridor also runs through still counts as the room side).
        let is_pierced = |p: Point| {
            membership
                .get(&(p.x, p.y))
                .is_some_and(|m| m.corridor && m.room.is_none())
        };
        let room_side = |p: Point| -> Option<RegionId> {
            membership.get(&(p.x, p.y)).and_then(|m| m.room)
        };

        // Decide each threshold in a fixed order: corridor regions in append
        // order, each region's `cells` in stored order. A door consumes one coin
        // flip; an archway likewise consumes one — both branches draw exactly
        // once so the stream stays in lock-step with the threshold sequence. A
        // visited set guards against deciding a cell twice when two corridors
        // overlap it. Each placement remembers which room it borders so edges
        // can claim a representative door afterward.
        let mut placements: Vec<(Point, RegionId)> = Vec::new();
        let mut decided: BTreeSet<(i32, i32)> = BTreeSet::new();
        for region in &ctx.regions {
            if region.kind != RegionKind::Corridor {
                continue;
            }
            for &c in &region.cells {
                // Only the pierced-wall cells (corridor floor outside any room)
                // can be thresholds.
                if !is_pierced(c) {
                    continue;
                }
                // On one axis, one neighbor is in some room and the opposite is
                // the corridor continuing outward (pierced floor). Try (N, S)
                // then (W, E); the room may be on either end of the axis.
                let axes = [
                    (c.offset(0, -1), c.offset(0, 1)),
                    (c.offset(-1, 0), c.offset(1, 0)),
                ];
                let bordered_room = axes.iter().find_map(|&(a, b)| {
                    let room_a = room_side(a).filter(|_| is_pierced(b));
                    let room_b = room_side(b).filter(|_| is_pierced(a));
                    room_a.or(room_b)
                });
                let Some(room) = bordered_room else {
                    continue;
                };
                // A genuine threshold. Decide it at most once across corridors.
                if !decided.insert((c.x, c.y)) {
                    continue;
                }
                if rng.chance(self.cfg.door_chance) {
                    ctx.tiles.set(c, Tile::Door);
                    placements.push((c, room));
                }
                // A miss leaves the cell as the corridor `Floor` it already is
                // (an open archway) and records no placement.
            }
        }

        // Attribute a representative door to each edge. CorridorCarver appends
        // one Corridor region per edge in graph-edge order, so the i-th corridor
        // region corresponds to edge i. We pair them by index; if the counts
        // disagree (a malformed input) we attribute only what we can — no panic
        // in release.
        let corridor_ids: Vec<RegionId> = ctx
            .regions
            .iter()
            .filter(|r| r.kind == RegionKind::Corridor)
            .map(|r| r.id)
            .collect();
        debug_assert_eq!(
            corridor_ids.len(),
            ctx.graph.edges().len(),
            "expected one corridor region per graph edge"
        );

        // For each edge, find a door placed at one of *its* corridor's
        // thresholds that borders one of the edge's two endpoint rooms. A door
        // this corridor placed at a non-endpoint room's wall is intentionally
        // not attributed here. We borrow `ctx.regions` first (shared) and
        // `ctx.graph` second (mutable) — distinct fields, so both live at once.
        let regions = &ctx.regions;
        for (i, edge) in ctx.graph.edges_mut().iter_mut().enumerate() {
            let Some(&corridor_id) = corridor_ids.get(i) else {
                continue;
            };
            if let Some(&(door, _)) = placements.iter().find(|&&(door, room)| {
                (room == edge.a || room == edge.b) && cell_in_corridor(regions, corridor_id, door)
            }) {
                edge.at = Some(door);
            }
        }
    }
}

/// Returns `true` if `cell` is one of the cells of the corridor region `id`.
///
/// Used during edge attribution to confirm a placed door belongs to *this*
/// edge's corridor (not merely some corridor that happens to border the same
/// room). Reads `cells` directly; the region list is short (one per edge plus
/// the rooms), so a linear membership check is plenty.
fn cell_in_corridor(regions: &[crate::region::Region], id: RegionId, cell: Point) -> bool {
    regions
        .get(id.0)
        .is_some_and(|r| r.kind == RegionKind::Corridor && r.cells.contains(&cell))
}

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

    /// Carves a rectangle of `Floor` into `tiles` and returns its cells in
    /// row-major order — the shape a `Room` interior takes after `RoomCarver`.
    fn carve_rect(tiles: &mut Grid<Tile>, r: Rect) -> Vec<Point> {
        let cells: Vec<Point> = r.iter().collect();
        for &p in &cells {
            tiles.set(p, Tile::Floor);
        }
        cells
    }

    /// Carves an explicit list of `Floor` cells (a corridor run) into `tiles`
    /// and returns them — the shape a `Corridor` region takes after
    /// `CorridorCarver`. Cells inside the room are carved Floor too (already
    /// Floor there), faithfully mirroring the pierce: the corridor's `cells`
    /// include the in-room overlap plus the pierced run outside.
    fn carve_cells(tiles: &mut Grid<Tile>, cells: &[Point]) -> Vec<Point> {
        for &p in cells {
            tiles.set(p, Tile::Floor);
        }
        cells.to_vec()
    }

    /// Pushes a region of `kind` with the given bounds + cells, assigning the
    /// next sequential id (id-as-index invariant).
    fn push_region(ctx: &mut GenContext, kind: RegionKind, bounds: Rect, cells: Vec<Point>) {
        let id = RegionId(ctx.regions.len());
        ctx.regions.push(Region {
            id,
            kind,
            bounds,
            cells,
            theme: None,
        });
    }

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

    /// Builds the canonical PIERCE fixture, mirroring the real pipeline.
    ///
    /// A room rect is carved to Floor; a 1-wide corridor runs from inside the
    /// room (sharing the room's right-edge cell), THROUGH the pierced cell just
    /// outside the room, and onward. With `R` room floor, `*` the shared
    /// room-edge cell (room AND corridor), `P` the pierced threshold (corridor,
    /// no room), `C` corridor floor:
    /// ```text
    ///   R R R * P C C
    /// ```
    /// The room occupies `x:2..6, y:3..7`; the shared edge cell is `(5, 5)`; the
    /// pierced threshold is `(6, 5)`; the corridor continues `x:7..9` at `y = 5`.
    /// Room is region 0, corridor region 1, linked by edge {0, 1}.
    fn room_pierce_corridor() -> (GenContext, Point) {
        let mut tiles = Grid::new(16, 12, Tile::Wall);

        let room_rect = Rect::new(2, 3, 4, 4); // x:2..6, y:3..7
        let room_cells = carve_rect(&mut tiles, room_rect);

        let pierce = Point::new(6, 5); // corridor floor just outside the room

        // Corridor: shared in-room cell (5,5) + pierced (6,5) + outward run.
        let corridor_pts: Vec<Point> = (5..9).map(|x| Point::new(x, 5)).collect();
        let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
        let corridor_bounds = Rect::new(5, 5, 4, 1);

        let mut ctx = GenContext {
            tiles,
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
        push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells);
        ctx.graph.add_edge(RegionId(0), RegionId(1));

        (ctx, pierce)
    }

    /// 4-connected Floor-or-Door connectivity check: is `goal` reachable from
    /// `start` walking only walkable cells (Floor and Door are both walkable)?
    fn walkable_connected(tiles: &Grid<Tile>, start: Point, goal: Point) -> bool {
        let walkable = |p: Point| matches!(tiles.get(p), Some(&Tile::Floor) | Some(&Tile::Door));
        if !walkable(start) || !walkable(goal) {
            return false;
        }
        let mut seen: BTreeSet<(i32, i32)> = BTreeSet::new();
        let mut stack = vec![start];
        seen.insert((start.x, start.y));
        while let Some(p) = stack.pop() {
            if p == goal {
                return true;
            }
            for n in tiles.neighbors4(p) {
                if walkable(n) && seen.insert((n.x, n.y)) {
                    stack.push(n);
                }
            }
        }
        false
    }

    /// The pass identifies itself with the exact contract name.
    #[test]
    fn name_is_door_placer() {
        assert_eq!(DoorPlacer::default().name(), "door_placer");
    }

    /// door_chance = 1.0: the pierced threshold cell becomes a Door.
    #[test]
    fn certain_door_marks_the_pierced_threshold() {
        let (mut ctx, pierce) = room_pierce_corridor();
        assert_eq!(
            ctx.tiles.get(pierce),
            Some(&Tile::Floor),
            "the pierced cell starts as corridor Floor"
        );

        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD);

        assert_eq!(
            ctx.tiles.get(pierce),
            Some(&Tile::Door),
            "with door_chance=1.0 the pierce threshold must become a Door"
        );
    }

    /// door_chance = 0.0: no doors at all; the threshold stays Floor and the
    /// room is still walkable-connected to the corridor (connectivity is via the
    /// corridor floor, independent of doors).
    #[test]
    fn zero_chance_places_no_doors_but_stays_connected() {
        let (mut ctx, pierce) = room_pierce_corridor();
        let room_center = ctx.regions[0].bounds.center();
        let corridor_far = Point::new(8, 5);

        run(&mut ctx, DoorConfig { door_chance: 0.0 }, 0xABCD);

        assert!(
            ctx.tiles.iter().all(|(_, &t)| t != Tile::Door),
            "door_chance=0.0 must place no doors"
        );
        assert_eq!(
            ctx.tiles.get(pierce),
            Some(&Tile::Floor),
            "the threshold stays an open Floor archway"
        );
        assert!(
            walkable_connected(&ctx.tiles, room_center, corridor_far),
            "room must stay floor-connected to the corridor without any door"
        );
    }

    /// No door appears inside a room interior or mid-corridor: with
    /// door_chance=1.0 the only Door is the single pierce threshold.
    #[test]
    fn doors_only_at_thresholds() {
        let (mut ctx, pierce) = room_pierce_corridor();
        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD);

        let doors: Vec<Point> = ctx
            .tiles
            .iter()
            .filter(|&(_, &t)| t == Tile::Door)
            .map(|(p, _)| p)
            .collect();
        assert_eq!(doors, vec![pierce], "exactly one door, at the pierce threshold");

        // Room interior cells (excluding the shared edge cell the corridor runs
        // through) stay Floor — no door mid-room.
        for &p in &ctx.regions[0].cells {
            assert_ne!(ctx.tiles.get(p), Some(&Tile::Door), "no door inside the room");
        }
        // Corridor cells other than the threshold stay Floor — no door
        // mid-corridor.
        for &p in &ctx.regions[1].cells {
            if p != pierce {
                assert_ne!(ctx.tiles.get(p), Some(&Tile::Door), "no door mid-corridor");
            }
        }
    }

    /// A corridor that never borders a room (no room-membership neighbor) gets
    /// no doors, even at door_chance=1.0.
    #[test]
    fn corridor_without_room_neighbor_gets_no_doors() {
        let mut tiles = Grid::new(12, 6, Tile::Wall);
        // A lone corridor run, no room anywhere.
        let corridor_pts: Vec<Point> = (1..9).map(|x| Point::new(x, 3)).collect();
        let corridor_cells = carve_cells(&mut tiles, &corridor_pts);

        let mut ctx = GenContext {
            tiles,
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        push_region(
            &mut ctx,
            RegionKind::Corridor,
            Rect::new(1, 3, 8, 1),
            corridor_cells,
        );
        // One edge per corridor region keeps the count well-formed (the
        // endpoints name no real rooms, which is fine — no doors get placed).
        ctx.graph.add_edge(RegionId(0), RegionId(0));

        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 11);

        assert!(
            ctx.tiles.iter().all(|(_, &t)| t != Tile::Door),
            "a corridor with no adjoining room must get no doors"
        );
    }

    /// Bounds-safety: a pierce threshold sitting at the very edge of the map
    /// (one neighbor off-grid) must not panic and is still detected.
    #[test]
    fn threshold_at_map_edge_does_not_panic() {
        let mut tiles = Grid::new(8, 8, Tile::Wall);
        // Room floor at column 0, rows 0..3 (its left/top walls are off-grid).
        let room_pts: Vec<Point> = (0..3).map(|y| Point::new(0, y)).collect();
        let room_cells = carve_cells(&mut tiles, &room_pts);
        // Corridor: shared room cell (0,0), pierced (1,0), outward (2,0).
        let pierce = Point::new(1, 0);
        let corridor_pts = [Point::new(0, 0), pierce, Point::new(2, 0)];
        let corridor_cells = carve_cells(&mut tiles, &corridor_pts);

        let mut ctx = GenContext {
            tiles,
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        push_region(&mut ctx, RegionKind::Room, Rect::new(0, 0, 1, 3), room_cells);
        push_region(&mut ctx, RegionKind::Corridor, Rect::new(0, 0, 3, 1), corridor_cells);
        ctx.graph.add_edge(RegionId(0), RegionId(1));

        // Must not panic even with off-grid neighbor probes at the boundary.
        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xDEAD);

        assert_eq!(
            ctx.tiles.get(pierce),
            Some(&Tile::Door),
            "an edge-of-map pierce threshold is still a door"
        );
    }

    /// Determinism: the same seed reproduces identical Door tiles AND identical
    /// edge `at` values.
    #[test]
    fn same_seed_yields_identical_doors_and_edges() {
        let cfg = DoorConfig { door_chance: 0.5 };
        let (mut a, _) = room_pierce_corridor();
        let (mut b, _) = room_pierce_corridor();
        run(&mut a, cfg, 0x5EED);
        run(&mut b, cfg, 0x5EED);

        assert_eq!(a.tiles, b.tiles, "door tiles must be reproducible");

        let at_a: Vec<Option<Point>> = a.graph.edges().iter().map(|e| e.at).collect();
        let at_b: Vec<Option<Point>> = b.graph.edges().iter().map(|e| e.at).collect();
        assert_eq!(at_a, at_b, "edge door locations must be reproducible");
    }

    /// edge.at with door_chance=1.0 on a connected fixture: the edge's `at` is
    /// Some(the door cell).
    #[test]
    fn edge_at_is_set_when_door_placed() {
        let (mut ctx, pierce) = room_pierce_corridor();
        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD);

        let edges = ctx.graph.edges();
        assert_eq!(
            edges[0].at,
            Some(pierce),
            "the edge's representative door is the pierce threshold cell"
        );
    }

    /// edge.at with door_chance=0.0: both ends are archways, so `at` is None.
    #[test]
    fn edge_at_is_none_when_no_door_placed() {
        let (mut ctx, _) = room_pierce_corridor();
        run(&mut ctx, DoorConfig { door_chance: 0.0 }, 0xABCD);

        let edges = ctx.graph.edges();
        assert_eq!(
            edges[0].at, None,
            "with no door placed the edge records no location"
        );
    }

    /// Statistical: across many independent thresholds at door_chance=0.5,
    /// roughly half become doors (loose bounds, mirroring rng.rs's chance test).
    /// Each fixture has exactly one threshold, so one seed = one Bernoulli trial.
    #[test]
    fn half_of_thresholds_become_doors_at_p_half() {
        let trials = 4000;
        let cfg = DoorConfig { door_chance: 0.5 };
        let hits = (0..trials)
            .filter(|&seed| {
                let (mut ctx, pierce) = room_pierce_corridor();
                run(&mut ctx, cfg, seed);
                ctx.tiles.get(pierce) == Some(&Tile::Door)
            })
            .count();
        assert!(
            (1600..2400).contains(&hits),
            "p=0.5 over {trials} thresholds produced {hits} doors"
        );
    }

    /// A corridor that pierces a NON-endpoint room places a door there, but that
    /// door is not attributed to the edge (whose endpoints are different rooms).
    /// Here the edge is {room0, room2}; the corridor pierces room1's wall.
    #[test]
    fn non_endpoint_pierce_door_is_not_attributed_to_edge() {
        let mut tiles = Grid::new(20, 8, Tile::Wall);

        // Room 0 on the left, room 2 on the right, room 1 in the middle.
        let r0 = Rect::new(1, 2, 3, 4); // x:1..4
        let r1 = Rect::new(8, 2, 3, 4); // x:8..11
        let r2 = Rect::new(15, 2, 3, 4); // x:15..18
        let r0_cells = carve_rect(&mut tiles, r0);
        let r1_cells = carve_rect(&mut tiles, r1);
        let r2_cells = carve_rect(&mut tiles, r2);

        // One corridor (for the {0,2} edge) running along y=3 from inside room 0
        // straight through room 1 and into room 2. It pierces:
        //   - room 0's right wall at (4,3)  [borders endpoint room 0]
        //   - room 1's left wall  at (7,3)  [borders non-endpoint room 1]
        //   - room 1's right wall at (11,3) [borders non-endpoint room 1]
        //   - room 2's left wall  at (14,3) [borders endpoint room 2]
        let corridor_pts: Vec<Point> = (3..16).map(|x| Point::new(x, 3)).collect();
        let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
        let corridor_bounds = Rect::new(3, 3, 13, 1);

        let mut ctx = GenContext {
            tiles,
            regions: Vec::new(),
            graph: ConnGraph::new(),
            blackboard: Blackboard::new(),
        };
        push_region(&mut ctx, RegionKind::Room, r0, r0_cells);
        push_region(&mut ctx, RegionKind::Room, r1, r1_cells);
        push_region(&mut ctx, RegionKind::Room, r2, r2_cells);
        push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells);
        // The edge connects the two ENDPOINT rooms 0 and 2 (not the middle one).
        ctx.graph.add_edge(RegionId(0), RegionId(2));

        run(&mut ctx, DoorConfig { door_chance: 1.0 }, 7);

        // All four pierce thresholds became doors.
        for &p in &[
            Point::new(4, 3),
            Point::new(7, 3),
            Point::new(11, 3),
            Point::new(14, 3),
        ] {
            assert_eq!(ctx.tiles.get(p), Some(&Tile::Door), "pierce at {p:?} is a door");
        }

        // But the edge.at must be a door bordering an ENDPOINT room (0 or 2),
        // never one of room 1's interior pierces (7,3)/(11,3).
        let at = ctx.graph.edges()[0].at.expect("edge gets a door");
        assert!(
            at == Point::new(4, 3) || at == Point::new(14, 3),
            "edge.at must border an endpoint room, got {at:?}"
        );
    }
}
