// Map geometry uses pre-calculated array indices into client-provided
// vertex/polygon arrays. The bounds are established by the caller's
// for-loop ranges; #[allow] avoids noise from each indexed access.
// 0540: module-level #![allow(clippy::indexing_slicing)] narrowed to
// function-level allows on the geometry helpers below.

/// A 2D line segment expressed as a pair of `(x, y)` endpoints.
/// Introduced by 0400 to keep clippy's `type_complexity` happy
/// where `visibility_polygon_with_segs` accepts a slice of these.
pub type LineSegment = ((f64, f64), (f64, f64));

/// Types for custom map loading (S18).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct MapPoint {
    pub x: f64,
    pub y: f64,
}

/// One wall: consecutive vertex pairs block line-of-sight.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct MapWall(pub Vec<MapPoint>);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct MapPortal {
    pub position: MapPoint,
    pub bounds:   Vec<MapPoint>,
    pub rotation: f64,
    pub closed:   bool,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct MapLight {
    pub position:  MapPoint,
    pub range:     f64,
    pub color:     String,
    pub intensity: f64,
}

impl MapLight {
    /// Generic constructor — all parameters explicit. Presets below wrap this.
    pub fn new(position: MapPoint, range: f64, color: impl Into<String>, intensity: f64) -> Self {
        Self { position, range, color: color.into(), intensity }
    }

    // ---- reference presets (composition, not variants) ------------------
    //
    // These are *pattern demonstrators* for community-contributed libraries.
    // Each preset is one parameter set with a descriptive name; game plugins
    // or scene authors can mint their own without touching SDK surface.

    /// Hand-held torch: warm, short range, flickery.
    pub fn torch(position: MapPoint) -> Self {
        Self::new(position, 4.0, "#ffaa55", 0.9)
    }

    /// Campfire: slightly larger, warmer than a torch, steadier.
    pub fn campfire(position: MapPoint) -> Self {
        Self::new(position, 6.0, "#ff8833", 1.0)
    }

    /// Moonlight: pale blue, broad coverage, low intensity.
    pub fn moonlight(position: MapPoint) -> Self {
        Self::new(position, 12.0, "#aabbff", 0.35)
    }

    /// Urban streetlamp: yellow-white, medium range, steady.
    pub fn streetlamp(position: MapPoint) -> Self {
        Self::new(position, 8.0, "#ffddaa", 0.85)
    }

    /// Magical blue light: cold, steady, cinematic — good for dungeons.
    pub fn magical(position: MapPoint) -> Self {
        Self::new(position, 5.0, "#88ccff", 1.0)
    }
}

/// Fully resolved map; broadcast to all clients in the session.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct LoadedMap {
    pub name:           String,
    pub grid_cols:      u32,
    pub grid_rows:      u32,
    pub cell_width_px:  f64,
    pub cell_height_px: f64,
    pub origin_x:       f64,
    pub origin_y:       f64,
    /// Reserved for future rotated-grid support; always 0.0 for v1.
    #[cfg_attr(feature = "serde", serde(default))]
    pub rotation_deg:   f64,
    /// `"data:image/png;base64,..."` or `"data:image/jpeg;base64,..."`
    pub image_data_url: String,
    /// Opaque walls (UVTT `line_of_sight`) — block BOTH vision and motion.
    pub walls:          Vec<MapWall>,
    /// Object walls (UVTT `objects_line_of_sight`) — block MOTION only.
    /// You can see over / past them but can't walk through. Tables,
    /// railings, low walls, counters, etc.
    #[cfg_attr(feature = "serde", serde(default))]
    pub objects:        Vec<MapWall>,
    pub portals:        Vec<MapPortal>,
    pub lights:         Vec<MapLight>,
    /// Map-level vision mode. Controls whether walls occlude sight and
    /// whether there is a range cap for observers on this map.
    ///
    /// `VisionProfile::unlimited()` — theatre-of-mind / open table: all
    /// entities are visible to all seats regardless of walls or distance.
    /// This is the default for backward compatibility and for maps where
    /// the GM narrates visibility rather than the engine computing it.
    ///
    /// `VisionProfile::sighted()` — dungeon / encounter map: opaque walls
    /// block line of sight; no range cap.
    ///
    /// Per-entity overrides (e.g. darkvision, blindsight) are expressed
    /// on `TangibleData.vision` and take precedence over this map default.
    #[cfg_attr(feature = "serde", serde(default = "crate::map::default_map_vision"))]
    pub vision:         crate::trait_data::VisionProfile,
}

/// Auto-detected grid parameters returned after plain-image upload.
/// Serde default for `LoadedMap.vision`. Returns `unlimited()` so that
/// maps serialised before this field was added behave as theatre-of-mind
/// (the engine did not compute vision for them previously).
pub fn default_map_vision() -> crate::trait_data::VisionProfile {
    crate::trait_data::VisionProfile::unlimited()
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone)]
pub struct GridCandidate {
    pub cols:           u32,
    pub rows:           u32,
    pub cell_width_px:  f64,
    pub cell_height_px: f64,
    pub origin_x:       f64,
    pub origin_y:       f64,
    /// Detector confidence in [0, 1]. 0.0 = autocorrelation fallback;
    /// >0.6 = Hough peak-detection succeeded.
    #[cfg_attr(feature = "serde", serde(default))]
    pub confidence:     f64,
    /// Reserved for future rotated-grid support; always 0.0 for v1.
    #[cfg_attr(feature = "serde", serde(default))]
    pub rotation_deg:   f64,
}

/// Fluent builder for constructing a `LoadedMap` programmatically —
/// procedural dungeon generators, scripted test scenes, in-code map
/// authoring. Not involved in the UVTT-file load path (which stays as
/// `map_loader::load_uvtt`).
#[derive(Debug)]
pub struct LoadedMapBuilder {
    name:           String,
    grid_cols:      u32,
    grid_rows:      u32,
    cell_width_px:  f64,
    cell_height_px: f64,
    image_data_url: String,
    walls:          Vec<MapWall>,
    objects:        Vec<MapWall>,
    portals:        Vec<MapPortal>,
    lights:         Vec<MapLight>,
    vision:         crate::trait_data::VisionProfile,
}

impl Default for LoadedMapBuilder {
    fn default() -> Self {
        Self {
            name: String::new(),
            grid_cols: 0, grid_rows: 0,
            cell_width_px: 50.0, cell_height_px: 50.0,
            image_data_url: String::new(),
            walls: vec![], objects: vec![],
            portals: vec![], lights: vec![],
            vision: crate::trait_data::VisionProfile::unlimited(),
        }
    }
}

impl LoadedMapBuilder {
    pub fn new(name: impl Into<String>, cols: u32, rows: u32) -> Self {
        Self {
            name: name.into(),
            grid_cols: cols,
            grid_rows: rows,
            cell_width_px:  50.0,
            cell_height_px: 50.0,
            ..Default::default()
        }
    }
    pub fn cell_px(mut self, w: f64, h: f64) -> Self {
        self.cell_width_px = w; self.cell_height_px = h; self
    }
    pub fn background(mut self, data_url: impl Into<String>) -> Self {
        self.image_data_url = data_url.into(); self
    }
    /// Add an opaque wall polyline (blocks vision + motion).
    pub fn wall(mut self, points: &[(f64, f64)]) -> Self {
        self.walls.push(MapWall(points.iter()
            .map(|(x, y)| MapPoint { x: *x, y: *y }).collect()));
        self
    }
    /// Add a motion-only object polyline (blocks motion, vision passes).
    pub fn object(mut self, points: &[(f64, f64)]) -> Self {
        self.objects.push(MapWall(points.iter()
            .map(|(x, y)| MapPoint { x: *x, y: *y }).collect()));
        self
    }
    pub fn portal(mut self, a: (f64, f64), b: (f64, f64), closed: bool) -> Self {
        self.portals.push(MapPortal {
            position: MapPoint { x: (a.0 + b.0) * 0.5, y: (a.1 + b.1) * 0.5 },
            bounds:   vec![MapPoint { x: a.0, y: a.1 }, MapPoint { x: b.0, y: b.1 }],
            rotation: 0.0, closed,
        });
        self
    }
    pub fn light(mut self, at: (f64, f64), range: f64, color: impl Into<String>, intensity: f64) -> Self {
        self.lights.push(MapLight {
            position: MapPoint { x: at.0, y: at.1 },
            range, color: color.into(), intensity,
        });
        self
    }
    /// Drop a pre-baked `MapLight` onto the map. Use with presets:
    /// `builder.add_light(MapLight::torch(MapPoint { x, y }))`.
    pub fn add_light(mut self, light: MapLight) -> Self {
        self.lights.push(light);
        self
    }
    /// Set the map-level vision profile. Use `VisionProfile::sighted()` for
    /// dungeon/encounter maps where walls block sight; `VisionProfile::unlimited()`
    /// (the default) for theatre-of-mind maps.
    pub fn vision(mut self, v: crate::trait_data::VisionProfile) -> Self {
        self.vision = v; self
    }
    pub fn build(self) -> LoadedMap {
        LoadedMap {
            name: self.name,
            grid_cols: self.grid_cols,
            grid_rows: self.grid_rows,
            cell_width_px:  self.cell_width_px,
            cell_height_px: self.cell_height_px,
            origin_x: 0.0, origin_y: 0.0,
            rotation_deg: 0.0,
            image_data_url: self.image_data_url,
            walls: self.walls, objects: self.objects,
            portals: self.portals, lights: self.lights,
            vision: self.vision,
        }
    }
}

impl LoadedMap {
    /// Entry point for the programmatic map builder.
    pub fn builder(name: impl Into<String>, cols: u32, rows: u32) -> LoadedMapBuilder {
        LoadedMapBuilder::new(name, cols, rows)
    }

    /// Map-space centre of a Grid cell `(col, row)`, in cell units.
    pub fn cell_center(&self, col: i32, row: i32) -> (f64, f64) {
        (col as f64 + 0.5, row as f64 + 0.5)
    }

    /// Does the segment `(a, b)` in map-space cross a **vision** blocker —
    /// opaque wall, closed portal, or map boundary? Objects (see-through)
    /// are NOT included. Client fog uses this.
    #[allow(
        clippy::indexing_slicing,
        reason = "windows(2) yields exactly 2-element slices; window[0]/[1] bounds-safe by construction"
    )]
    pub fn is_vision_blocked(&self, a: (f64, f64), b: (f64, f64)) -> bool {
        self.is_blocked(a, b)
    }

    /// Does the segment `(a, b)` cross a **motion** blocker — opaque wall,
    /// object, closed portal, or map boundary? Server motion validation
    /// uses this so players can't walk through tables or low walls.
    #[allow(
        clippy::indexing_slicing,
        reason = "windows(2) yields exactly 2-element slices; window[0]/[1] bounds-safe by construction"
    )]
    pub fn is_motion_blocked(&self, a: (f64, f64), b: (f64, f64)) -> bool {
        if self.is_blocked(a, b) { return true; }
        // Objects (motion-only barriers) — not in is_blocked so vision
        // still passes through them.
        for obj in &self.objects {
            for window in obj.0.windows(2) {
                let c = (window[0].x, window[0].y);
                let d = (window[1].x, window[1].y);
                if segments_intersect(a, b, c, d) { return true; }
            }
        }
        false
    }

    /// Intrinsic map-boundary segments. Always opaque and impassable —
    /// shared by `is_blocked` and `opaque_segments` so the two paths can't
    /// diverge.
    pub fn boundary_segments(&self) -> [((f64, f64), (f64, f64)); 4] {
        let (w, h) = (self.grid_cols as f64, self.grid_rows as f64);
        [
            ((0.0, 0.0), (w,   0.0)),
            ((w,   0.0), (w,   h  )),
            ((w,   h  ), (0.0, h  )),
            ((0.0, h  ), (0.0, 0.0)),
        ]
    }

    /// Does the segment `(a, b)` in map-space cross any opaque wall, closed
    /// portal, or the map boundary? Map boundary is always a hard wall — you
    /// can't walk or see off the map. Shared by server (motion validation)
    /// and client (fog rendering).
    #[allow(
        clippy::indexing_slicing,
        reason = "windows(2) yields exactly 2-element slices; window[0]/[1] bounds-safe by construction"
    )]
    pub fn is_blocked(&self, a: (f64, f64), b: (f64, f64)) -> bool {
        for (c, d) in self.boundary_segments() {
            if segments_intersect(a, b, c, d) { return true; }
        }
        for wall in &self.walls {
            let pts = &wall.0;
            for window in pts.windows(2) {
                let c = (window[0].x, window[0].y);
                let d = (window[1].x, window[1].y);
                if segments_intersect(a, b, c, d) { return true; }
            }
        }
        for portal in &self.portals {
            if !portal.closed { continue; }
            let pts = &portal.bounds;
            if pts.len() < 2 { continue; }
            let c = (pts[0].x, pts[0].y);
            let d = (pts[pts.len() - 1].x, pts[pts.len() - 1].y);
            if segments_intersect(a, b, c, d) { return true; }
        }
        false
    }

    /// Group portals that share endpoints into logical "doors". Many UVTT
    /// exporters (Arkenforge in particular) split a single real door into
    /// multiple adjacent 2-point segments that share endpoints — twelve
    /// segments for one tavern door. For rendering, hit-testing and
    /// toggling we want to treat each chain as one door.
    ///
    /// Returns a `Vec<Vec<usize>>` of portal-index groups. Each inner Vec
    /// is one logical door (in arbitrary portal order — use geometry if you
    /// need them ordered along the polyline).
    #[allow(
        clippy::indexing_slicing,
        reason = "portal-graph indexing via known-valid portal indices established by the build phase"
    )]
    pub fn portal_chains(&self) -> Vec<Vec<usize>> {
        let n = self.portals.len();
        if n == 0 { return Vec::new(); }
        let quant = |p: &MapPoint| -> (i64, i64) {
            ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)
        };
        // Union-find keyed on portal index; union i+j if they share any
        // quantised endpoint.
        let mut parent: Vec<usize> = (0..n).collect();
        fn find(p: &mut [usize], i: usize) -> usize {
            if p[i] == i { return i; }
            let r = find(p, p[i]); p[i] = r; r
        }
        fn union(p: &mut [usize], a: usize, b: usize) {
            let ra = find(p, a); let rb = find(p, b);
            if ra != rb { p[ra] = rb; }
        }
        use std::collections::HashMap;
        let mut seen: HashMap<(i64, i64), usize> = HashMap::new();
        for (i, portal) in self.portals.iter().enumerate() {
            if portal.bounds.is_empty() { continue; }
            let last = portal.bounds.len() - 1;
            for pt in [&portal.bounds[0], &portal.bounds[last]] {
                let k = quant(pt);
                match seen.get(&k) {
                    Some(&j) => union(&mut parent, i, j),
                    None => { seen.insert(k, i); }
                }
            }
        }
        let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
        for i in 0..n {
            let r = find(&mut parent, i);
            groups.entry(r).or_default().push(i);
        }
        groups.into_values().collect()
    }

    /// Return the chain containing `portal_index`, or just `[portal_index]`
    /// if that portal isn't adjacent to any others.
    pub fn portal_chain_for(&self, portal_index: usize) -> Vec<usize> {
        for chain in self.portal_chains() {
            if chain.contains(&portal_index) { return chain; }
        }
        vec![portal_index]
    }

    /// Trace a door chain's 2-point segments into one ordered polyline by
    /// walking shared endpoints. Starts at a degree-1 endpoint (a "free end"
    /// of the chain) when one exists; for a closed loop, picks an arbitrary
    /// starting endpoint. Returns `[a, b, c, …]` — the client strokes this
    /// once with `lineJoin=round` to get a single continuous door face.
    #[allow(
        clippy::indexing_slicing,
        reason = "chain[i] indices come from portal_chains() — already validated; polyline indexes its own walls Vec by known-valid offsets"
    )]
    pub fn ordered_chain_polyline(&self, chain: &[usize]) -> Vec<MapPoint> {
        use std::collections::HashMap;
        if chain.is_empty() { return Vec::new(); }
        let quant = |p: &MapPoint| -> (i64, i64) {
            ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)
        };
        // Collect 2-point segments (first and last bounds point of each portal).
        let mut segs: Vec<(MapPoint, MapPoint)> = Vec::new();
        for &i in chain {
            let Some(p) = self.portals.get(i) else { continue };
            if p.bounds.len() < 2 { continue; }
            let a = p.bounds[0].clone();
            let b = p.bounds[p.bounds.len() - 1].clone();
            segs.push((a, b));
        }
        if segs.is_empty() { return Vec::new(); }

        // Adjacency: endpoint-key → list of (segment index, reversed?).
        // "reversed" means this endpoint is the `b` side of the segment.
        let mut adj: HashMap<(i64, i64), Vec<(usize, bool)>> = HashMap::new();
        for (si, (a, b)) in segs.iter().enumerate() {
            adj.entry(quant(a)).or_default().push((si, false));
            adj.entry(quant(b)).or_default().push((si, true));
        }

        // Start: prefer a degree-1 endpoint (open chain end). Fall back to any.
        let start_key = adj.iter()
            .find(|(_, v)| v.len() == 1)
            .map(|(k, _)| *k)
            .or_else(|| adj.keys().next().copied());
        let Some(start_key) = start_key else { return Vec::new(); };

        // Seed the walk with the segment whose endpoint at start_key is
        // traversed first.
        let mut used = vec![false; segs.len()];
        let mut polyline: Vec<MapPoint> = Vec::new();
        let (start_si, start_rev) = adj[&start_key][0];
        used[start_si] = true;
        let (sa, sb) = &segs[start_si];
        let (p0, p1) = if start_rev { (sb, sa) } else { (sa, sb) };
        polyline.push(p0.clone());
        polyline.push(p1.clone());
        let mut current_key = quant(p1);

        while used.iter().any(|u| !u) {
            let entries = match adj.get(&current_key) {
                Some(e) => e,
                None => break,
            };
            let Some(&(si, reversed)) = entries.iter().find(|(si, _)| !used[*si]) else { break; };
            let (a, b) = &segs[si];
            let next_pt = if reversed { a } else { b };
            polyline.push(next_pt.clone());
            used[si] = true;
            current_key = quant(next_pt);
        }
        polyline
    }

    /// Returns true iff the grid cell at `(col, row)` is visible from a
    /// viewer at grid cell `(viewer_col, viewer_row)`. Includes the
    /// viewer's own cell.
    pub fn cell_visible_from(
        &self,
        viewer_col: i32,
        viewer_row: i32,
        col: i32,
        row: i32,
    ) -> bool {
        if col == viewer_col && row == viewer_row { return true; }
        let a = self.cell_center(viewer_col, viewer_row);
        let b = self.cell_center(col, row);
        !self.is_blocked(a, b)
    }

    /// Collect every opaque wall/portal segment plus the map boundary as
    /// four segments. Open portals are skipped. **Pub** so callers can
    /// compute it once per frame and reuse across multiple viewers.
    ///
    /// The caller can feed the result into [`visibility_polygon_with_segs`]
    /// to avoid re-walking the walls for every viewer.
    #[allow(
        clippy::indexing_slicing,
        reason = "windows(2) yields exactly 2-element slices; window[0]/[1] bounds-safe by construction"
    )]
    pub fn opaque_segments(&self) -> Vec<((f64, f64), (f64, f64))> {
        let mut raw: Vec<((f64, f64), (f64, f64))> = Vec::new();
        for wall in &self.walls {
            for w in wall.0.windows(2) {
                raw.push(((w[0].x, w[0].y), (w[1].x, w[1].y)));
            }
        }
        for portal in &self.portals {
            if !portal.closed || portal.bounds.len() < 2 { continue; }
            for w in portal.bounds.windows(2) {
                raw.push(((w[0].x, w[0].y), (w[1].x, w[1].y)));
            }
        }
        // Map boundary — rays always stop at the map edge.
        for seg in self.boundary_segments() {
            raw.push(seg);
        }
        raw
    }

    /// Compute the visibility polygon from `viewer` in cell-unit map space.
    /// Convenience wrapper around [`visibility_polygon_with_segs`] that
    /// walks walls every call. If you have multiple viewers, compute
    /// [`opaque_segments`] once and pass it in to amortise that work.
    pub fn visibility_polygon(&self, viewer: (f64, f64), max_radius: f64) -> Vec<(f64, f64)> {
        let segs = self.opaque_segments();
        Self::visibility_polygon_with_segs(viewer, max_radius, &segs)
    }

    /// Angular-sweep visibility polygon using a caller-supplied segment list.
    /// For each wall endpoint we cast a ray slightly before / at / after it
    /// and keep the nearest hit; angles are normalised to [0, τ) so the
    /// sort gives a monotonic sweep. Result is a closed polygon in angular
    /// order around `viewer`.
    pub fn visibility_polygon_with_segs(
        viewer: (f64, f64),
        max_radius: f64,
        segs: &[LineSegment],
    ) -> Vec<(f64, f64)> {
        let eps: f64 = 1e-4;
        let tau = std::f64::consts::TAU;
        let norm = |a: f64| {
            let mut x = a % tau;
            if x < 0.0 { x += tau; }
            x
        };

        // Bounding square that encloses the reach — used to cheaply skip
        // segments obviously too far to matter.
        let (bx_lo, bx_hi) = (viewer.0 - max_radius, viewer.0 + max_radius);
        let (by_lo, by_hi) = (viewer.1 - max_radius, viewer.1 + max_radius);
        let near: Vec<&LineSegment> = segs.iter().filter(|(a, b)| {
            let (lx, hx) = (a.0.min(b.0), a.0.max(b.0));
            let (ly, hy) = (a.1.min(b.1), a.1.max(b.1));
            !(hx < bx_lo || lx > bx_hi || hy < by_lo || ly > by_hi)
        }).collect();

        let mut angles: Vec<f64> = Vec::with_capacity(near.len() * 6 + 32);
        for (a, b) in &near {
            for p in [a, b] {
                let base = (p.1 - viewer.1).atan2(p.0 - viewer.0);
                angles.push(norm(base - eps));
                angles.push(norm(base));
                angles.push(norm(base + eps));
            }
        }
        // 16 circle samples so the polygon stays smooth in open space.
        for i in 0..16 {
            angles.push(norm((i as f64) * (tau / 16.0)));
        }
        angles.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        // Drop angles within ε of their predecessor — redundant rays.
        angles.dedup_by(|x, y| (*x - *y).abs() < 1e-6);

        let mut poly: Vec<(f64, f64)> = Vec::with_capacity(angles.len());
        for theta in angles {
            let dir = (theta.cos(), theta.sin());
            let end = (viewer.0 + dir.0 * max_radius, viewer.1 + dir.1 * max_radius);
            let mut best_t = 1.0_f64;
            for (a, b) in &near {
                if let Some(t) = ray_segment_t(viewer, end, *a, *b) {
                    if t < best_t { best_t = t; }
                }
            }
            poly.push((
                viewer.0 + dir.0 * max_radius * best_t,
                viewer.1 + dir.1 * max_radius * best_t,
            ));
        }
        poly
    }
}

/// Parametric intersection of segment `p→q` with segment `a→b`. Returns
/// `Some(t)` where the hit is `p + t*(q-p)` with `0 < t ≤ 1` and the hit
/// falls on `a→b` (`0 ≤ u ≤ 1`). None if parallel, behind, or beyond the
/// segments. Skip t < EPS so a ray starting exactly on a wall doesn't
/// immediately clip to the viewer's own location.
fn ray_segment_t(
    p: (f64, f64), q: (f64, f64),
    a: (f64, f64), b: (f64, f64),
) -> Option<f64> {
    let r = (q.0 - p.0, q.1 - p.1);
    let s = (b.0 - a.0, b.1 - a.1);
    let rxs = cross(r, s);
    if rxs.abs() < 1e-12 { return None; }
    let qp = (a.0 - p.0, a.1 - p.1);
    let t = cross(qp, s) / rxs;
    let u = cross(qp, r) / rxs;
    if t > 1e-9 && t <= 1.0 && (-1e-9..=1.0 + 1e-9).contains(&u) {
        Some(t)
    } else {
        None
    }
}


/// Standard 2D segment–segment intersection. Strict general-position test
/// so pieces touching a wall at an endpoint aren't flagged as crossing.
fn segments_intersect(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), p4: (f64, f64)) -> bool {
    let d1 = cross((p4.0 - p3.0, p4.1 - p3.1), (p1.0 - p3.0, p1.1 - p3.1));
    let d2 = cross((p4.0 - p3.0, p4.1 - p3.1), (p2.0 - p3.0, p2.1 - p3.1));
    let d3 = cross((p2.0 - p1.0, p2.1 - p1.1), (p3.0 - p1.0, p3.1 - p1.1));
    let d4 = cross((p2.0 - p1.0, p2.1 - p1.1), (p4.0 - p1.0, p4.1 - p1.1));
    ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
        && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
}

fn cross(u: (f64, f64), v: (f64, f64)) -> f64 {
    u.0 * v.1 - u.1 * v.0
}

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

    fn map(cols: u32, rows: u32) -> LoadedMap {
        LoadedMap {
            name: "t".into(),
            grid_cols: cols,
            grid_rows: rows,
            cell_width_px: 50.0,
            cell_height_px: 50.0,
            origin_x: 0.0,
            origin_y: 0.0,
            rotation_deg: 0.0,
            image_data_url: String::new(),
            walls: vec![],
            objects: vec![],
            portals: vec![],
            lights: vec![],
            vision: crate::trait_data::VisionProfile::unlimited(),
        }
    }

    fn wall_line(pts: &[(f64, f64)]) -> MapWall {
        MapWall(pts.iter().map(|&(x, y)| MapPoint { x, y }).collect())
    }

    fn portal(a: (f64, f64), b: (f64, f64), closed: bool) -> MapPortal {
        MapPortal {
            position: MapPoint { x: (a.0 + b.0) * 0.5, y: (a.1 + b.1) * 0.5 },
            bounds: vec![MapPoint { x: a.0, y: a.1 }, MapPoint { x: b.0, y: b.1 }],
            rotation: 0.0,
            closed,
        }
    }

    // ----- segments_intersect -----

    #[test]
    fn segments_intersect_crossing() {
        assert!(segments_intersect((0.0, 0.0), (2.0, 2.0), (0.0, 2.0), (2.0, 0.0)));
    }

    #[test]
    fn segments_intersect_parallel_no_cross() {
        assert!(!segments_intersect((0.0, 0.0), (2.0, 0.0), (0.0, 1.0), (2.0, 1.0)));
    }

    #[test]
    fn segments_intersect_disjoint() {
        assert!(!segments_intersect((0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (3.0, 3.0)));
    }

    #[test]
    fn segments_intersect_touching_endpoint_does_not_count() {
        // Strict general position: endpoint touching isn't a crossing, so a
        // piece standing at a wall corner can see past it.
        assert!(!segments_intersect((0.0, 0.0), (2.0, 0.0), (2.0, 0.0), (2.0, 2.0)));
    }

    #[test]
    fn segments_intersect_collinear_overlap_does_not_count() {
        // General-position test — collinear segments are explicitly excluded.
        assert!(!segments_intersect((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0)));
    }

    // ----- ray_segment_t -----

    #[test]
    fn ray_hits_perpendicular_wall() {
        // Ray from (0,0) to (4,0), wall at x=2 vertical → hit at t=0.5.
        let t = ray_segment_t((0.0, 0.0), (4.0, 0.0), (2.0, -1.0), (2.0, 1.0));
        assert!(t.is_some());
        assert!((t.unwrap() - 0.5).abs() < 1e-9);
    }

    #[test]
    fn ray_misses_offset_wall() {
        let t = ray_segment_t((0.0, 0.0), (4.0, 0.0), (2.0, 1.0), (2.0, 2.0));
        assert_eq!(t, None);
    }

    #[test]
    fn ray_parallel_to_wall_is_none() {
        let t = ray_segment_t((0.0, 0.0), (4.0, 0.0), (0.0, 1.0), (4.0, 1.0));
        assert_eq!(t, None);
    }

    #[test]
    fn ray_starting_on_wall_skips_t_zero() {
        // Ray origin lies on the wall — algorithm must skip the "hit at
        // t=0" so viewer standing on a wall doesn't see nothing.
        let t = ray_segment_t((0.0, 0.0), (4.0, 0.0), (0.0, -1.0), (0.0, 1.0));
        assert_eq!(t, None);
    }

    // ----- is_blocked / is_motion_blocked / is_vision_blocked -----

    #[test]
    fn map_boundary_blocks_rays_leaving_map() {
        let m = map(10, 10);
        // Ray from inside to outside the map MUST be blocked by boundary.
        assert!(m.is_blocked((5.0, 5.0), (15.0, 5.0)));
    }

    #[test]
    fn closed_portal_blocks_vision() {
        let mut m = map(10, 10);
        m.portals.push(portal((5.0, 0.0), (5.0, 10.0), true));
        assert!(m.is_vision_blocked((1.0, 5.0), (9.0, 5.0)));
    }

    #[test]
    fn open_portal_does_not_block_vision() {
        let mut m = map(10, 10);
        m.portals.push(portal((5.0, 0.0), (5.0, 10.0), false));
        assert!(!m.is_vision_blocked((1.0, 5.0), (9.0, 5.0)));
    }

    #[test]
    fn object_blocks_motion_but_not_vision() {
        let mut m = map(10, 10);
        m.objects.push(wall_line(&[(5.0, 0.0), (5.0, 10.0)]));
        let a = (1.0, 5.0);
        let b = (9.0, 5.0);
        assert!(!m.is_vision_blocked(a, b), "objects do not block vision");
        assert!(m.is_motion_blocked(a, b), "objects DO block motion");
    }

    #[test]
    fn opaque_wall_blocks_both() {
        let mut m = map(10, 10);
        m.walls.push(wall_line(&[(5.0, 0.0), (5.0, 10.0)]));
        let a = (1.0, 5.0);
        let b = (9.0, 5.0);
        assert!(m.is_vision_blocked(a, b));
        assert!(m.is_motion_blocked(a, b));
    }

    // ----- visibility_polygon_with_segs -----

    #[test]
    fn visibility_polygon_in_open_room_is_radius_bounded() {
        let m = map(20, 20);
        let segs = m.opaque_segments();
        let poly = LoadedMap::visibility_polygon_with_segs((10.0, 10.0), 5.0, &segs);
        // At least 3 vertices for a valid polygon, and all vertices within
        // radius of the viewer (plus a small FP tolerance).
        assert!(poly.len() >= 3);
        for (x, y) in &poly {
            let d = ((x - 10.0).powi(2) + (y - 10.0).powi(2)).sqrt();
            assert!(d <= 5.0 + 1e-6, "vertex at ({x}, {y}) exceeds radius");
        }
    }

    #[test]
    fn visibility_polygon_respects_walls() {
        // Box around viewer — polygon must stay inside the box.
        let mut m = map(20, 20);
        m.walls.push(wall_line(&[(5.0, 5.0), (15.0, 5.0)]));
        m.walls.push(wall_line(&[(15.0, 5.0), (15.0, 15.0)]));
        m.walls.push(wall_line(&[(15.0, 15.0), (5.0, 15.0)]));
        m.walls.push(wall_line(&[(5.0, 15.0), (5.0, 5.0)]));
        let segs = m.opaque_segments();
        let poly = LoadedMap::visibility_polygon_with_segs((10.0, 10.0), 20.0, &segs);
        assert!(poly.len() >= 4);
        // Every vertex stays within the [5, 15] box (±FP tolerance).
        for (x, y) in &poly {
            assert!(*x >= 5.0 - 1e-6 && *x <= 15.0 + 1e-6, "vertex {x} escaped box");
            assert!(*y >= 5.0 - 1e-6 && *y <= 15.0 + 1e-6, "vertex {y} escaped box");
        }
    }

    // ----- portal_chains -----

    #[test]
    fn isolated_portal_is_its_own_chain() {
        let mut m = map(10, 10);
        m.portals.push(portal((1.0, 1.0), (1.0, 3.0), true));
        let chains = m.portal_chains();
        assert_eq!(chains.len(), 1);
        assert_eq!(chains[0], vec![0]);
    }

    #[test]
    fn three_portals_sharing_endpoints_form_one_chain() {
        let mut m = map(10, 10);
        m.portals.push(portal((1.0, 1.0), (2.0, 1.0), true));
        m.portals.push(portal((2.0, 1.0), (3.0, 1.0), true));
        m.portals.push(portal((3.0, 1.0), (4.0, 1.0), true));
        let chains = m.portal_chains();
        assert_eq!(chains.len(), 1, "sharing endpoints = one chain");
        let mut ids = chains[0].clone();
        ids.sort();
        assert_eq!(ids, vec![0, 1, 2]);
    }

    #[test]
    fn two_separate_doors_yield_two_chains() {
        let mut m = map(10, 10);
        m.portals.push(portal((1.0, 1.0), (2.0, 1.0), true));
        m.portals.push(portal((5.0, 5.0), (6.0, 5.0), true));
        let chains = m.portal_chains();
        assert_eq!(chains.len(), 2);
    }

    #[test]
    fn portal_chain_for_returns_full_chain() {
        let mut m = map(10, 10);
        m.portals.push(portal((1.0, 1.0), (2.0, 1.0), true));
        m.portals.push(portal((2.0, 1.0), (3.0, 1.0), true));
        let mut chain = m.portal_chain_for(0);
        chain.sort();
        assert_eq!(chain, vec![0, 1]);
    }

    #[test]
    fn ordered_polyline_traverses_open_chain() {
        let mut m = map(10, 10);
        m.portals.push(portal((1.0, 1.0), (2.0, 1.0), true));
        m.portals.push(portal((2.0, 1.0), (3.0, 1.0), true));
        m.portals.push(portal((3.0, 1.0), (4.0, 1.0), true));
        let poly = m.ordered_chain_polyline(&[0, 1, 2]);
        // Start at a degree-1 endpoint, walk through shared points.
        assert_eq!(poly.len(), 4);
        let xs: Vec<f64> = poly.iter().map(|p| p.x).collect();
        // Should be monotonic 1→4 or 4→1.
        let monotone_up = xs.windows(2).all(|w| w[1] >= w[0]);
        let monotone_down = xs.windows(2).all(|w| w[1] <= w[0]);
        assert!(monotone_up || monotone_down);
    }

    // ----- boundary_segments consistency -----

    #[test]
    fn boundary_segments_appear_in_opaque_segments() {
        let m = map(8, 6);
        let segs = m.opaque_segments();
        // opaque_segments should contain the 4 boundary segments.
        for boundary in m.boundary_segments() {
            assert!(segs.contains(&boundary), "boundary {:?} missing from opaque_segments", boundary);
        }
    }

    #[test]
    fn light_presets_produce_distinct_parameter_sets() {
        let p = MapPoint { x: 0.0, y: 0.0 };
        let torch    = MapLight::torch(p.clone());
        let campfire = MapLight::campfire(p.clone());
        let moon     = MapLight::moonlight(p.clone());
        let street   = MapLight::streetlamp(p.clone());
        let magical  = MapLight::magical(p);

        // All presets light, but with different ranges and colours.
        let ranges: Vec<f64> = vec![torch.range, campfire.range, moon.range, street.range, magical.range];
        let colors: Vec<&str> = vec![&torch.color, &campfire.color, &moon.color, &street.color, &magical.color];

        // All ranges positive.
        for r in &ranges { assert!(*r > 0.0); }
        // Colours all distinct — presets should differ visibly.
        for i in 0..colors.len() {
            for j in (i + 1)..colors.len() {
                assert_ne!(colors[i], colors[j], "preset colours must differ: {} vs {}", colors[i], colors[j]);
            }
        }
    }

    #[test]
    fn add_light_composes_with_presets() {
        let m = LoadedMap::builder("torchlit-cell", 4, 4)
            .add_light(MapLight::torch(MapPoint { x: 100.0, y: 100.0 }))
            .add_light(MapLight::moonlight(MapPoint { x: 200.0, y: 200.0 }))
            .build();
        assert_eq!(m.lights.len(), 2);
        assert_eq!(m.lights[0].color, "#ffaa55");
        assert_eq!(m.lights[1].color, "#aabbff");
    }
}
