import { actionDeleteRelation } from '../actions/delete_relation';
import { actionReverse } from '../actions/reverse';
import { actionSplit } from '../actions/split';
import { coreGraph } from '../core/graph';
import { geoAngle, geoSphericalDistance } from '../geo';
import { osmIdManager, type NodeId, type RelationId, type WayId } from './id_manager';
import { utilArrayDifference, utilArrayUniq } from '../util';
import type { EntityId, OsmEntity, osmNode, osmRelation, osmWay } from '..';
import type { Action } from '../core/history';
import type { Projection } from '../geo/raw_mercator';

interface Restriction {
    id: RelationId;
    direct: boolean;
    from: EntityId;
    only?: true;
    no?: true;
    end: boolean;
}

export class osmTurn implements Turn {
    declare key: string;
    declare path: EntityId[];
    declare restrictionID?: RelationId;
    declare from: {
        node: NodeId;
        vertex: NodeId;
        way: WayId;
    };
    declare via: {
        node: NodeId | undefined;
        ways: WayId[] | undefined;
    };
    declare to: {
        node: NodeId;
        vertex: NodeId;
        way: WayId;
    };
    /** isUTurn */
    declare u: boolean;
    declare no?: boolean;
    declare only?: boolean;
    declare direct?: boolean;
    constructor(properties: Turn) {
        Object.assign(this, properties);
    }
}
type Turn = Omit<osmTurn, 'constructor'>;



/** @param maxDistance in metres */
export function osmIntersection(graph: coreGraph, startVertexId: NodeId, maxDistance: number = 30) {
    var vgraph = new coreGraph();           // virtual graph
    var i, j, k;


    function memberOfRestriction(entity: OsmEntity) {
        return graph.parentRelations(entity)
            .some(function(r) { return r.isRestriction(); });
    }

    function isRoad(way: osmWay) {
        if (way.isArea() || way.isDegenerate()) return false;
        var roads: Record<string, true> = {
            'motorway': true,
            'motorway_link': true,
            'trunk': true,
            'trunk_link': true,
            'primary': true,
            'primary_link': true,
            'secondary': true,
            'secondary_link': true,
            'tertiary': true,
            'tertiary_link': true,
            'residential': true,
            'unclassified': true,
            'living_street': true,
            'service': true,
            'busway': true,
            'road': true,
            'track': true
        };
        return roads[way.tags.highway];
    }


    var startNode = graph.entity(startVertexId);
    var checkVertices = [startNode];
    var checkWays;
    var vertices: osmNode[] = [];
    var vertexIds: NodeId[] = [];
    var vertex;
    var ways: osmWay[] = [];
    var way;
    var node;
    var parent;

    // `actions` will store whatever actions must be performed to satisfy
    // preconditions for adding a turn restriction to this intersection.
    //  - Remove any existing degenerate turn restrictions (missing from/to, etc)
    //  - Reverse oneways so that they are drawn in the forward direction
    //  - Split ways on key vertices
    var actions: Action[] = [];


    // STEP 1:  walk the graph outwards from starting vertex to search
    //  for more key vertices and ways to include in the intersection..

    while (checkVertices.length) {
        vertex = checkVertices.pop()!;

        // check this vertex for parent ways that are roads
        checkWays = graph.parentWays(vertex);
        var hasWays = false;
        for (i = 0; i < checkWays.length; i++) {
            way = checkWays[i];
            if (!isRoad(way) && !memberOfRestriction(way)) continue;

            ways.push(way);   // it's a road, or it's already in a turn restriction
            hasWays = true;

            // check the way's children for more key vertices
            const nodes = utilArrayUniq(graph.childNodes(way));
            for (j = 0; j < nodes.length; j++) {
                node = nodes[j];
                if (node === vertex) continue;                                           // same thing
                if (vertices.indexOf(node) !== -1) continue;                             // seen it already
                if (geoSphericalDistance(node.loc, startNode.loc) > maxDistance) continue;   // too far from start

                // a key vertex will have parents that are also roads
                var hasParents = false;
                const parents = graph.parentWays(node);
                for (k = 0; k < parents.length; k++) {
                    parent = parents[k];
                    if (parent === way) continue;                 // same thing
                    if (ways.indexOf(parent) !== -1) continue;    // seen it already
                    if (!isRoad(parent)) continue;                // not a road
                    hasParents = true;
                    break;
                }

                if (hasParents) {
                    checkVertices.push(node);
                }
            }
        }

        if (hasWays) {
            vertices.push(vertex);
        }
    }

    vertices = utilArrayUniq(vertices);
    ways = utilArrayUniq(ways);


    // STEP 2:  Build a virtual graph containing only the entities in the intersection..
    // Everything done after this step should act on the virtual graph
    // Any actions that must be performed later to the main graph go in `actions` array
    ways.forEach(function(way) {
        graph.childNodes(way).forEach(function(node) {
            vgraph = vgraph.replace(node);
        });

        vgraph = vgraph.replace(way);

        graph.parentRelations(way).forEach(function(relation) {
            if (relation.isRestriction()) {
                if (relation.isValidRestriction()) {
                    vgraph = vgraph.replace(relation);
                } else if (relation.isComplete(graph)) {
                    actions.push(actionDeleteRelation(relation.id));
                }
            }
        });
    });


    // STEP 3:  Force all oneways to be drawn in the forward direction
    ways.forEach(function(w) {
        var way = vgraph.entity(w.id);
        if (way.tags.oneway === '-1') {
            var action = actionReverse(way.id, { reverseOneway: true });
            actions.push(action);
            vgraph = action(vgraph);
        }
    });


    // STEP 4:  Split ways on key vertices
    var origCount = osmIdManager.next.way;
    vertices.forEach(function(v) {
        // This is an odd way to do it, but we need to find all the ways that
        // will be split here, then split them one at a time to ensure that these
        // actions can be replayed on the main graph exactly in the same order.
        // (It is unintuitive, but the order of ways returned from graph.parentWays()
        // is arbitrary, depending on how the main graph and vgraph were built)
        var splitAll = actionSplit([v.id]);
        splitAll.keepHistoryOn('first');
        if (!splitAll.disabled!(vgraph)) {
            splitAll.ways(vgraph).forEach(function(way) {
                var splitOne = actionSplit([v.id]);
                splitOne.limitWays([way.id]);
                splitOne.keepHistoryOn('first');
                actions.push(splitOne);
                vgraph = splitOne(vgraph);
            });
        }
    });

    // In here is where we should also split the intersection at nearby junction.
    //   for https://github.com/mapbox/iD-internal/issues/31
    // nearbyVertices.forEach(function(v) {
    // });

    // Reasons why we reset the way id count here:
    //  1. Continuity with way ids created by the splits so that we can replay
    //     these actions later if the user decides to create a turn restriction
    //  2. Avoids churning way ids just by hovering over a vertex
    //     and displaying the turn restriction editor
    osmIdManager.next.way = origCount;


    // STEP 5:  Update arrays to point to vgraph entities
    vertexIds = vertices.map(function(v) { return v.id; });
    vertices = [];
    ways = [];

    vertexIds.forEach(function(id) {
        const vertex = vgraph.entity(id);
        const parents = vgraph.parentWays(vertex);
        vertices.push(vertex);
        ways = ways.concat(parents);
    });

    vertices = utilArrayUniq(vertices);
    ways = utilArrayUniq(ways);

    vertexIds = vertices.map(function(v) { return v.id; });
    const wayIds = ways.map(function(w) { return w.id; });


    // STEP 6:  Update the ways with some metadata that will be useful for
    // walking the intersection graph later and rendering turn arrows.

    function withMetadata(way: osmWay, vertexIds: NodeId[]) {
        // bidirectional ways are two-way from an intersection's perspective
        var __oneWay = way.isOneWay() && !way.isBiDirectional();

        // which affixes are key vertices?
        var __first = (vertexIds.indexOf(way.first()) !== -1);
        var __last = (vertexIds.indexOf(way.last()) !== -1);

        // what roles is this way eligible for?
        var __via = (__first && __last);
        var __from = ((__first && !__oneWay) || __last);
        var __to = (__first || (__last && !__oneWay));

        return way.update({
            __first:  __first,
            __last:  __last,
            __from:  __from,
            __via: __via,
            __to:  __to,
            __oneWay:  __oneWay
        });
    }

    ways = [];
    wayIds.forEach(function(id) {
        var way = withMetadata(vgraph.entity(id), vertexIds);
        vgraph = vgraph.replace(way);
        ways.push(way);
    });


    // STEP 7:  Simplify - This is an iterative process where we:
    //  1. Find trivial vertices with only 2 parents
    //  2. trim off the leaf way from those vertices and remove from vgraph

    var keepGoing;
    var removeWayIds: WayId[] = [];
    var removeVertexIds: NodeId[] = [];

    do {
        keepGoing = false;
        const checkVertices = vertexIds.slice();

        for (i = 0; i < checkVertices.length; i++) {
            var vertexId = checkVertices[i];
            vertex = vgraph.hasEntity(vertexId);

            if (!vertex) {
                if (vertexIds.indexOf(vertexId) !== -1) {
                    vertexIds.splice(vertexIds.indexOf(vertexId), 1);   // stop checking this one
                }
                removeVertexIds.push(vertexId);
                continue;
            }

            let parents = vgraph.parentWays(vertex);
            if (parents.length < 3) {
                if (vertexIds.indexOf(vertexId) !== -1) {
                    vertexIds.splice(vertexIds.indexOf(vertexId), 1);   // stop checking this one
                }
            }

            if (parents.length === 2) {     // vertex with 2 parents is trivial
                var a = parents[0];
                var b = parents[1];
                var aIsLeaf = a && !a.__via;
                var bIsLeaf = b && !b.__via;
                var leaf, survivor;

                if (aIsLeaf && !bIsLeaf) {
                    leaf = a;
                    survivor = b;
                } else if (!aIsLeaf && bIsLeaf) {
                    leaf = b;
                    survivor = a;
                }

                if (leaf && survivor) {
                    survivor = withMetadata(survivor, vertexIds);      // update survivor way
                    vgraph = vgraph.replace(survivor).remove(leaf);    // update graph
                    removeWayIds.push(leaf.id);
                    keepGoing = true;
                }
            }

            parents = vgraph.parentWays(vertex);

            if (parents.length < 2) {     // vertex is no longer a key vertex
                if (vertexIds.indexOf(vertexId) !== -1) {
                    vertexIds.splice(vertexIds.indexOf(vertexId), 1);   // stop checking this one
                }
                removeVertexIds.push(vertexId);
                keepGoing = true;
            }

            if (parents.length < 1) {     // vertex is no longer attached to anything
                vgraph = vgraph.remove(vertex);
            }

        }
    } while (keepGoing);


    vertices = vertices
        .filter(function(vertex) { return removeVertexIds.indexOf(vertex.id) === -1; })
        .map(function(vertex) { return vgraph.entity(vertex.id); });
    ways = ways
        .filter(function(way) { return removeWayIds.indexOf(way.id) === -1; })
        .map(function(way) { return vgraph.entity(way.id); });


    // OK!  Here is our intersection..
    const intersection = function () {};
    intersection.graph = vgraph;
    intersection.actions = actions;
    intersection.vertices = vertices;
    intersection.ways = ways;



    // Get all the valid turns through this intersection given a starting way id.
    // This operates on the virtual graph for everything.
    //
    // Basically, walk through all possible paths from starting way,
    //   honoring the existing turn restrictions as we go (watch out for loops!)
    //
    // For each path found, generate and return a `osmTurn` datastructure.
    //
    intersection.turns = function(fromWayId: WayId, maxViaWay: number) {
        if (!fromWayId) return [];
        if (!maxViaWay) maxViaWay = 0;

        var vgraph = intersection.graph;
        var keyVertexIds = intersection.vertices.map(function(v) { return v.id; });

        var start = vgraph.entity(fromWayId);
        if (!start || !(start.__from || start.__via)) return [];

        // maxViaWay=0   from-*-to              (0 vias)
        // maxViaWay=1   from-*-via-*-to        (1 via max)
        // maxViaWay=2   from-*-via-*-via-*-to  (2 vias max)
        var maxPathLength = (maxViaWay * 2) + 3;
        var turns: osmTurn[] = [];

        step(start);
        return turns;


        // traverse the intersection graph and find all the valid paths
        function step(entity: OsmEntity, currPath?: EntityId[], currRestrictions?: osmRelation[], matchedRestriction?: Restriction | false | null) {
            currPath = (currPath || []).slice();  // shallow copy
            if (currPath.length >= maxPathLength) return;
            currPath.push(entity.id);
            currRestrictions = (currRestrictions || []).slice();  // shallow copy

            if (entity.type === 'node') {
                stepNode(entity, currPath, currRestrictions);
            } else if (entity.type === 'way') {
                stepWay(entity, currPath, currRestrictions, matchedRestriction);
            }
        }

        function stepNode(entity: OsmEntity, currPath: EntityId[], currRestrictions: osmRelation[]) {
            var i, j;
            var parents = vgraph.parentWays(entity);
            var nextWays = [];

            // which ways can we step into?
            for (i = 0; i < parents.length; i++) {
                var way = parents[i];

                // if next way is a oneway incoming to this vertex, skip
                if (way.__oneWay && way.nodes[0] !== entity.id) continue;

                // if we have seen it before (allowing for an initial u-turn), skip
                if (currPath.indexOf(way.id) !== -1 && currPath.length >= 3) continue;

                // Check all "current" restrictions (where we've already walked the `FROM`)
                var restrict: Restriction | null = null;
                for (j = 0; j < currRestrictions.length; j++) {
                    var restriction = currRestrictions[j];
                    var f = restriction.memberByRole('from')!;
                    var v = restriction.membersByRole('via');
                    var t = restriction.memberByRole('to')!;
                    var isNo = /^no_/.test(restriction.tags.restriction);
                    var isOnly = /^only_/.test(restriction.tags.restriction);

                    if (!(isNo || isOnly)) {
                        continue; // skip unsupported restriction values
                    }

                    // Does the current path match this turn restriction?
                    var matchesFrom = (f.id === fromWayId);
                    var matchesViaTo = false;
                    var isAlongOnlyPath = false;

                    if (t.id === way.id) {     // match TO

                        if (v.length === 1 && v[0].type === 'node') {    // match VIA node
                            matchesViaTo = (v[0].id === entity.id && (
                                (matchesFrom && currPath.length === 2) ||
                                (!matchesFrom && currPath.length > 2)
                            ));

                        } else {                                         // match all VIA ways
                            var pathVias = [];
                            for (k = 2; k < currPath.length; k +=2 ) {   // k = 2 skips FROM
                                pathVias.push(currPath[k]);              // (path goes way-node-way...)
                            }
                            var restrictionVias = [];
                            for (k = 0; k < v.length; k++) {
                                if (v[k].type === 'way') {
                                    restrictionVias.push(v[k].id);
                                }
                            }
                            var diff = utilArrayDifference(pathVias, restrictionVias);
                            matchesViaTo = !diff.length;
                        }

                    } else if (isOnly) {
                        for (k = 0; k < v.length; k++) {
                            // way doesn't match TO, but is one of the via ways along the path of an "only"
                            if (v[k].type === 'way' && v[k].id === way.id) {
                                isAlongOnlyPath = true;
                                break;
                            }
                        }
                    }

                    if (matchesViaTo) {
                        if (isOnly) {
                            restrict = { id: restriction.id, direct: matchesFrom, from: f.id, only: true, end: true };
                        } else {
                            restrict = { id: restriction.id, direct: matchesFrom, from: f.id, no: true, end: true };
                        }
                    } else {    // indirect - caused by a different nearby restriction
                        if (isAlongOnlyPath) {
                            restrict = { id: restriction.id, direct: false, from: f.id, only: true, end: false };
                        } else if (isOnly) {
                            restrict = { id: restriction.id, direct: false, from: f.id, no: true, end: true };
                        }
                    }

                    // stop looking if we find a "direct" restriction (matching FROM, VIA, TO)
                    if (restrict && restrict.direct) break;
                }

                nextWays.push({ way: way, restrict: restrict });
            }

            nextWays.forEach(function(nextWay) {
                step(nextWay.way, currPath, currRestrictions, nextWay.restrict);
            });
        }

        function stepWay(entity: osmWay, currPath: EntityId[], currRestrictions: osmRelation[], matchedRestriction: Restriction | undefined | null | false) {
            var i;
            if (currPath.length >= 3) {     // this is a "complete" path..
                var turnPath = currPath.slice();   // shallow copy

                // an indirect restriction - only include the partial path (starting at FROM)
                if (matchedRestriction && matchedRestriction.direct === false) {
                    for (i = 0; i < turnPath.length; i++) {
                        if (turnPath[i] === matchedRestriction.from) {
                            turnPath = turnPath.slice(i);
                            break;
                        }
                    }
                }

                var turn = pathToTurn(turnPath);
                if (turn) {
                    if (matchedRestriction) {
                        turn.restrictionID = matchedRestriction.id;
                        turn.no = matchedRestriction.no;
                        turn.only = matchedRestriction.only;
                        turn.direct = matchedRestriction.direct;
                    }
                    turns.push(new osmTurn(turn));
                }

                if (currPath[0] === currPath[2]) return;   // if we made a u-turn - stop here
            }

            if (matchedRestriction && matchedRestriction.end) return;  // don't advance any further

            // which nodes can we step into?
            var n1 = vgraph.entity(entity.first());
            var n2 = vgraph.entity(entity.last());
            var dist = geoSphericalDistance(n1.loc, n2.loc);
            var nextNodes = [];

            if (currPath.length > 1) {
                if (dist > maxDistance) return;   // the next node is too far
                if (!entity.__via) return;        // this way is a leaf / can't be a via
            }

            if (!entity.__oneWay &&                     // bidirectional..
                keyVertexIds.indexOf(n1.id) !== -1 &&   // key vertex..
                currPath.indexOf(n1.id) === -1) {       // haven't seen it yet..
                nextNodes.push(n1);                     // can advance to first node
            }
            if (keyVertexIds.indexOf(n2.id) !== -1 &&   // key vertex..
                currPath.indexOf(n2.id) === -1) {       // haven't seen it yet..
                nextNodes.push(n2);                     // can advance to last node
            }

            nextNodes.forEach(function(nextNode) {
                // gather restrictions FROM this way
                var fromRestrictions = vgraph.parentRelations(entity).filter(function(r) {
                    if (!r.isRestriction()) return false;

                    var f = r.memberByRole('from');
                    if (!f || f.id !== entity.id) return false;

                    var isOnly = /^only_/.test(r.tags.restriction);
                    if (!isOnly) return true;

                    // `only_` restrictions only matter along the direction of the VIA - #4849
                    var isOnlyVia = false;
                    var v = r.membersByRole('via');
                    if (v.length === 1 && v[0].type === 'node') {   // via node
                        isOnlyVia = (v[0].id === nextNode.id);
                    } else {                                        // via way(s)
                        for (var i = 0; i < v.length; i++) {
                            if (v[i].type !== 'way') continue;
                            var viaWay = vgraph.entity<osmWay>(v[i].id);
                            if (viaWay.first() === nextNode.id || viaWay.last() === nextNode.id) {
                                isOnlyVia = true;
                                break;
                            }
                        }
                    }
                    return isOnlyVia;
                });

                step(nextNode, currPath, currRestrictions.concat(fromRestrictions), false);
            });
        }


        // assumes path is alternating way-node-way of odd length
        function pathToTurn(path: EntityId[]): Turn | undefined | null {
            if (path.length < 3) return;
            var fromWayId: WayId, fromNodeId: NodeId, fromVertexId: NodeId;
            var toWayId: WayId, toNodeId: NodeId, toVertexId: NodeId;
            var viaWayIds: WayId[] | undefined, viaNodeId: NodeId | undefined, isUturn: boolean;

            fromWayId = path[0] as WayId;
            toWayId = path[path.length - 1] as WayId;

            if (path.length === 3 && fromWayId === toWayId) {  // u turn
                var way = vgraph.entity(fromWayId);
                if (way.__oneWay) return null;

                isUturn = true;
                viaNodeId = path[1] as NodeId;
                fromVertexId = path[1] as NodeId;
                toVertexId = path[1] as NodeId;
                fromNodeId = adjacentNode(fromWayId, viaNodeId);
                toNodeId = fromNodeId;

            } else {
                isUturn = false;
                fromVertexId = path[1] as NodeId;
                fromNodeId = adjacentNode(fromWayId, fromVertexId);
                toVertexId = path[path.length - 2] as NodeId;
                toNodeId = adjacentNode(toWayId, toVertexId);

                if (path.length === 3) {
                    viaNodeId = path[1] as NodeId;
                } else {
                    viaWayIds = path.filter(function(entityId): entityId is WayId { return entityId[0] === 'w'; });
                    viaWayIds = viaWayIds.slice(1, viaWayIds.length - 1);  // remove first, last
                }
            }

            return {
                key:  path.join('_'),
                path: path,
                from: { node: fromNodeId, way:  fromWayId, vertex: fromVertexId },
                via:  { node: viaNodeId,  ways: viaWayIds },
                to:   { node: toNodeId,   way:  toWayId, vertex: toVertexId },
                u:    isUturn
            };


            function adjacentNode(wayId: WayId, affixId: NodeId) {
                var nodes = vgraph.entity(wayId).nodes;
                return affixId === nodes[0] ? nodes[1] : nodes[nodes.length - 2];
            }
        }

    };

    return intersection;
}


export function osmInferRestriction(graph: coreGraph, turn: osmTurn, projection: Projection) {
    var fromWay = graph.entity(turn.from.way);
    var fromNode = graph.entity(turn.from.node);
    var fromVertex = graph.entity(turn.from.vertex);
    var toWay = graph.entity(turn.to.way);
    var toNode = graph.entity(turn.to.node);
    var toVertex = graph.entity(turn.to.vertex);

    var fromOneWay = (fromWay.tags.oneway === 'yes');
    var toOneWay = (toWay.tags.oneway === 'yes');
    var angle = (geoAngle(fromVertex, fromNode, projection) -
                geoAngle(toVertex, toNode, projection)) * 180 / Math.PI;

    while (angle < 0) {
        angle += 360;
    }

    if (fromNode === toNode) {
        return 'no_u_turn';
    }
    if ((angle < 23 || angle > 336) && fromOneWay && toOneWay) {
        return 'no_u_turn';   // wider tolerance for u-turn if both ways are oneway
    }
    if ((angle < 40 || angle > 319) && fromOneWay && toOneWay && turn.from.vertex !== turn.to.vertex) {
        return 'no_u_turn';   // even wider tolerance for u-turn if there is a via way (from !== to)
    }
    if (angle < 158) {
        return 'no_right_turn';
    }
    if (angle > 202) {
        return 'no_left_turn';
    }

    return 'no_straight_on';
}
