//! Road-edit deformation pipeline (spec §4, milestone 0.2).
//!
//! `apply_road_edit` is the public entry point. The implementation
//! tries to *preserve* parcels when possible (re-projecting only the
//! frontage vertices onto the new road geometry; side and back
//! vertices stay fixed in absolute world coordinates per **D13**)
//! and falls back to regenerating the affected blocks when
//! preservation would violate any of:
//!
//! - I1 (resulting polygon is invalid),
//! - the rotation threshold of \cref{sec:edit-handling}
//!   (`params.max_side_rotation`),
//! - the area or frontage minimums (`min_area`, `min_frontage`).
//!
//! Buildings attached to surviving parcels are kept iff
//! `BuildingFitCheck::fits_in` returns true post-deformation;
//! otherwise the building is evicted.

use std::collections::BTreeSet;

use glam::DVec2;
use slotmap::Key;

use crate::config::SubdivisionParams;
use crate::error::ParcelError;
use crate::geometry::{Polygon, EPS_GEOM};
use crate::network::graph::FaceId;
use crate::network::{NodeId, RoadGraph, RoadId};
use crate::parcel::subdivide::subdivide_block;
use crate::parcel::ParcelId;

use super::{Parcel, ParcelSet};

/// A road-network mutation that may rearrange surrounding parcels.
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RoadEdit {
    /// Move the position of an existing intersection.
    MoveNode {
        /// Target node.
        node: NodeId,
        /// New position, meters.
        to: DVec2,
    },
    /// Split a road segment by inserting a new node along it.
    SplitSegment {
        /// Road being split.
        road: RoadId,
        /// Position of the new node (must lie on the segment).
        at: DVec2,
    },
    /// Delete a road segment.
    DeleteSegment {
        /// Road being deleted.
        road: RoadId,
    },
    /// Insert a new road between two existing nodes.
    InsertSegment {
        /// Endpoint A.
        from: NodeId,
        /// Endpoint B.
        to: NodeId,
    },
}

/// Outcome buckets for an edit application (spec §4.2).
#[derive(Debug, Clone, Default)]
#[must_use]
pub struct EditOutcome {
    /// Parcels that survived deformation.
    pub deformed: Vec<ParcelId>,
    /// Parcels whose block was re-subdivided.
    pub regenerated: Vec<ParcelId>,
    /// Parcels removed by the edit.
    pub condemned: Vec<ParcelId>,
    /// Parcels newly created during regeneration.
    pub created: Vec<ParcelId>,
    /// Parcels whose building was evicted because it no longer fits.
    pub evicted_buildings: Vec<ParcelId>,
}

/// Apply `edit` to `graph` and update `parcels` to match the new
/// geometry.
///
/// # Errors
///
/// Returns [`ParcelError::UnknownEntity`] if the edit references an
/// id that is no longer present, or [`ParcelError::Resubdivide`] if
/// re-subdivision of an affected block fails.
pub fn apply_road_edit(
    parcels: &mut ParcelSet,
    graph: &mut RoadGraph,
    edit: RoadEdit,
    params: &SubdivisionParams,
) -> Result<EditOutcome, ParcelError> {
    params.validate()?;
    let graph_before = graph.clone();
    apply_topology_mutation(graph, edit)?;
    graph.rebuild_topology()?;
    let mut outcome = EditOutcome::default();
    match edit {
        RoadEdit::MoveNode { node, .. } => {
            move_node_path(parcels, &graph_before, graph, node, params, &mut outcome)?;
        }
        RoadEdit::DeleteSegment { road } => {
            delete_segment_path(parcels, road, graph, params, &mut outcome)?;
        }
        RoadEdit::SplitSegment { road, at } => {
            split_segment_path(
                parcels,
                &graph_before,
                graph,
                road,
                at,
                params,
                &mut outcome,
            )?;
        }
        RoadEdit::InsertSegment { .. } => {
            // Insert preservation is milestone-0.3+ work — for now,
            // regenerate every face touched by the new segment.
            let affected = roads_affected_by_old_id(&graph_before, edit);
            regenerate_path(parcels, graph, params, &affected, &mut outcome)?;
        }
    }
    Ok(outcome)
}

// -----------------------------------------------------------------
// Move-node preserve path
// -----------------------------------------------------------------

fn move_node_path(
    parcels: &mut ParcelSet,
    graph_before: &RoadGraph,
    graph_after: &RoadGraph,
    node: NodeId,
    params: &SubdivisionParams,
    outcome: &mut EditOutcome,
) -> Result<(), ParcelError> {
    // Collect roads incident to the moved node (those still in the
    // graph after the edit).
    let incident_roads: Vec<RoadId> = graph_after
        .road_endpoints()
        .filter_map(|(rid, a, b)| {
            if a == node || b == node {
                Some(rid)
            } else {
                None
            }
        })
        .collect();

    // PROPOSE phase: walk every parcel on incident roads, gather
    // proposed vertex moves, and categorize each parcel as
    // Deformed/Untouched/Condemned/Regenerate. Nothing is committed
    // until the APPLY phase below.
    let mut to_regenerate: BTreeSet<FaceId> = BTreeSet::new();
    let mut proposed_moves: Vec<(super::VertexId, DVec2)> = Vec::new();
    let mut deformed_with_new_fi: Vec<(ParcelId, usize)> = Vec::new();
    let mut to_condemn: Vec<ParcelId> = Vec::new();
    for rid in &incident_roads {
        let parcel_ids: Vec<ParcelId> = parcels.parcels_on_road(*rid).collect::<Vec<_>>();
        for pid in parcel_ids {
            let result = match parcels.parcels.get(pid) {
                Some(parcel) => {
                    deform_parcel_after_road_move(parcel, *rid, graph_before, graph_after, params)
                }
                None => continue,
            };
            match result {
                DeformResult::Deformed {
                    vertex_moves,
                    new_frontage_edge_index,
                } => {
                    outcome.deformed.push(pid);
                    proposed_moves.extend(vertex_moves);
                    deformed_with_new_fi.push((pid, new_frontage_edge_index));
                }
                DeformResult::Untouched => {
                    // Parcel skipped; not added to any outcome bucket.
                }
                DeformResult::Condemned => {
                    let block = parcels.parcels.get(pid).map(|p| p.block);
                    to_condemn.push(pid);
                    outcome.condemned.push(pid);
                    if let Some(face) = block {
                        to_regenerate.insert(face);
                    }
                }
                DeformResult::Regenerate => {
                    if let Some(p) = parcels.parcels.get(pid) {
                        to_regenerate.insert(p.block);
                    }
                }
            }
        }
    }

    // APPLY phase. Vertex moves propagate atomically through the
    // shared-vertex registry, so adjacent parcels' shared boundaries
    // stay in lockstep. (D17.) Update frontage_edge_index for
    // deformed parcels separately because the polygon's vertex
    // ordering may have shifted from `Polygon::new`'s orientation
    // normalization in the proposal phase.
    for (vid, new_pos) in &proposed_moves {
        parcels.move_vertex(*vid, *new_pos);
    }
    for (pid, new_fi) in deformed_with_new_fi {
        if let Some(p) = parcels.parcels.get_mut(pid) {
            p.frontage_edge_index = new_fi;
        }
    }
    // BuildingFitCheck eviction now that polygons are committed.
    let deformed_pids: Vec<ParcelId> = outcome.deformed.clone();
    for pid in deformed_pids {
        if let Some(p) = parcels.parcels.get_mut(pid) {
            if p.building.is_some() {
                let fits = p.building.as_ref().is_some_and(|b| b.fits_in(p));
                if !fits {
                    p.building = None;
                    outcome.evicted_buildings.push(pid);
                }
            }
        }
    }
    // Drop condemned parcels.
    for pid in to_condemn {
        parcels.remove(pid);
    }

    // Regenerate the blocks where any parcel asked for it.
    if !to_regenerate.is_empty() {
        // Collect all parcels in those blocks; mark them as
        // regenerated and drop them, then re-subdivide.
        let block_set = to_regenerate.clone();
        let pids_in_blocks: Vec<ParcelId> = parcels
            .parcels
            .iter()
            .filter(|(_, p)| block_set.contains(&p.block))
            .map(|(id, _)| id)
            .collect();
        for pid in &pids_in_blocks {
            outcome.regenerated.push(*pid);
        }
        for pid in pids_in_blocks {
            drop_parcel(parcels, pid);
        }
        // Re-subdivide each block.
        let blocks = crate::network::blocks::extract_blocks(graph_after);
        for (block_idx, block) in blocks.iter().enumerate() {
            if !to_regenerate.contains(&block.face_id()) {
                continue;
            }
            let new_parcels = subdivide_block(graph_after, block, params, block_idx)?;
            for parcel in new_parcels {
                let pid = parcels.insert(parcel);
                outcome.created.push(pid);
            }
        }
    }
    Ok(())
}

#[derive(Debug, Clone)]
enum DeformResult {
    /// Parcel materially changed. The proposed `vertex_moves` are the
    /// `(VertexId, new_pos)` pairs to apply via
    /// `ParcelSet::move_vertex` after the whole pass has been
    /// validated. Deferring the apply keeps adjacent parcels'
    /// boundaries in lockstep — when two parcels share a frontage
    /// vertex they each propose a move for it, and the registry
    /// applies it once for all referrers.
    Deformed {
        vertex_moves: Vec<(super::VertexId, DVec2)>,
        new_frontage_edge_index: usize,
    },
    /// Parcel removed.
    Condemned,
    /// Parcel needs the block re-subdivided.
    Regenerate,
    /// Parcel's frontage stayed on the same road line and within
    /// segment bounds — no geometric change required (D14, §11).
    Untouched,
}

/// Check what should happen to `parcel` after the road geometry
/// changed. **Pure**: computes proposed vertex moves and validates
/// them against rotation/area thresholds, but does not modify the
/// parcel. The caller is responsible for applying the proposed
/// moves via [`ParcelSet::move_vertex`] (which propagates to every
/// parcel referencing each vertex).
fn deform_parcel_after_road_move(
    parcel: &Parcel,
    road: RoadId,
    graph_before: &RoadGraph,
    graph_after: &RoadGraph,
    params: &SubdivisionParams,
) -> DeformResult {
    let Some((a, b)) = graph_after.road_nodes(road) else {
        return DeformResult::Condemned;
    };
    let (Some(pa_before), Some(pb_before), Some(pa_after), Some(pb_after)) = (
        graph_before.node_position(a),
        graph_before.node_position(b),
        graph_after.node_position(a),
        graph_after.node_position(b),
    ) else {
        return DeformResult::Condemned;
    };
    let road_vec_before = pb_before - pa_before;
    let len_sq_before = road_vec_before.length_squared();
    if len_sq_before < EPS_GEOM * EPS_GEOM {
        return DeformResult::Regenerate;
    }
    let road_vec_after = pb_after - pa_after;

    let n = parcel.polygon.len();
    let fi = parcel.frontage_edge_index;
    let p_a = parcel.polygon.vertices()[fi];
    let p_b = parcel.polygon.vertices()[(fi + 1) % n];

    // Minimum-change check (D14): if the road's *line* didn't change
    // — i.e. the new segment is collinear with the old, just maybe
    // longer or shorter — and the parcel's frontage is still within
    // the new segment bounds, we skip the deformation entirely. The
    // parcel keeps its absolute coordinates. This avoids gratuitously
    // shifting parcels along a road that only got stretched.
    {
        let dir_before = road_vec_before / road_vec_before.length();
        let len_after = road_vec_after.length();
        if len_after > EPS_GEOM {
            let dir_after = road_vec_after / len_after;
            let parallel = (dir_before.x * dir_after.y - dir_before.y * dir_after.x).abs() < 1e-6;
            if parallel {
                let new_normal = DVec2::new(-dir_after.y, dir_after.x);
                let perp_dist = (p_a - pa_after).dot(new_normal).abs();
                if perp_dist < EPS_GEOM {
                    let t_a_new = (p_a - pa_after).dot(dir_after);
                    let t_b_new = (p_b - pa_after).dot(dir_after);
                    if t_a_new >= -EPS_GEOM
                        && t_b_new >= -EPS_GEOM
                        && t_a_new <= len_after + EPS_GEOM
                        && t_b_new <= len_after + EPS_GEOM
                    {
                        return DeformResult::Untouched;
                    }
                }
            }
        }
    }

    let mut new_verts: Vec<DVec2> = parcel.polygon.vertices().to_vec();
    let t_a = (p_a - pa_before).dot(road_vec_before) / len_sq_before;
    let t_b = (p_b - pa_before).dot(road_vec_before) / len_sq_before;
    new_verts[fi] = pa_after + road_vec_after * t_a;
    new_verts[(fi + 1) % n] = pa_after + road_vec_after * t_b;

    // Side rotation check before constructing the polygon (so we can
    // bail to Regenerate cheaply).
    let prev_idx = (fi + n - 1) % n;
    let next_idx = (fi + 2) % n;
    let old_side_in =
        (parcel.polygon.vertices()[fi] - parcel.polygon.vertices()[prev_idx]).normalize_or_zero();
    let new_side_in = (new_verts[fi] - new_verts[prev_idx]).normalize_or_zero();
    let old_side_out = (parcel.polygon.vertices()[next_idx]
        - parcel.polygon.vertices()[(fi + 1) % n])
        .normalize_or_zero();
    let new_side_out = (new_verts[next_idx] - new_verts[(fi + 1) % n]).normalize_or_zero();
    let rot_in = old_side_in.dot(new_side_in).clamp(-1.0, 1.0).acos();
    let rot_out = old_side_out.dot(new_side_out).clamp(-1.0, 1.0).acos();
    if rot_in > params.max_side_rotation || rot_out > params.max_side_rotation {
        return DeformResult::Regenerate;
    }

    let Ok(new_poly) = Polygon::new(new_verts) else {
        return DeformResult::Regenerate;
    };

    if new_poly.area() < params.min_area {
        return DeformResult::Condemned;
    }
    let v = new_poly.vertices();
    // Find the new frontage edge index — `Polygon::new` may have
    // reordered vertices if it had to flip orientation. We locate
    // the edge whose midpoint lies on the new road segment.
    let new_fi = match find_frontage_index(&new_poly, pa_after, pb_after) {
        Some(idx) => idx,
        None => return DeformResult::Regenerate,
    };
    let new_frontage = (v[(new_fi + 1) % v.len()] - v[new_fi]).length();
    if new_frontage < params.min_frontage {
        return DeformResult::Condemned;
    }

    // Propose: only the two frontage vertices move. Side and back
    // vertices stay fixed (D13). Vertex IDs come from the parcel's
    // shared registry, so when these moves are applied via
    // `ParcelSet::move_vertex` they propagate to every parcel
    // referencing the same vertex.
    let vid_a = parcel.vertex_ids[fi];
    let vid_b = parcel.vertex_ids[(fi + 1) % n];
    DeformResult::Deformed {
        vertex_moves: vec![
            (vid_a, pa_after + road_vec_after * t_a),
            (vid_b, pa_after + road_vec_after * t_b),
        ],
        new_frontage_edge_index: new_fi,
    }
}

fn find_frontage_index(polygon: &Polygon, road_a: DVec2, road_b: DVec2) -> Option<usize> {
    let road_dir = (road_b - road_a).normalize_or_zero();
    if road_dir.length_squared() < EPS_GEOM * EPS_GEOM {
        return None;
    }
    let road_normal = DVec2::new(-road_dir.y, road_dir.x);
    let v = polygon.vertices();
    let n = v.len();
    let tol = 100.0 * EPS_GEOM;
    let mut best: Option<(usize, f64)> = None;
    for i in 0..n {
        let p = v[i];
        let q = v[(i + 1) % n];
        let dp = (p - road_a).dot(road_normal).abs();
        let dq = (q - road_a).dot(road_normal).abs();
        if dp < tol && dq < tol {
            let edge_dir = (q - p).normalize_or_zero();
            if edge_dir.dot(road_dir).abs() > 0.5 {
                let len = (q - p).length();
                if best.is_none_or(|(_, blen)| len > blen) {
                    best = Some((i, len));
                }
            }
        }
    }
    best.map(|(i, _)| i)
}

// -----------------------------------------------------------------
// Split-segment preserve path
// -----------------------------------------------------------------

fn split_segment_path(
    parcels: &mut ParcelSet,
    graph_before: &RoadGraph,
    graph_after: &RoadGraph,
    old_road: RoadId,
    split_point: DVec2,
    _params: &SubdivisionParams,
    outcome: &mut EditOutcome,
) -> Result<(), ParcelError> {
    let Some((a, b)) = graph_before.road_nodes(old_road) else {
        return Err(ParcelError::UnknownEntity);
    };
    let pos_a = graph_before
        .node_position(a)
        .ok_or(ParcelError::UnknownEntity)?;
    let pos_b = graph_before
        .node_position(b)
        .ok_or(ParcelError::UnknownEntity)?;
    // Find the new node m in graph_after at split_point and the two
    // new roads it splits the old road into.
    let mut new_node: Option<NodeId> = None;
    for (nid, node) in &graph_after.nodes {
        if (node.pos - split_point).length() < 0.01 {
            new_node = Some(nid);
            break;
        }
    }
    let m = new_node.ok_or(ParcelError::InconsistentEdit("split node not found".into()))?;
    let r1 = graph_after
        .find_road_between(a, m)
        .ok_or(ParcelError::InconsistentEdit(
            "split road r1 missing".into(),
        ))?;
    let r2 = graph_after
        .find_road_between(m, b)
        .ok_or(ParcelError::InconsistentEdit(
            "split road r2 missing".into(),
        ))?;

    let road_vec = pos_b - pos_a;
    let road_len_sq = road_vec.length_squared();
    if road_len_sq < EPS_GEOM * EPS_GEOM {
        return Err(ParcelError::InconsistentEdit("zero-length old road".into()));
    }
    let t_split = (split_point - pos_a).dot(road_vec) / road_len_sq;

    let parcel_ids: Vec<ParcelId> = parcels.parcels_on_road(old_road).collect();
    for pid in parcel_ids {
        let Some(parcel) = parcels.parcels.get(pid) else {
            continue;
        };
        let n = parcel.polygon.len();
        let fi = parcel.frontage_edge_index;
        let v = parcel.polygon.vertices();
        let p_a = v[fi];
        let p_b = v[(fi + 1) % n];
        let t_a = (p_a - pos_a).dot(road_vec) / road_len_sq;
        let t_b = (p_b - pos_a).dot(road_vec) / road_len_sq;
        let (t_lo, t_hi) = if t_a < t_b { (t_a, t_b) } else { (t_b, t_a) };

        if t_hi <= t_split + EPS_GEOM {
            // Frontage entirely on the a-side → r1.
            // No geometric change; just rebind frontage_road.
            rebind_frontage_road(parcels, pid, r1);
            outcome.deformed.push(pid);
        } else if t_lo >= t_split - EPS_GEOM {
            // Frontage entirely on the b-side → r2.
            rebind_frontage_road(parcels, pid, r2);
            outcome.deformed.push(pid);
        } else {
            // Frontage spans the split → cut into two.
            // Only handle the simple 4-vertex case; fall through to
            // condemn for higher-vertex parcels.
            if n != 4 {
                drop_parcel(parcels, pid);
                outcome.condemned.push(pid);
                continue;
            }
            let q_b = v[(fi + 2) % n];
            let q_a = v[(fi + 3) % n];
            let frontage_vec = p_b - p_a;
            let len_sq = frontage_vec.length_squared();
            if len_sq < EPS_GEOM * EPS_GEOM {
                drop_parcel(parcels, pid);
                outcome.condemned.push(pid);
                continue;
            }
            let t_local = (split_point - p_a).dot(frontage_vec) / len_sq;
            if t_local <= EPS_GEOM || t_local >= 1.0 - EPS_GEOM {
                // Split coincident with an endpoint — just rebind.
                let target = if t_local <= 0.5 { r1 } else { r2 };
                rebind_frontage_road(parcels, pid, target);
                outcome.deformed.push(pid);
                continue;
            }
            let q_star = q_a + (q_b - q_a) * t_local;
            // The "a side" of the split goes to r1, "b side" to r2.
            // p_a is closer to a if t_a < t_b. (Polygon CCW means
            // frontage goes from p_a to p_b along the road, but
            // direction can be either way relative to road A→B.)
            let (a_side_road, b_side_road) = if t_a < t_b { (r1, r2) } else { (r2, r1) };
            // Build new polygons.
            let poly_a = Polygon::new(vec![p_a, split_point, q_star, q_a]);
            let poly_b = Polygon::new(vec![split_point, p_b, q_b, q_star]);
            let (Ok(poly_a), Ok(poly_b)) = (poly_a, poly_b) else {
                drop_parcel(parcels, pid);
                outcome.condemned.push(pid);
                continue;
            };
            let block = parcel.block;
            let area_a = poly_a.area();
            let area_b = poly_b.area();
            let parcel_owned = parcels
                .parcels
                .remove(pid)
                .ok_or(ParcelError::UnknownEntity)?;
            if let Some(v) = parcels.by_block.get_mut(&parcel_owned.block) {
                v.retain(|&x| x != pid);
            }
            if let Some(v) = parcels.by_road.get_mut(&parcel_owned.frontage_road) {
                v.retain(|&x| x != pid);
            }
            outcome.condemned.push(pid);
            // Building stays with the larger half.
            let mut building_a: Option<super::BuildingHandle> = None;
            let mut building_b: Option<super::BuildingHandle> = None;
            if area_a >= area_b {
                building_a = parcel_owned.building;
            } else {
                building_b = parcel_owned.building;
            }
            let edge_kinds = crate::parcel::classify::classify_edges(4, 0);
            let new_a = Parcel {
                polygon: poly_a,
                vertex_ids: Vec::new(),
                edge_kinds: edge_kinds.clone(),
                frontage_road: a_side_road,
                frontage_edge_index: 0,
                block,
                building: building_a,
            };
            let new_b = Parcel {
                polygon: poly_b,
                vertex_ids: Vec::new(),
                edge_kinds,
                frontage_road: b_side_road,
                frontage_edge_index: 0,
                block,
                building: building_b,
            };
            let new_a_id = parcels.insert(new_a);
            let new_b_id = parcels.insert(new_b);
            outcome.created.push(new_a_id);
            outcome.created.push(new_b_id);
        }
    }
    Ok(())
}

fn rebind_frontage_road(parcels: &mut ParcelSet, pid: ParcelId, new_road: RoadId) {
    let old_road = if let Some(p) = parcels.parcels.get(pid) {
        p.frontage_road
    } else {
        return;
    };
    if old_road == new_road {
        return;
    }
    if let Some(p) = parcels.parcels.get_mut(pid) {
        p.frontage_road = new_road;
    }
    if let Some(v) = parcels.by_road.get_mut(&old_road) {
        v.retain(|&x| x != pid);
    }
    parcels.by_road.entry(new_road).or_default().push(pid);
}

// -----------------------------------------------------------------
// Delete-segment path: condemn + regenerate-block
// -----------------------------------------------------------------

fn delete_segment_path(
    parcels: &mut ParcelSet,
    road: RoadId,
    graph_after: &RoadGraph,
    params: &SubdivisionParams,
    outcome: &mut EditOutcome,
) -> Result<(), ParcelError> {
    // Collect parcels with frontage on the deleted road; condemn
    // them. The block topology likely changed; regenerate every
    // block touched by any condemned parcel.
    let pids: Vec<ParcelId> = parcels.parcels_on_road(road).collect::<Vec<_>>();
    let mut affected_blocks: BTreeSet<FaceId> = BTreeSet::new();
    for pid in pids {
        if let Some(p) = parcels.parcels.get(pid) {
            affected_blocks.insert(p.block);
        }
        outcome.condemned.push(pid);
        drop_parcel(parcels, pid);
    }
    // Regenerate.
    let blocks = crate::network::blocks::extract_blocks(graph_after);
    for (block_idx, block) in blocks.iter().enumerate() {
        // After deletion, the face IDs may be different — regenerate
        // anything that was previously affected. The simplest
        // correct rule: regenerate any face that currently has no
        // parcels.
        let face = block.face_id();
        if parcels.by_block.get(&face).is_some_and(|v| !v.is_empty()) {
            continue;
        }
        let new_parcels = subdivide_block(graph_after, block, params, block_idx)?;
        for parcel in new_parcels {
            let pid = parcels.insert(parcel);
            outcome.created.push(pid);
        }
        let _ = affected_blocks;
    }
    Ok(())
}

// -----------------------------------------------------------------
// Regenerate-only fallback (Split, Insert)
// -----------------------------------------------------------------

fn regenerate_path(
    parcels: &mut ParcelSet,
    graph_after: &RoadGraph,
    params: &SubdivisionParams,
    affected_roads: &[RoadId],
    outcome: &mut EditOutcome,
) -> Result<(), ParcelError> {
    let mut to_drop: Vec<ParcelId> = Vec::new();
    for rid in affected_roads {
        if let Some(ids) = parcels.by_road.get(rid) {
            to_drop.extend(ids.iter().copied());
        }
    }
    to_drop.sort();
    to_drop.dedup();
    for pid in &to_drop {
        outcome.condemned.push(*pid);
    }
    for pid in to_drop {
        drop_parcel(parcels, pid);
    }
    let blocks = crate::network::blocks::extract_blocks(graph_after);
    for (block_idx, block) in blocks.iter().enumerate() {
        let face = block.face_id();
        if parcels.by_block.get(&face).is_some_and(|v| !v.is_empty()) {
            continue;
        }
        let new_parcels = subdivide_block(graph_after, block, params, block_idx)?;
        for parcel in new_parcels {
            let pid = parcels.insert(parcel);
            outcome.created.push(pid);
        }
    }
    Ok(())
}

fn roads_affected_by_old_id(graph: &RoadGraph, edit: RoadEdit) -> Vec<RoadId> {
    let mut roads = Vec::new();
    match edit {
        RoadEdit::MoveNode { node, .. } => {
            for (rid, a, b) in graph.road_endpoints() {
                if a == node || b == node {
                    roads.push(rid);
                }
            }
        }
        RoadEdit::SplitSegment { road, .. } | RoadEdit::DeleteSegment { road } => {
            roads.push(road);
        }
        RoadEdit::InsertSegment { from, to } => {
            for (rid, a, b) in graph.road_endpoints() {
                if a == from || b == from || a == to || b == to {
                    roads.push(rid);
                }
            }
        }
    }
    roads
}

// -----------------------------------------------------------------
// Mutation helpers + parcel-set bookkeeping
// -----------------------------------------------------------------

fn apply_topology_mutation(graph: &mut RoadGraph, edit: RoadEdit) -> Result<(), ParcelError> {
    match edit {
        RoadEdit::MoveNode { node, to } => {
            let n = graph
                .nodes
                .get_mut(node)
                .ok_or(ParcelError::UnknownEntity)?;
            n.pos = to;
            graph.topology_valid = false;
            Ok(())
        }
        RoadEdit::DeleteSegment { road } => {
            graph.roads.remove(road).ok_or(ParcelError::UnknownEntity)?;
            graph.topology_valid = false;
            Ok(())
        }
        RoadEdit::SplitSegment { road, at } => {
            let (a, b) = graph.road_nodes(road).ok_or(ParcelError::UnknownEntity)?;
            graph.roads.remove(road);
            let m = graph.add_node(at);
            graph
                .add_road(a, m)
                .map_err(|_| ParcelError::InconsistentEdit("split a->m".into()))?;
            graph
                .add_road(m, b)
                .map_err(|_| ParcelError::InconsistentEdit("split m->b".into()))?;
            graph.topology_valid = false;
            Ok(())
        }
        RoadEdit::InsertSegment { from, to } => {
            graph
                .add_road(from, to)
                .map_err(|_| ParcelError::InconsistentEdit("insert".into()))?;
            graph.topology_valid = false;
            Ok(())
        }
    }
}

fn drop_parcel(parcels: &mut ParcelSet, pid: ParcelId) {
    parcels.remove(pid);
}

#[allow(dead_code)]
fn _unused_face_id() -> FaceId {
    FaceId::null()
}
