using Theriapolis.Core.Util;
using Theriapolis.Core.World.Polylines;

namespace Theriapolis.Core.World.Generation.Stages;

/// <summary>
/// Stage 18 — PolylineCleanup
/// Post-processing pass after roads, rivers, and rails are generated.
/// Phase 1: Clusters nearby endpoints and snaps them to a shared centroid.
/// Phase 2: Snaps remaining endpoints to nearby polyline bodies (T-junctions).
/// Phase 3: Merges overlapping parallel segments by snapping interior points to longer polylines.
/// Phase 4: Removes small self-intersection loops and near-duplicate consecutive points.
/// </summary>
public sealed class PolylineCleanupStage : IWorldGenStage
{
    public string Name => "PolylineCleanup";

    public void Run(WorldGenContext ctx)
    {
        var world = ctx.World;

        int roadFixes  = SnapAndConnect(world, world.Roads,  dropSubsumed: true,  splitAtSettlements: true);
        int railFixes  = SnapAndConnect(world, world.Rails,  dropSubsumed: false, splitAtSettlements: false);
        int riverFixes = SnapAndConnect(world, world.Rivers, dropSubsumed: false, splitAtSettlements: false);

        // Ensure all non-POI settlements have at least one road endpoint nearby.
        // Cleanup can drop short connector roads during overlap merging, leaving
        // settlements stranded. This pass creates stubs for any that lost connection.
        int connectors = EnsureSettlementConnectivity(world);

        // Bridge detection must run AFTER cleanup so bridges align with
        // post-snapping road geometry.
        world.Bridges.Clear();
        RoadNetworkGenStage.DetectBridges(world);

        ctx.LogMessage($"[PolylineCleanup] Fixed {roadFixes} road, {railFixes} rail, {riverFixes} river connections; {connectors} settlement connectors; placed {world.Bridges.Count} bridges.");
    }

    private static int SnapAndConnect(WorldState world, List<Polyline> polylines, bool dropSubsumed, bool splitAtSettlements)
    {
        if (polylines.Count < 2) return 0;

        int fixes = 0;

        // ── Phase 1: Cluster nearby endpoints via Union-Find ────────────────
        var (clusteredSet, clusterFixes) = ClusterEndpoints(world, polylines);
        fixes += clusterFixes;

        // ── Phase 2: Snap unclustered endpoints to nearest polyline body ────
        fixes += SnapToBody(world, polylines, clusteredSet);

        // ── Phase 3: Merge overlapping parallel segments ────────────────────
        fixes += MergeOverlapping(polylines, dropSubsumed);

        // ── Phase 3b: Split polylines that pass through a settlement centre ─
        // A road generated to connect A→B can clip through C's centre tile
        // when the A* path or its smoothed body happens to traverse C. Without
        // splitting, the polyline visually passes through C but doesn't
        // terminate there — leaving C apparently disconnected from the trunk
        // even though its tile carries HasRoad. Split into A→C and C→B so C
        // becomes a proper network junction.
        if (splitAtSettlements)
            fixes += SplitAtNonEndpointSettlements(world, polylines);

        // ── Phase 4: Clean up artifacts ─────────────────────────────────────
        foreach (var poly in polylines)
        {
            RemoveSmallLoops(poly.Points);
            RemoveNearDuplicates(poly.Points);
        }

        // ── Phase 5: Rebuild simplified points ──────────────────────────────
        foreach (var poly in polylines)
            if (poly.Points.Count >= 2)
                poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);

        return fixes;
    }

    // ── Phase 1: Endpoint clustering ────────────────────────────────────────

    private static (HashSet<int> clustered, int fixes) ClusterEndpoints(WorldState world, List<Polyline> polylines)
    {
        var endpoints = CollectEndpoints(polylines);
        int n = endpoints.Count;

        // Union-Find
        var parent = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
        int Find(int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
        void Union(int a, int b) { parent[Find(a)] = Find(b); }

        float snapDistSq = C.POLYLINE_SNAP_ENDPOINT_DIST * C.POLYLINE_SNAP_ENDPOINT_DIST;

        for (int i = 0; i < n; i++)
        {
            var posI = GetEndpoint(polylines, endpoints[i]);
            for (int j = i + 1; j < n; j++)
            {
                if (endpoints[i].polyIdx == endpoints[j].polyIdx) continue;
                var posJ = GetEndpoint(polylines, endpoints[j]);
                if (Vec2.DistSq(posI, posJ) >= snapDistSq) continue;

                // Skip pair-union when both endpoints are independently close
                // to a shared third polyline's body AND their body-snap targets
                // are far enough apart to represent distinct T-junctions. The
                // Union-Find centroid of such a pair sits OFF that body,
                // creating a visible forked gap between both roads and the
                // through-road they were each already meeting. Phase 2
                // body-snap produces two clean T-junctions instead.
                if (EndpointsShareDistinctCommonBody(
                        polylines, posI, posJ,
                        endpoints[i].polyIdx, endpoints[j].polyIdx))
                    continue;

                Union(i, j);
            }
        }

        // Group clusters and snap to centroid
        var clustered = new HashSet<int>();
        var clusters = new Dictionary<int, List<int>>();
        for (int i = 0; i < n; i++)
        {
            int root = Find(i);
            if (!clusters.TryGetValue(root, out var list))
                clusters[root] = list = new List<int>();
            list.Add(i);
        }

        int fixes = 0;
        // Max distance from a settlement-anchor endpoint for a cluster member to
        // also snap onto the settlement. Union-Find can transitively chain endpoints
        // across several pair-wise links (each up to POLYLINE_SNAP_ENDPOINT_DIST),
        // so a footpath T-junction on a main road that happens to pass near a
        // settlement could otherwise be yanked to the settlement center, creating
        // a long straight trailing segment. Keep the settlement anchor influence
        // local — only endpoints actually within ~2 tiles of the settlement snap.
        float settlementAnchorDistSq = C.SETTLEMENT_CONNECT_DIST * C.SETTLEMENT_CONNECT_DIST;

        foreach (var (_, cluster) in clusters)
        {
            if (cluster.Count < 2) continue;

            // If any endpoint in the cluster lands on a settlement tile, anchor
            // nearby cluster members to that settlement center instead of the
            // geometric centroid. This prevents roads being yanked away from
            // settlements. Far-away cluster members (reached via transitive
            // union chaining) are handled separately so they are not pulled
            // across several tiles onto the settlement.
            Vec2? settlementAnchor = null;
            foreach (int idx in cluster)
            {
                var pt = GetEndpoint(polylines, endpoints[idx]);
                if (IsOnSettlement(world, pt))
                {
                    settlementAnchor = pt;
                    break;
                }
            }

            if (settlementAnchor.HasValue)
            {
                Vec2 anchor = settlementAnchor.Value;

                // Polylines that will snap to the anchor — "siblings" whose
                // bodies are candidate branch targets for the remaining members.
                var siblingPolyIdxs = new HashSet<int>();
                foreach (int idx in cluster)
                {
                    var ept = GetEndpoint(polylines, endpoints[idx]);
                    if (Vec2.DistSq(ept, anchor) < settlementAnchorDistSq)
                        siblingPolyIdxs.Add(endpoints[idx].polyIdx);
                }

                // If an off-settlement endpoint within the anchor radius lies
                // within BRANCH_PREFER_DIST of a sibling polyline's body, we
                // defer it to Phase 2 body-snapping instead of pulling it onto
                // the settlement center. Otherwise the two roads run parallel
                // into the settlement on their final approach — a visual
                // "extra road" right alongside the existing road — when a
                // T-junction branch would look natural.
                const float BRANCH_PREFER_DIST = 16f; // 0.5 tiles
                float branchPreferDistSq = BRANCH_PREFER_DIST * BRANCH_PREFER_DIST;

                foreach (int idx in cluster)
                {
                    var pt = GetEndpoint(polylines, endpoints[idx]);
                    float distToAnchorSq = Vec2.DistSq(pt, anchor);
                    if (distToAnchorSq >= settlementAnchorDistSq) continue;
                    // Far-from-anchor cluster member — leave unclustered. Phase 2
                    // (SnapToBody) will snap each one onto the nearest polyline
                    // body at its original T-junction position. Sub-clustering
                    // these together would incorrectly merge genuinely distinct
                    // T-junctions onto the same point.

                    // Don't snap if the polyline's OTHER endpoint is also at
                    // the anchor — snapping both would collapse the polyline
                    // to a single point. This happens for short stubs leaving
                    // a settlement: both start and end lie inside the 3×3
                    // settlement footprint / anchor radius. Phase 2 body-snap
                    // will then route the non-settlement end onto the nearby
                    // polyline body it was meant to join.
                    var ownPts = polylines[endpoints[idx].polyIdx].Points;
                    Vec2 otherEp = endpoints[idx].isStart ? ownPts[^1] : ownPts[0];
                    if (Vec2.DistSq(otherEp, anchor) < settlementAnchorDistSq) continue;

                    // If pt is essentially already at the anchor (within
                    // BRANCH_PREFER_DIST), snap unconditionally — there's no
                    // branch geometry to prefer since body-snap would yield
                    // the same position.
                    if (distToAnchorSq >= branchPreferDistSq)
                    {
                        // pt is within the settlement's approach zone but not
                        // at its center. Check whether a sibling polyline's
                        // body passes near pt; if so, defer to Phase 2 so we
                        // get a T-junction branch onto the sibling rather
                        // than a redundant parallel approach to the anchor.
                        int ownPolyIdx = endpoints[idx].polyIdx;
                        bool preferBranch = false;
                        foreach (int otherPolyIdx in siblingPolyIdxs)
                        {
                            if (otherPolyIdx == ownPolyIdx) continue;
                            var otherPts = polylines[otherPolyIdx].Points;
                            if (otherPts.Count < 2) continue;

                            NearestPointOnPolyline(pt, otherPts, out float dSq);
                            // Only branch when the body is meaningfully closer
                            // than the anchor — otherwise a body-snap just
                            // reproduces the anchor-snap in a different way.
                            if (dSq < branchPreferDistSq && dSq + 4f < distToAnchorSq)
                            {
                                preferBranch = true;
                                break;
                            }
                        }
                        if (preferBranch) continue;
                    }

                    SnapEndpoint(polylines[endpoints[idx].polyIdx], endpoints[idx].isStart, anchor);
                    clustered.Add(idx);
                    fixes++;
                }
                continue;
            }

            // No settlement in cluster: snap all to the geometric centroid.
            Vec2 centroid = new Vec2(0, 0);
            foreach (int idx in cluster)
                centroid = centroid + GetEndpoint(polylines, endpoints[idx]);
            var target = centroid / cluster.Count;

            foreach (int idx in cluster)
            {
                SnapEndpoint(polylines[endpoints[idx].polyIdx], endpoints[idx].isStart, target);
                clustered.Add(idx);
                fixes++;
            }
        }

        return (clustered, fixes);
    }

    /// <summary>
    /// True when both endpoints lie within body-snap distance of the same
    /// third polyline's body AND their body-snap targets are far enough apart
    /// to represent distinct T-junctions. Used by Phase 1 clustering to avoid
    /// fusing two independent junction approaches into a single off-body
    /// centroid. Threshold is 2 tiles: targets closer than that are treated
    /// as a shared junction on the body and allowed to cluster normally.
    /// </summary>
    private static bool EndpointsShareDistinctCommonBody(
        List<Polyline> polylines, Vec2 ptA, Vec2 ptB, int polyIdxA, int polyIdxB)
    {
        float bodyDistSq = C.POLYLINE_SNAP_BODY_DIST * C.POLYLINE_SNAP_BODY_DIST;
        const float DISTINCT_TARGET_DIST = 64f; // 2 tiles
        float distinctTargetDistSq = DISTINCT_TARGET_DIST * DISTINCT_TARGET_DIST;

        for (int k = 0; k < polylines.Count; k++)
        {
            if (k == polyIdxA || k == polyIdxB) continue;
            var body = polylines[k].Points;
            if (body.Count < 2) continue;

            var nearA = NearestPointOnPolyline(ptA, body, out float distSqA);
            if (distSqA >= bodyDistSq) continue;

            var nearB = NearestPointOnPolyline(ptB, body, out float distSqB);
            if (distSqB >= bodyDistSq) continue;

            if (Vec2.DistSq(nearA, nearB) >= distinctTargetDistSq)
                return true;
        }
        return false;
    }

    /// <summary>Convert a world-pixel point to a tile coord and test IsSettlement.</summary>
    private static bool IsOnSettlement(WorldState world, Vec2 worldPixel)
    {
        int tx = (int)(worldPixel.X / C.WORLD_TILE_PIXELS);
        int ty = (int)(worldPixel.Y / C.WORLD_TILE_PIXELS);
        if ((uint)tx >= C.WORLD_WIDTH_TILES || (uint)ty >= C.WORLD_HEIGHT_TILES) return false;
        return (world.Tiles[tx, ty].Features & FeatureFlags.IsSettlement) != 0;
    }

    /// <summary>
    /// Tighter variant of <see cref="IsOnSettlement"/>: true only when the
    /// given world-pixel lies in the tile that is a settlement's actual centre
    /// (i.e. matches some settlement's <c>TileX</c>/<c>TileY</c>). Used to
    /// distinguish "this endpoint is at a settlement's destination point"
    /// from "this endpoint is somewhere inside the fat settlement footprint".
    /// </summary>
    private static bool IsAtSettlementCentre(WorldState world, Vec2 worldPixel)
    {
        int tx = (int)(worldPixel.X / C.WORLD_TILE_PIXELS);
        int ty = (int)(worldPixel.Y / C.WORLD_TILE_PIXELS);
        foreach (var s in world.Settlements)
            if (s.TileX == tx && s.TileY == ty) return true;
        return false;
    }

    // ── Phase 2: Body snapping ──────────────────────────────────────────────

    private static int SnapToBody(WorldState world, List<Polyline> polylines, HashSet<int> clustered)
    {
        var endpoints = CollectEndpoints(polylines);
        float bodySnapDistSq = C.POLYLINE_SNAP_BODY_DIST * C.POLYLINE_SNAP_BODY_DIST;
        int fixes = 0;

        for (int i = 0; i < endpoints.Count; i++)
        {
            if (clustered.Contains(i)) continue;

            Vec2 endpoint = GetEndpoint(polylines, endpoints[i]);

            // Endpoints sitting on a settlement's exact centre tile are at
            // their correct terminus — don't displace them. We can't use the
            // IsSettlement flag here because tier-2+ settlements occupy a 3×3
            // footprint; a junction anchor produced by SplitByExistingFeature
            // can land on a non-centre footprint tile and needs snapping onto
            // the nearby polyline body for visual continuity.
            if (IsAtSettlementCentre(world, endpoint)) continue;

            float bestDistSq = bodySnapDistSq;
            Vec2 bestPoint = default;
            bool found = false;

            for (int j = 0; j < polylines.Count; j++)
            {
                if (j == endpoints[i].polyIdx) continue;
                var pts = polylines[j].Points;
                if (pts.Count < 2) continue;

                var nearPt = NearestPointOnPolyline(endpoint, pts, out float dSq);
                if (dSq < bestDistSq)
                {
                    bestDistSq = dSq;
                    bestPoint = nearPt;
                    found = true;
                }
            }

            if (found)
            {
                SnapEndpoint(polylines[endpoints[i].polyIdx], endpoints[i].isStart, bestPoint);
                fixes++;
            }
        }

        return fixes;
    }

    // ── Phase 3: Merge overlapping parallel segments ────────────────────────

    /// <summary>
    /// If <paramref name="dropSubsumed"/> is true, polylines whose geometry is mostly
    /// coincident with a larger polyline are trimmed to an endpoint stub (or deleted
    /// outright when both endpoints already lie on the larger polyline). This is
    /// desirable for roads — a Tier 4 footpath parallel to a Highway should collapse
    /// to a short stub onto the highway — but not for rivers or rail.
    /// </summary>
    private static int MergeOverlapping(List<Polyline> polylines, bool dropSubsumed)
    {
        if (polylines.Count < 2) return 0;

        float mergeDist   = C.POLYLINE_MERGE_DIST;
        float mergeDistSq = mergeDist * mergeDist;
        int merges = 0;

        var bounds = new (float minX, float minY, float maxX, float maxY)[polylines.Count];
        for (int b = 0; b < polylines.Count; b++)
        {
            float minX = float.MaxValue, minY = float.MaxValue;
            float maxX = float.MinValue, maxY = float.MinValue;
            foreach (var p in polylines[b].Points)
            {
                if (p.X < minX) minX = p.X;
                if (p.Y < minY) minY = p.Y;
                if (p.X > maxX) maxX = p.X;
                if (p.Y > maxY) maxY = p.Y;
            }
            bounds[b] = (minX - mergeDist, minY - mergeDist, maxX + mergeDist, maxY + mergeDist);
        }

        var toRemove = new HashSet<int>();

        for (int a = 0; a < polylines.Count; a++)
        {
            if (toRemove.Contains(a)) continue;
            var ptsA = polylines[a].Points;
            if (ptsA.Count < 4) continue;
            int countA = ptsA.Count;

            // Per-point nearest projection onto every longer candidate.
            var bestDistSqArr = new float[countA];
            var bestPtArr     = new Vec2[countA];
            var bestCandArr   = new int[countA];
            for (int i = 0; i < countA; i++) { bestDistSqArr[i] = mergeDistSq; bestCandArr[i] = -1; }

            // Body-junction candidates: polylines B where A's endpoint already
            // sits ON B's body (not B's endpoint). These represent junction
            // connections already established by Phase 2's SnapToBody. A's
            // interior points should NOT be snapped onto such a B because
            // A is approaching B from the side and its interior geometry is
            // its own path — snapping part of it creates the triangle artifact
            // where the snapped run diverges from the unsnapped tail.
            // Subsumption still works via bestCandArr (so truly redundant A's
            // are still dropped); only the per-point fallback snap is skipped.
            var bodyJunctionCandidates = new HashSet<int>();

            // Snaps landing near polyline B's endpoints are a chance-proximity
            // pattern (two roads converging at a junction) rather than a
            // genuine parallel overlap. Rejecting them prevents the common
            // pathology where a run of points on A all collapse onto the same
            // endpoint of B, creating a V-kink that later looks like a loop.
            const float ENDPOINT_GUARD_DIST = 24f;
            float endpointGuardSq = ENDPOINT_GUARD_DIST * ENDPOINT_GUARD_DIST;

            // When A shares an endpoint with B (e.g. both roads leave the same
            // settlement), A's early points near that shared endpoint are close
            // to B's matching side by construction, not by parallel overlap.
            // Use a wider guard on the shared side so divergent roads don't
            // get their early points collapsed onto B.
            const float SHARED_ENDPOINT_GUARD_DIST = 96f; // 3 tiles
            float sharedEndpointGuardSq = SHARED_ENDPOINT_GUARD_DIST * SHARED_ENDPOINT_GUARD_DIST;

            // Tangent-alignment guard. A genuine parallel overlap has A and B
            // running in the same direction at the snap point. Two roads that
            // merely cross or glance off each other near a junction have
            // tangents at a sharp angle — snapping A's points onto B there
            // drags a straight run of A sideways onto B's curve, creating a
            // triangular kink. Require |cos(angle)| ≥ 0.87 (≤ 30° off-axis).
            const float TANGENT_ALIGN_MIN = 0.87f;

            for (int b = 0; b < polylines.Count; b++)
            {
                if (b == a || toRemove.Contains(b)) continue;
                int countB = polylines[b].Points.Count;
                if (countB < countA || (countB == countA && b >= a)) continue;

                var bb   = bounds[b];
                var ptsB = polylines[b].Points;
                var bStart = ptsB[0];
                var bEnd   = ptsB[^1];

                bool aStartNearBStart = Vec2.DistSq(ptsA[0],  bStart) < endpointGuardSq;
                bool aStartNearBEnd   = Vec2.DistSq(ptsA[0],  bEnd)   < endpointGuardSq;
                bool aEndNearBStart   = Vec2.DistSq(ptsA[^1], bStart) < endpointGuardSq;
                bool aEndNearBEnd     = Vec2.DistSq(ptsA[^1], bEnd)   < endpointGuardSq;

                // Body-junction detection: does A's endpoint sit ON B's body
                // (not near B's own endpoints)? If so, A meets B at a T-style
                // junction already established before Phase 3. Per-point merge
                // of A's interior onto B would partially snap a curving
                // approach, leaving the unsnapped tail to form a triangle.
                const float BODY_JUNCTION_TOL = 4f;
                float bodyJunctionTolSq = BODY_JUNCTION_TOL * BODY_JUNCTION_TOL;
                bool StartOnBBody()
                {
                    var near = NearestPointOnPolyline(ptsA[0], ptsB, out float dSq);
                    return dSq < bodyJunctionTolSq
                        && Vec2.DistSq(near, bStart) > endpointGuardSq
                        && Vec2.DistSq(near, bEnd)   > endpointGuardSq;
                }
                bool EndOnBBody()
                {
                    var near = NearestPointOnPolyline(ptsA[^1], ptsB, out float dSq);
                    return dSq < bodyJunctionTolSq
                        && Vec2.DistSq(near, bStart) > endpointGuardSq
                        && Vec2.DistSq(near, bEnd)   > endpointGuardSq;
                }
                if (StartOnBBody() || EndOnBBody()) bodyJunctionCandidates.Add(b);

                for (int i = 0; i < countA; i++)
                {
                    var pt = ptsA[i];
                    if (pt.X < bb.minX || pt.X > bb.maxX ||
                        pt.Y < bb.minY || pt.Y > bb.maxY) continue;

                    var near = NearestPointOnPolyline(pt, ptsB, out float dSq, out int segIdx);
                    if (dSq >= bestDistSqArr[i]) continue;

                    if (Vec2.DistSq(near, bStart) < endpointGuardSq ||
                        Vec2.DistSq(near, bEnd)   < endpointGuardSq) continue;

                    // Extended guard where A and B share an endpoint: points
                    // projecting near the shared side of B are divergent
                    // geometry, not a parallel overlap.
                    float distNearBStartSq = Vec2.DistSq(near, bStart);
                    float distNearBEndSq   = Vec2.DistSq(near, bEnd);
                    if ((aStartNearBStart || aEndNearBStart) && distNearBStartSq < sharedEndpointGuardSq) continue;
                    if ((aStartNearBEnd   || aEndNearBEnd)   && distNearBEndSq   < sharedEndpointGuardSq) continue;

                    // Tangent-alignment check
                    if (!TangentsAligned(ptsA, i, ptsB, segIdx, TANGENT_ALIGN_MIN)) continue;

                    bestDistSqArr[i] = dSq;
                    bestPtArr[i]     = near;
                    bestCandArr[i]   = b;
                }
            }

            // Is A mostly coincident with one specific candidate? Count per-candidate
            // coverage so that interleaved overlaps with multiple candidates don't
            // spuriously register as subsumption.
            int primaryB = -1;
            int primaryCount = 0;
            if (dropSubsumed)
            {
                var tally = new Dictionary<int, int>();
                for (int i = 0; i < countA; i++)
                {
                    int b = bestCandArr[i];
                    if (b < 0) continue;
                    tally.TryGetValue(b, out int c);
                    tally[b] = c + 1;
                    if (c + 1 > primaryCount) { primaryCount = c + 1; primaryB = b; }
                }
            }

            const float SUBSUMPTION_THRESHOLD = 0.65f;
            bool subsumed = dropSubsumed
                && primaryB >= 0
                && (float)primaryCount / countA >= SUBSUMPTION_THRESHOLD;

            if (subsumed)
            {
                bool startOn = bestCandArr[0]          == primaryB;
                bool endOn   = bestCandArr[countA - 1] == primaryB;

                if (startOn && endOn)
                {
                    // Whole polyline duplicates the larger one's geometry — drop it.
                    toRemove.Add(a);
                    merges++;
                    continue;
                }

                if (startOn ^ endOn)
                {
                    var stub = new List<Vec2>();
                    if (endOn)
                    {
                        int firstOn = 0;
                        while (firstOn < countA && bestCandArr[firstOn] != primaryB) firstOn++;
                        if (firstOn >= countA) goto fallback; // defensive
                        for (int i = 0; i < firstOn; i++) stub.Add(ptsA[i]);
                        stub.Add(bestPtArr[firstOn]);
                    }
                    else
                    {
                        int lastOn = countA - 1;
                        while (lastOn >= 0 && bestCandArr[lastOn] != primaryB) lastOn--;
                        if (lastOn < 0) goto fallback;
                        stub.Add(bestPtArr[lastOn]);
                        for (int i = lastOn + 1; i < countA; i++) stub.Add(ptsA[i]);
                    }

                    if (stub.Count >= 2)
                    {
                        ptsA.Clear();
                        ptsA.AddRange(stub);
                        merges++;
                        continue;
                    }
                }
            }

            // Short polylines whose endpoints both land on the same longer
            // candidate are redundant even if interior points meander outside
            // the merge distance (failing the 65% coincidence check above).
            if (dropSubsumed && !subsumed && countA < 10
                && bestCandArr[0] >= 0
                && bestCandArr[0] == bestCandArr[countA - 1])
            {
                toRemove.Add(a);
                merges++;
                continue;
            }

            fallback:
            // Snap interior points, but only where a RUN of consecutive points is near
            // the same target polyline. A lone close point is a chance proximity (e.g.
            // one road passes near the endpoint of another), and snapping it creates a
            // sharp kink rather than a genuine parallel merge.
            for (int i = 1; i < countA - 1; i++)
            {
                int cand = bestCandArr[i];
                if (cand < 0) continue;
                // Skip if A meets this candidate at a body junction — the junction
                // already establishes the connection, and per-point snapping of
                // A's curving approach onto B's body creates triangle artifacts.
                if (bodyJunctionCandidates.Contains(cand)) continue;
                bool hasRunNeighbor = bestCandArr[i - 1] == cand || bestCandArr[i + 1] == cand;
                if (!hasRunNeighbor) continue;
                ptsA[i] = bestPtArr[i];
                merges++;
            }
        }

        // ── Redundant parallel-stub pass ────────────────────────────────────
        // Road generation's SETTLEMENT_HALO_RADIUS disables the existing-road
        // reuse discount near endpoints, so an edge joining a settlement that
        // already has an outgoing road generates a dedicated halo approach.
        // When that approach's A* path happens to terminate on the existing
        // road's body after a short curve, the result is a visual "teardrop"
        // loop: two roads fork at the settlement and rejoin one tile away.
        // The per-point merge above cannot collapse this case because the two
        // arms run at sharp angles (tangent-alignment correctly rejects a
        // parallel snap). Detect and drop these stubs explicitly.
        if (dropSubsumed)
        {
            const float SHARED_ENDPOINT_TOL    = 8f;  // ~0.25 tiles
            const float STUB_FAR_DIST          = 32f; // 1 tile
            const float STUB_ENDPOINT_GUARD    = 24f;
            const int   STUB_MAX_POINTS        = 12;
            float sharedTolSq  = SHARED_ENDPOINT_TOL * SHARED_ENDPOINT_TOL;
            float farDistSq    = STUB_FAR_DIST * STUB_FAR_DIST;
            float endGuardSq   = STUB_ENDPOINT_GUARD * STUB_ENDPOINT_GUARD;

            for (int a = 0; a < polylines.Count; a++)
            {
                if (toRemove.Contains(a)) continue;
                var ptsA = polylines[a].Points;
                int countA = ptsA.Count;
                if (countA < 2 || countA > STUB_MAX_POINTS) continue;

                Vec2 aStart = ptsA[0];
                Vec2 aEnd   = ptsA[^1];

                for (int b = 0; b < polylines.Count; b++)
                {
                    if (b == a || toRemove.Contains(b)) continue;
                    var ptsB = polylines[b].Points;
                    if (ptsB.Count <= countA) continue;
                    Vec2 bStart = ptsB[0];
                    Vec2 bEnd   = ptsB[^1];

                    // Which (if any) A endpoint matches a B endpoint?
                    bool aStartSharedB = Vec2.DistSq(aStart, bStart) < sharedTolSq || Vec2.DistSq(aStart, bEnd) < sharedTolSq;
                    bool aEndSharedB   = Vec2.DistSq(aEnd,   bStart) < sharedTolSq || Vec2.DistSq(aEnd,   bEnd) < sharedTolSq;
                    // Need exactly one shared end (both shared is handled by subsumption above).
                    if (aStartSharedB == aEndSharedB) continue;

                    // Project the non-shared end onto B.
                    Vec2 farPt = aStartSharedB ? aEnd : aStart;
                    var nearPt = NearestPointOnPolyline(farPt, ptsB, out float dSq);
                    if (dSq >= farDistSq) continue;
                    // Reject if the projection lands near one of B's endpoints
                    // — that's a junction meeting, not a subsumed stub.
                    if (Vec2.DistSq(nearPt, bStart) < endGuardSq) continue;
                    if (Vec2.DistSq(nearPt, bEnd)   < endGuardSq) continue;

                    // Reject if A's far end coincides with another polyline's
                    // endpoint — A is then a legitimate connector (e.g. the
                    // settlement-side stub of an edge whose main body starts
                    // at the junction tile), not a redundant parallel approach
                    // that merely loops back onto B.
                    bool farEndIsConnector = false;
                    for (int c = 0; c < polylines.Count; c++)
                    {
                        if (c == a || c == b || toRemove.Contains(c)) continue;
                        var ptsC = polylines[c].Points;
                        if (ptsC.Count < 2) continue;
                        if (Vec2.DistSq(farPt, ptsC[0])  < sharedTolSq ||
                            Vec2.DistSq(farPt, ptsC[^1]) < sharedTolSq)
                        {
                            farEndIsConnector = true;
                            break;
                        }
                    }
                    if (farEndIsConnector) continue;

                    toRemove.Add(a);
                    merges++;
                    break;
                }
            }
        }

        if (toRemove.Count > 0)
        {
            for (int i = polylines.Count - 1; i >= 0; i--)
                if (toRemove.Contains(i)) polylines.RemoveAt(i);
        }

        return merges;
    }

    // ── Phase 3b: Split polylines that cross a non-endpoint settlement ──────

    /// <summary>
    /// When a polyline's interior body passes through a non-endpoint settlement's
    /// centre tile, split it there into two halves so the settlement becomes a
    /// real network junction. Without this, the rasterized HasRoad flag is set
    /// on the settlement tile but no polyline endpoint terminates there, so the
    /// settlement appears disconnected from the trunk despite a road running
    /// through it. Also relabels FromSettlementId/ToSettlementId on any polyline
    /// whose existing endpoint coincides with a different settlement's centre
    /// tile (caused by SplitByExistingFeature using the junction tile centre as
    /// an anchor while inheriting the original A* edge's settlement labels).
    /// </summary>
    private static int SplitAtNonEndpointSettlements(WorldState world, List<Polyline> polylines)
    {
        int splits = 0;
        int px = C.WORLD_TILE_PIXELS;

        var centreTiles = new Dictionary<long, Settlement>();
        foreach (var s in world.Settlements)
        {
            if (s.IsPoi) continue;
            centreTiles[TileKey(s.TileX, s.TileY)] = s;
        }
        if (centreTiles.Count == 0) return 0;

        int nextId = 0;
        foreach (var poly in polylines)
            if (poly.Id >= nextId) nextId = poly.Id + 1;

        int p = 0;
        while (p < polylines.Count)
        {
            var poly = polylines[p];
            if (poly.Points.Count < 4) { p++; continue; }

            // Relabel mislabeled endpoints whose tile lies on a settlement
            // centre that disagrees with FromSettlementId / ToSettlementId.
            if (TryLookupCentre(poly.Points[0],  px, centreTiles, out var startS)
                && startS!.Id != poly.FromSettlementId)
            {
                poly.Points[0] = PolylineBuilder.TileToWorldPixel(startS.TileX, startS.TileY);
                poly.FromSettlementId = startS.Id;
            }
            if (TryLookupCentre(poly.Points[^1], px, centreTiles, out var endS)
                && endS!.Id != poly.ToSettlementId)
            {
                poly.Points[^1] = PolylineBuilder.TileToWorldPixel(endS.TileX, endS.TileY);
                poly.ToSettlementId = endS.Id;
            }

            long startKey = TileKeyOf(poly.Points[0],  px);
            long endKey   = TileKeyOf(poly.Points[^1], px);

            int splitIdx = -1;
            Settlement? splitAt = null;
            for (int i = 1; i < poly.Points.Count - 1; i++)
            {
                long key = TileKeyOf(poly.Points[i], px);
                if (key == startKey || key == endKey) continue;
                if (centreTiles.TryGetValue(key, out var s))
                {
                    splitIdx = i;
                    splitAt  = s;
                    break;
                }
            }

            if (splitAt == null) { p++; continue; }

            // Need at least one interior point on each side of the split.
            if (splitIdx < 1 || splitIdx >= poly.Points.Count - 1) { p++; continue; }

            Vec2 centre = PolylineBuilder.TileToWorldPixel(splitAt.TileX, splitAt.TileY);

            var newPoly = new Polyline
            {
                Type               = poly.Type,
                Id                 = nextId++,
                Width              = poly.Width,
                RoadClassification = poly.RoadClassification,
                FromSettlementId   = splitAt.Id,
                ToSettlementId     = poly.ToSettlementId,
            };
            newPoly.Points.Add(centre);
            for (int i = splitIdx + 1; i < poly.Points.Count; i++)
                newPoly.Points.Add(poly.Points[i]);

            poly.Points.RemoveRange(splitIdx, poly.Points.Count - splitIdx);
            poly.Points.Add(centre);
            poly.ToSettlementId = splitAt.Id;

            polylines.Add(newPoly);
            splits++;
            // Don't advance p — re-examine truncated head for additional crossings.
        }

        return splits;
    }

    private static long TileKey(int tx, int ty) => ((long)ty << 32) | (uint)tx;

    private static long TileKeyOf(Vec2 worldPixel, int px)
    {
        int tx = (int)(worldPixel.X / px);
        int ty = (int)(worldPixel.Y / px);
        return TileKey(tx, ty);
    }

    private static bool TryLookupCentre(Vec2 worldPixel, int px,
        Dictionary<long, Settlement> centres, out Settlement? settlement)
    {
        long key = TileKeyOf(worldPixel, px);
        if (centres.TryGetValue(key, out var s)) { settlement = s; return true; }
        settlement = null;
        return false;
    }

    // ── Phase 4a: Remove small loops ────────────────────────────────────────

    /// <summary>
    /// Detects small self-intersection loops (point i close to point j with
    /// intermediate points forming a loop) and removes the intermediate points.
    /// Distinguishes true loops from gentle curves via arc/chord ratio: a
    /// self-crossing loop doubles back on itself, so its arc length is much
    /// greater than the straight-line distance between its endpoints, whereas
    /// a meandering curve has arc length close to the chord.
    /// </summary>
    private static void RemoveSmallLoops(List<Vec2> points)
    {
        float loopDistSq = C.POLYLINE_MERGE_DIST * C.POLYLINE_MERGE_DIST;
        const int maxLookahead = 12;
        const float LOOP_ARC_CHORD_RATIO = 2.5f;

        for (int i = 0; i < points.Count - 2; i++)
        {
            int searchEnd = Math.Min(i + maxLookahead, points.Count);
            for (int j = i + 2; j < searchEnd; j++)
            {
                if (Vec2.DistSq(points[i], points[j]) >= loopDistSq) continue;

                // Accumulate arc length along the sub-path i..j.
                float arcLen = 0f;
                for (int k = i; k < j; k++)
                    arcLen += MathF.Sqrt(Vec2.DistSq(points[k], points[k + 1]));
                float chord = MathF.Sqrt(Vec2.DistSq(points[i], points[j]));

                // Arc much longer than chord → doubled-back loop → safe to collapse.
                if (arcLen >= chord * LOOP_ARC_CHORD_RATIO)
                {
                    points.RemoveRange(i + 1, j - i - 1);
                    break;
                }
            }
        }
    }

    // ── Phase 4b: Remove near-duplicate consecutive points ──────────────────

    private static void RemoveNearDuplicates(List<Vec2> points)
    {
        const float minDistSq = 4f; // 2 world pixels
        int i = 0;
        while (i < points.Count - 2) // keep at least 2 points
        {
            if (Vec2.DistSq(points[i], points[i + 1]) < minDistSq)
                points.RemoveAt(i + 1);
            else
                i++;
        }
    }

    // ── Phase 6: Settlement connectivity ──────────────────────────────────

    /// <summary>
    /// Verify all non-POI settlements have at least one road polyline endpoint
    /// within snapping distance. Creates a short connector stub for any that don't.
    /// </summary>
    private static int EnsureSettlementConnectivity(WorldState world)
    {
        // Use a tight radius so that settlements are only considered connected
        // when a road endpoint is truly at/near them, not just passing nearby.
        float connectDistSq = C.SETTLEMENT_CONNECT_DIST * C.SETTLEMENT_CONNECT_DIST;
        int px = C.WORLD_TILE_PIXELS;
        int connectors = 0;
        var pathfinder = new AStarPathfinder();

        foreach (var settle in world.Settlements)
        {
            if (settle.IsPoi) continue;

            Vec2 center = PolylineBuilder.TileToWorldPixel(settle.TileX, settle.TileY);

            // Check if any road endpoint is near this settlement
            bool connected = false;
            foreach (var road in world.Roads)
            {
                if (road.Points.Count < 2) continue;
                if (Vec2.DistSq(road.Points[0], center) < connectDistSq ||
                    Vec2.DistSq(road.Points[^1], center) < connectDistSq)
                {
                    connected = true;
                    break;
                }
            }

            if (connected) continue;

            // Find the nearest point on any road polyline body
            float bestDistSq = float.MaxValue;
            Vec2 bestPoint = default;
            bool found = false;

            foreach (var road in world.Roads)
            {
                if (road.Points.Count < 2) continue;
                var near = NearestPointOnPolyline(center, road.Points, out float dSq);
                if (dSq < bestDistSq)
                {
                    bestDistSq = dSq;
                    bestPoint = near;
                    found = true;
                }
            }

            if (!found) continue;

            int tier = settle.Tier <= 3 ? 3 : 4;
            int targetTx = (int)(bestPoint.X / px);
            int targetTy = (int)(bestPoint.Y / px);

            // Route via A* so the connector avoids parallel river travel and
            // crosses rivers perpendicularly — bridge detection then places a
            // bridge at any genuine crossing.
            var path = pathfinder.FindPath(
                settle.TileX, settle.TileY,
                targetTx, targetTy,
                RoadNetworkGenStage.RoadCostFnRelaxed(world, settle.TileX, settle.TileY, targetTx, targetTy));

            Polyline connector;
            if (path != null && path.Count >= 2)
            {
                connector = RoadNetworkGenStage.BuildRoadPolyline(
                    world, path, world.Roads.Count, settle.Id, -1, tier);
            }
            else
            {
                // Fallback: straight-line stub if A* can't route (e.g. rail
                // blocks every approach). Better than leaving the settlement
                // stranded — UNLESS the straight line would cross ocean tiles,
                // in which case the settlement is island-stranded and a
                // sea-crossing road is worse than no connector. Island
                // placement should already be filtered upstream by
                // LandmassMap, so this is a safety net.
                if (StraightLineCrossesOcean(world, center, bestPoint))
                    continue;

                connector = new Polyline
                {
                    Type               = PolylineType.Road,
                    Id                 = world.Roads.Count,
                    Width              = tier <= 3 ? 1f : 0.5f,
                    RoadClassification = tier <= 3 ? RoadType.DirtRoad : RoadType.Footpath,
                    FromSettlementId   = settle.Id,
                    ToSettlementId     = -1,
                };
                connector.Points.Add(center);
                connector.Points.Add(bestPoint);
                connector.SimplifiedPoints = new List<Vec2>(connector.Points);
            }

            world.Roads.Add(connector);
            PolylineBuilder.RasterizeToTileFlags(world, connector, setAdjacency: false);
            connectors++;
        }

        return connectors;
    }

    /// <summary>
    /// True if the line segment between two world-pixel points passes through
    /// any ocean tile. Samples at half-tile intervals along the segment.
    /// </summary>
    private static bool StraightLineCrossesOcean(WorldState world, Vec2 a, Vec2 b)
    {
        int px = C.WORLD_TILE_PIXELS;
        float dist = Vec2.Dist(a, b);
        int steps = Math.Max(2, (int)MathF.Ceiling(dist / (px * 0.5f)));
        for (int i = 0; i <= steps; i++)
        {
            float t = (float)i / steps;
            float wx = a.X + (b.X - a.X) * t;
            float wy = a.Y + (b.Y - a.Y) * t;
            int tx = (int)(wx / px);
            int ty = (int)(wy / px);
            if ((uint)tx >= C.WORLD_WIDTH_TILES || (uint)ty >= C.WORLD_HEIGHT_TILES) continue;
            if (world.Tiles[tx, ty].Biome == BiomeId.Ocean) return true;
        }
        return false;
    }

    // ── Shared helpers ──────────────────────────────────────────────────────

    private static List<(int polyIdx, bool isStart)> CollectEndpoints(List<Polyline> polylines)
    {
        var endpoints = new List<(int polyIdx, bool isStart)>();
        for (int i = 0; i < polylines.Count; i++)
        {
            if (polylines[i].Points.Count < 2) continue;
            endpoints.Add((i, true));
            endpoints.Add((i, false));
        }
        return endpoints;
    }

    private static Vec2 GetEndpoint(List<Polyline> polys, (int polyIdx, bool isStart) ep)
    {
        var pts = polys[ep.polyIdx].Points;
        return ep.isStart ? pts[0] : pts[^1];
    }

    /// <summary>
    /// Snap a polyline's start or end to the target point, trimming overshoot if needed.
    /// </summary>
    private static void SnapEndpoint(Polyline poly, bool isStart, Vec2 target)
    {
        var pts = poly.Points;
        if (pts.Count < 2) return;

        if (!isStart)
        {
            int searchStart = Math.Max(0, pts.Count - C.POLYLINE_MAX_TRIM_POINTS);
            int bestIdx = pts.Count - 1;
            float bestDist = Vec2.DistSq(pts[^1], target);

            for (int i = pts.Count - 2; i >= searchStart; i--)
            {
                float d = Vec2.DistSq(pts[i], target);
                if (d < bestDist) { bestDist = d; bestIdx = i; }
            }

            // Trim overshoot (only if at least 2 points remain)
            if (bestIdx < pts.Count - 1 && bestIdx >= 1)
                pts.RemoveRange(bestIdx + 1, pts.Count - 1 - bestIdx);

            pts[pts.Count - 1] = target;
        }
        else
        {
            int searchEnd = Math.Min(C.POLYLINE_MAX_TRIM_POINTS, pts.Count);
            int bestIdx = 0;
            float bestDist = Vec2.DistSq(pts[0], target);

            for (int i = 1; i < searchEnd; i++)
            {
                float d = Vec2.DistSq(pts[i], target);
                if (d < bestDist) { bestDist = d; bestIdx = i; }
            }

            if (bestIdx > 0 && bestIdx < pts.Count - 1)
                pts.RemoveRange(0, bestIdx);

            pts[0] = target;
        }
    }

    /// <summary>
    /// Returns true if the tangent direction of A at index i is aligned (within
    /// ±60° by default) with B's segment at segIdx. Direction-agnostic via |dot|
    /// so roads traversed in opposite directions still count as parallel.
    /// </summary>
    private static bool TangentsAligned(List<Vec2> ptsA, int i, List<Vec2> ptsB, int segIdxB, float minAbsCos)
    {
        // A's tangent at i: central difference where possible, else one-sided
        Vec2 tA;
        if (i > 0 && i < ptsA.Count - 1)      tA = ptsA[i + 1] - ptsA[i - 1];
        else if (i == 0)                       tA = ptsA[1] - ptsA[0];
        else                                   tA = ptsA[^1] - ptsA[^2];
        if (tA.LengthSquared < 1e-8f) return true; // degenerate — don't block

        // B's tangent at the matched segment
        Vec2 tB = ptsB[segIdxB + 1] - ptsB[segIdxB];
        if (tB.LengthSquared < 1e-8f) return true;

        tA = tA.Normalized;
        tB = tB.Normalized;
        return MathF.Abs(Vec2.Dot(tA, tB)) >= minAbsCos;
    }

    private static Vec2 NearestPointOnPolyline(Vec2 query, List<Vec2> pts, out float distSq)
        => NearestPointOnPolyline(query, pts, out distSq, out _);

    private static Vec2 NearestPointOnPolyline(Vec2 query, List<Vec2> pts, out float distSq, out int segIdx)
    {
        Vec2 best = pts[0];
        distSq = Vec2.DistSq(query, pts[0]);
        segIdx = 0;

        for (int i = 0; i < pts.Count - 1; i++)
        {
            var near = NearestPointOnSegment(query, pts[i], pts[i + 1], out float d);
            if (d < distSq) { distSq = d; best = near; segIdx = i; }
        }
        return best;
    }

    private static Vec2 NearestPointOnSegment(Vec2 p, Vec2 a, Vec2 b, out float distSq)
    {
        Vec2 ab = b - a;
        float lenSq = ab.LengthSquared;

        if (lenSq < 1e-8f)
        {
            distSq = Vec2.DistSq(p, a);
            return a;
        }

        float t = Vec2.Dot(p - a, ab) / lenSq;
        t = Math.Clamp(t, 0f, 1f);
        Vec2 nearest = a + ab * t;
        distSq = Vec2.DistSq(p, nearest);
        return nearest;
    }
}
