import _throttle from 'lodash-es/throttle';

import { geoPath as d3_geoPath } from 'd3-geo';
import RBush from 'rbush';
import { localizer } from '../core/localizer';

import {
    geoExtent, geoPolygonIntersectsPolygon, geoPathLength,
    geoScaleToZoom, geoVecInterp, geoVecLength
} from '../geo';
import { presetManager } from '../presets';
import { osmEntity, osmIsInterestingTag } from '../osm';
import { utilDetect } from '../util/detect';
import { utilArrayDifference, utilDisplayName, utilDisplayNameForPath, utilEntitySelector } from '../util';



export function svgLabels(projection, context) {
    var path = d3_geoPath(projection);
    var detected = utilDetect();
    var baselineHack = (detected.ie ||
        detected.browser.toLowerCase() === 'edge' ||
        (detected.browser.toLowerCase() === 'firefox' && detected.version >= 70));
    var _rdrawn = new RBush();
    var _rskipped = new RBush();
    var _entitybboxes = {};

    // Listed from highest to lowest priority
    const labelStack = [
        ['line', 'aeroway', '*', 12],
        ['line', 'highway', 'motorway', 12],
        ['line', 'highway', 'trunk', 12],
        ['line', 'highway', 'primary', 12],
        ['line', 'highway', 'secondary', 12],
        ['line', 'highway', 'tertiary', 12],
        ['line', 'highway', '*', 12],
        ['line', 'railway', '*', 12],
        ['line', 'waterway', '*', 12],
        ['area', 'aeroway', '*', 12],
        ['area', 'amenity', '*', 12],
        ['area', 'building', '*', 12],
        ['area', 'historic', '*', 12],
        ['area', 'leisure', '*', 12],
        ['area', 'man_made', '*', 12],
        ['area', 'natural', '*', 12],
        ['area', 'shop', '*', 12],
        ['area', 'tourism', '*', 12],
        ['area', 'camp_site', '*', 12],
        ['point', 'aeroway', '*', 10],
        ['point', 'amenity', '*', 10],
        ['point', 'building', '*', 10],
        ['point', 'historic', '*', 10],
        ['point', 'leisure', '*', 10],
        ['point', 'man_made', '*', 10],
        ['point', 'natural', '*', 10],
        ['point', 'shop', '*', 10],
        ['point', 'tourism', '*', 10],
        ['point', 'camp_site', '*', 10],
        ['line', 'ref', '*', 12],
        ['area', 'ref', '*', 12],
        ['point', 'ref', '*', 10],
        ['line', 'name', '*', 12],
        ['area', 'name', '*', 12],
        ['point', 'name', '*', 10],
        ['point', 'addr:housenumber', '*', 10],
        ['point', 'addr:housename', '*', 10]
    ];


    function shouldSkipIcon(preset) {
        var noIcons = ['building', 'landuse', 'natural'];
        return noIcons.some(function(s) {
            return preset.id.indexOf(s) >= 0;
        });
    }


    function drawLinePaths(selection, labels, filter, classes) {
        var paths = selection.selectAll('path:not(.debug)')
            .filter(d => filter(d.entity))
            .data(labels, d => osmEntity.key(d.entity));

        // exit
        paths.exit()
            .remove();

        // enter/update
        paths.enter()
            .append('path')
            .style('stroke-width', d => d.position['font-size'])
            .attr('id', d => 'ideditor-labelpath-' + d.entity.id)
            .attr('class', classes)
            .merge(paths)
            .attr('d', d => d.position.lineString);
    }


    function drawLineLabels(selection, labels, filter, classes) {
        var texts = selection.selectAll('text.' + classes)
            .filter(d => filter(d.entity))
            .data(labels, d => osmEntity.key(d.entity));

        // exit
        texts.exit()
            .remove();

        // enter
        texts.enter()
            .append('text')
            .attr('class', d => classes + ' ' + d.position.classes + ' ' + d.entity.id)
            .attr('dy', baselineHack ? '0.35em' : null)
            .append('textPath')
            .attr('class', 'textpath');

        // update
        selection.selectAll('text.' + classes).selectAll('.textpath')
            .filter(d => filter(d.entity))
            .data(labels, d => osmEntity.key(d.entity))
            .attr('startOffset', '50%')
            .attr('xlink:href', function(d) { return '#ideditor-labelpath-' + d.entity.id; })
            .text(d => d.name);
    }


    function drawPointLabels(selection, labels, filter, classes) {
        if (classes.includes('pointlabel-halo')) {
            labels = labels.filter(d => !d.position.isAddr);
        }
        var texts = selection.selectAll('text.' + classes)
            .filter(d => filter(d.entity))
            .data(labels, d => osmEntity.key(d.entity));

        // exit
        texts.exit()
            .remove();

        // enter/update
        texts.enter()
            .append('text')
            .attr('class', d => classes + ' ' + d.position.classes + ' ' + d.entity.id)
            .style('text-anchor', d => d.position.textAnchor)
            .text(d => d.name)
            .merge(texts)
            .attr('x', d => d.position.x)
            .attr('y', d => d.position.y);
    }


    function drawAreaLabels(selection, labels, filter, classes) {
        labels = labels.filter(hasText);
        drawPointLabels(selection, labels, filter, classes);

        function hasText(d) {
            return d.position.hasOwnProperty('x') && d.position.hasOwnProperty('y');
        }
    }


    function drawAreaIcons(selection, labels, filter, classes) {
        var icons = selection.selectAll('use.' + classes)
            .filter(d => filter(d.entity))
            .data(labels, d => osmEntity.key(d.entity));

        // exit
        icons.exit()
            .remove();

        // enter/update
        icons.enter()
            .append('use')
            .attr('class', 'icon ' + classes)
            .attr('width', '17px')
            .attr('height', '17px')
            .merge(icons)
            .attr('transform', d => d.position.transform)
            .attr('xlink:href', function(d) {
                var preset = presetManager.match(d.entity, context.graph());
                var picon = preset && preset.icon;
                return picon ? '#' + picon : '';
            });
    }


    function drawCollisionBoxes(selection, rtree, which) {
        var classes = 'debug ' + which + ' ' + (which === 'debug-skipped' ? 'orange' : 'yellow');

        var gj = [];
        if (context.getDebug('collision')) {
            gj = rtree.all().map(function(d) {
                return { type: 'Polygon', coordinates: [[
                    [d.minX, d.minY],
                    [d.maxX, d.minY],
                    [d.maxX, d.maxY],
                    [d.minX, d.maxY],
                    [d.minX, d.minY]
                ]]};
            });
        }

        var boxes = selection.selectAll('.' + which)
            .data(gj);

        // exit
        boxes.exit()
            .remove();

        // enter/update
        boxes.enter()
            .append('path')
            .attr('class', classes)
            .merge(boxes)
            .attr('d', d3_geoPath());
    }


    function drawLabels(selection, graph, entities, filter, dimensions, fullRedraw) {
        var wireframe = context.surface().classed('fill-wireframe');
        var zoom = geoScaleToZoom(projection.scale());

        var labelable = [];
        var renderNodeAs = {};
        var i, j, k, entity, geometry;

        for (i = 0; i < labelStack.length; i++) {
            labelable.push([]);
        }

        if (fullRedraw) {
            _rdrawn.clear();
            _rskipped.clear();
            _entitybboxes = {};

        } else {
            for (i = 0; i < entities.length; i++) {
                entity = entities[i];
                var toRemove = []
                    .concat(_entitybboxes[entity.id] || [])
                    .concat(_entitybboxes[entity.id + 'I'] || []);

                for (j = 0; j < toRemove.length; j++) {
                    _rdrawn.remove(toRemove[j]);
                    _rskipped.remove(toRemove[j]);
                }
            }
        }

        // Loop through all the entities to do some preprocessing
        for (i = 0; i < entities.length; i++) {
            entity = entities[i];
            geometry = entity.geometry(graph);

            // Insert collision boxes around interesting points/vertices
            if (geometry === 'point' || (geometry === 'vertex' && isInterestingVertex(entity))) {
                const isAddr = isAddressPoint(entity.tags);
                var hasDirections = entity.directions(graph, projection).length;
                var markerPadding = 0;

                if (wireframe) {
                    renderNodeAs[entity.id] = { geometry: 'vertex', isAddr };
                } else if (geometry === 'vertex') {
                    renderNodeAs[entity.id] = { geometry: 'vertex', isAddr };
                } else if (zoom >= 18 && hasDirections) {
                    renderNodeAs[entity.id] = { geometry: 'vertex', isAddr };
                } else {
                    renderNodeAs[entity.id] = { geometry: 'point', isAddr };
                    markerPadding = 20;   // extra y for marker height
                }

                if (isAddr) {
                    undoInsert(entity.id + 'P');
                } else {
                    var coord = projection(entity.loc);
                    var nodePadding = 10;
                    var bbox = {
                        minX: coord[0] - nodePadding,
                        minY: coord[1] - nodePadding - markerPadding,
                        maxX: coord[0] + nodePadding,
                        maxY: coord[1] + nodePadding
                    };
                    doInsert(bbox, entity.id + 'P');
                }
            }

            // From here on, treat vertices like points
            if (geometry === 'vertex') {
                geometry = 'point';
            }

            // Determine which entities are label-able
            var preset = geometry === 'area' && presetManager.match(entity, graph);
            var icon = preset && !shouldSkipIcon(preset) && preset.icon;

            if (!icon && !utilDisplayName(entity)) continue;

            for (k = 0; k < labelStack.length; k++) {
                var matchGeom = labelStack[k][0];
                var matchKey = labelStack[k][1];
                var matchVal = labelStack[k][2];
                var hasVal = entity.tags[matchKey];

                if (geometry === matchGeom && hasVal && (matchVal === '*' || matchVal === hasVal)) {
                    labelable[k].push(entity);
                    break;
                }
            }
        }

        var labelled = {
            point: [],
            line: [],
            area: []
        };

        // Try and find a valid label for labellable entities
        for (k = 0; k < labelable.length; k++) {
            var fontSize = labelStack[k][3];

            for (i = 0; i < labelable[k].length; i++) {
                entity = labelable[k][i];
                geometry = entity.geometry(graph);

                var getName = (geometry === 'line') ? utilDisplayNameForPath : utilDisplayName;
                var name = getName(entity);
                var width = name && textWidth(name, fontSize, selection.select('g.layer-osm.labels').node());
                var p = null;

                if (geometry === 'point' || geometry === 'vertex') {
                    // no point or vertex labels in wireframe mode
                    // no vertex labels at low zooms (vertices have no icons)
                    if (wireframe) continue;
                    var renderAs = renderNodeAs[entity.id];
                    if (renderAs.geometry === 'vertex' && zoom < 17) continue;
                    while (renderAs.isAddr && width > 36) {
                        name = `${name.substring(0, name.replace(/…$/, '').length - 1)}…`;
                        width = textWidth(name, fontSize, selection.select('g.layer-osm.labels').node());
                    }

                    p = getPointLabel(entity, width, fontSize, renderAs);
                } else if (geometry === 'line') {
                    p = getLineLabel(entity, width, fontSize);

                } else if (geometry === 'area') {
                    p = getAreaLabel(entity, width, fontSize);
                }

                if (p) {
                    if (geometry === 'vertex') { geometry = 'point'; }  // treat vertex like point
                    p.classes = geometry + ' tag-' + labelStack[k][1];
                    labelled[geometry].push({
                        entity,
                        name,
                        position: p
                    });
                }
            }
        }


        function isInterestingVertex(entity) {
            var selectedIDs = context.selectedIDs();

            return entity.hasInterestingTags() ||
                entity.isEndpoint(graph) ||
                entity.isConnected(graph) ||
                selectedIDs.indexOf(entity.id) !== -1 ||
                graph.parentWays(entity).some(function(parent) {
                    return selectedIDs.indexOf(parent.id) !== -1;
                });
        }


        function getPointLabel(entity, width, height, style) {
            var y = (style.geometry === 'point' ? -12 : 0);
            var pointOffsets = {
                ltr: [15, y, 'start'],
                rtl: [-15, y, 'end']
            };
            const isAddr = style.isAddr;

            var textDirection = localizer.textDirection();

            var coord = projection(entity.loc);
            var textPadding = 2;
            var offset = pointOffsets[textDirection];
            if (isAddr) offset = [0, 1, 'middle'];
            var p = {
                height: height,
                width: width,
                x: coord[0] + offset[0],
                y: coord[1] + offset[1],
                textAnchor: offset[2],
                isAddr
            };

            // insert a collision box for the text label..
            let bbox;
            if (isAddr) {
                bbox = {
                    minX: p.x - (width / 2) - textPadding,
                    minY: p.y - (height / 2) - textPadding,
                    maxX: p.x + (width / 2) + textPadding,
                    maxY: p.y + (height / 2) + textPadding
                };
            } else if (textDirection === 'rtl') {
                bbox = {
                    minX: p.x - width - textPadding,
                    minY: p.y - (height / 2) - textPadding,
                    maxX: p.x + textPadding,
                    maxY: p.y + (height / 2) + textPadding
                };
            } else {
                bbox = {
                    minX: p.x - textPadding,
                    minY: p.y - (height / 2) - textPadding,
                    maxX: p.x + width + textPadding,
                    maxY: p.y + (height / 2) + textPadding
                };
            }

            if (tryInsert([bbox], entity.id, true)) {
                return p;
            }
        }


        function getLineLabel(entity, width, height) {
            var viewport = geoExtent(context.projection.clipExtent()).polygon();
            var points = graph.childNodes(entity)
                .map(function(node) { return projection(node.loc); });
            var length = geoPathLength(points);

            if (length < width + 20) return;

            // % along the line to attempt to place the label
            var lineOffsets = [50, 45, 55, 40, 60, 35, 65, 30, 70,
                               25, 75, 20, 80, 15, 95, 10, 90, 5, 95];
            var padding = 3;

            for (var i = 0; i < lineOffsets.length; i++) {
                var offset = lineOffsets[i];
                var middle = offset / 100 * length;
                var start = middle - width / 2;

                if (start < 0 || start + width > length) continue;

                // generate subpath and ignore paths that are invalid or don't cross viewport.
                var sub = subpath(points, start, start + width);
                if (!sub || !geoPolygonIntersectsPolygon(viewport, sub, true)) {
                    continue;
                }

                var isReverse = reverse(sub);
                if (isReverse) {
                    sub = sub.reverse();
                }

                var bboxes = [];
                var boxsize = (height + 2) / 2;

                for (var j = 0; j < sub.length - 1; j++) {
                    var a = sub[j];
                    var b = sub[j + 1];

                    // split up the text into small collision boxes
                    var num = Math.max(1, Math.floor(geoVecLength(a, b) / boxsize / 2));

                    for (var box = 0; box < num; box++) {
                        var p = geoVecInterp(a, b, box / num);
                        var x0 = p[0] - boxsize - padding;
                        var y0 = p[1] - boxsize - padding;
                        var x1 = p[0] + boxsize + padding;
                        var y1 = p[1] + boxsize + padding;

                        bboxes.push({
                            minX: Math.min(x0, x1),
                            minY: Math.min(y0, y1),
                            maxX: Math.max(x0, x1),
                            maxY: Math.max(y0, y1)
                        });
                    }
                }

                if (tryInsert(bboxes, entity.id, false)) {   // accept this one
                    return {
                        'font-size': height + 2,
                        lineString: lineString(sub),
                        startOffset: offset + '%'
                    };
                }
            }

            function reverse(p) {
                var angle = Math.atan2(p[1][1] - p[0][1], p[1][0] - p[0][0]);
                return !(p[0][0] < p[p.length - 1][0] && angle < Math.PI/2 && angle > -Math.PI/2);
            }

            function lineString(points) {
                return 'M' + points.join('L');
            }

            function subpath(points, from, to) {
                var sofar = 0;
                var start, end, i0, i1;

                for (var i = 0; i < points.length - 1; i++) {
                    var a = points[i];
                    var b = points[i + 1];
                    var current = geoVecLength(a, b);
                    var portion;
                    if (!start && sofar + current >= from) {
                        portion = (from - sofar) / current;
                        start = [
                            a[0] + portion * (b[0] - a[0]),
                            a[1] + portion * (b[1] - a[1])
                        ];
                        i0 = i + 1;
                    }
                    if (!end && sofar + current >= to) {
                        portion = (to - sofar) / current;
                        end = [
                            a[0] + portion * (b[0] - a[0]),
                            a[1] + portion * (b[1] - a[1])
                        ];
                        i1 = i + 1;
                    }
                    sofar += current;
                }

                var result = points.slice(i0, i1);
                result.unshift(start);
                result.push(end);
                return result;
            }
        }


        function getAreaLabel(entity, width, height) {
            var centroid = path.centroid(entity.asGeoJSON(graph));
            var extent = entity.extent(graph);
            var areaWidth = projection(extent[1])[0] - projection(extent[0])[0];

            if (isNaN(centroid[0]) || areaWidth < 20) return;

            var preset = presetManager.match(entity, context.graph());
            var picon = preset && preset.icon;
            var iconSize = 17;
            var padding = 2;
            var p = {};

            if (picon && !shouldSkipIcon(preset)) {  // icon and label..
                if (addIcon()) {
                    addLabel(iconSize + padding);
                    return p;
                }
            } else {   // label only..
                if (addLabel(0)) {
                    return p;
                }
            }


            function addIcon() {
                var iconX = centroid[0] - (iconSize / 2);
                var iconY = centroid[1] - (iconSize / 2);
                var bbox = {
                    minX: iconX,
                    minY: iconY,
                    maxX: iconX + iconSize,
                    maxY: iconY + iconSize
                };

                if (tryInsert([bbox], entity.id + 'I', true)) {
                    p.transform = 'translate(' + iconX + ',' + iconY + ')';
                    return true;
                }
                return false;
            }

            function addLabel(yOffset) {
                if (width && areaWidth >= width + 20) {
                    var labelX = centroid[0];
                    var labelY = centroid[1] + yOffset;
                    var bbox = {
                        minX: labelX - (width / 2) - padding,
                        minY: labelY - (height / 2) - padding,
                        maxX: labelX + (width / 2) + padding,
                        maxY: labelY + (height / 2) + padding
                    };

                    if (tryInsert([bbox], entity.id, true)) {
                        p.x = labelX;
                        p.y = labelY;
                        p.textAnchor = 'middle';
                        p.height = height;
                        return true;
                    }
                }
                return false;
            }
        }


        // force insert a singular bounding box
        // singular box only, no array, id better be unique
        function doInsert(bbox, id) {
            bbox.id = id;

            var oldbox = _entitybboxes[id];
            if (oldbox) {
                _rdrawn.remove(oldbox);
            }
            _entitybboxes[id] = bbox;
            _rdrawn.insert(bbox);
        }

        function undoInsert(id) {
            var oldbox = _entitybboxes[id];
            if (oldbox) {
                _rdrawn.remove(oldbox);
            }
            delete _entitybboxes[id];
        }

        function tryInsert(bboxes, id, saveSkipped) {
            var skipped = false;

            for (var i = 0; i < bboxes.length; i++) {
                var bbox = bboxes[i];
                bbox.id = id;

                // Check that label is visible
                if (bbox.minX < 0 || bbox.minY < 0 || bbox.maxX > dimensions[0] || bbox.maxY > dimensions[1]) {
                    skipped = true;
                    break;
                }
                if (_rdrawn.collides(bbox)) {
                    skipped = true;
                    break;
                }
            }

            _entitybboxes[id] = bboxes;

            if (skipped) {
                if (saveSkipped) {
                    _rskipped.load(bboxes);
                }
            } else {
                _rdrawn.load(bboxes);
            }

            return !skipped;
        }


        var layer = selection.selectAll('.layer-osm.labels');
        layer.selectAll('.labels-group')
            .data(['halo', 'label', 'debug'])
            .enter()
            .append('g')
            .attr('class', function(d) { return 'labels-group ' + d; });

        var halo = layer.selectAll('.labels-group.halo');
        var label = layer.selectAll('.labels-group.label');
        var debug = layer.selectAll('.labels-group.debug');

        // points
        drawPointLabels(label, labelled.point, filter, 'pointlabel');
        drawPointLabels(halo, labelled.point, filter, 'pointlabel-halo');

        // lines
        drawLinePaths(layer, labelled.line, filter, '');
        drawLineLabels(label, labelled.line, filter, 'linelabel');
        drawLineLabels(halo, labelled.line, filter, 'linelabel-halo');

        // areas
        drawAreaLabels(label, labelled.area, filter, 'arealabel');
        drawAreaLabels(halo, labelled.area, filter, 'arealabel-halo');
        drawAreaIcons(label, labelled.area, filter, 'areaicon');
        drawAreaIcons(halo, labelled.area, filter, 'areaicon-halo');

        // debug
        drawCollisionBoxes(debug, _rskipped, 'debug-skipped');
        drawCollisionBoxes(debug, _rdrawn, 'debug-drawn');

        layer.call(filterLabels);
    }


    function filterLabels(selection) {
        var drawLayer = selection.selectAll('.layer-osm.labels');
        var layers = drawLayer.selectAll('.labels-group.halo, .labels-group.label');

        layers.selectAll('.nolabel')
            .classed('nolabel', false);

        var mouse = context.map().mouse();
        var ids = [];
        var pad, bbox;

        // hide labels near the mouse
        if (mouse && context.mode().id !== 'browse' && context.mode().id !== 'select') {
            pad = 20;
            bbox = { minX: mouse[0] - pad, minY: mouse[1] - pad, maxX: mouse[0] + pad, maxY: mouse[1] + pad };
            var nearMouse = _rdrawn.search(bbox).map(function(entity) { return entity.id; });
            ids.push.apply(ids, nearMouse);
        }
        ids = utilArrayDifference(ids, context.mode()?.selectedIDs?.() || []);

        layers.selectAll(utilEntitySelector(ids))
            .classed('nolabel', true);


        // draw the mouse bbox if debugging is on..
        var debug = selection.selectAll('.labels-group.debug');
        var gj = [];
        if (context.getDebug('collision')) {
            gj = bbox ? [{
                type: 'Polygon',
                coordinates: [[
                    [bbox.minX, bbox.minY],
                    [bbox.maxX, bbox.minY],
                    [bbox.maxX, bbox.maxY],
                    [bbox.minX, bbox.maxY],
                    [bbox.minX, bbox.minY]
                ]]
            }] : [];
        }

        var box = debug.selectAll('.debug-mouse')
            .data(gj);

        // exit
        box.exit()
            .remove();

        // enter/update
        box.enter()
            .append('path')
            .attr('class', 'debug debug-mouse yellow')
            .merge(box)
            .attr('d', d3_geoPath());
    }


    var throttleFilterLabels = _throttle(filterLabels, 100);


    drawLabels.observe = function(selection) {
        var listener = function() { throttleFilterLabels(selection); };
        selection.on('mousemove.hidelabels', listener);
        context.on('enter.hidelabels', listener);
    };


    drawLabels.off = function(selection) {
        throttleFilterLabels.cancel();
        selection.on('mousemove.hidelabels', null);
        context.on('enter.hidelabels', null);
    };


    return drawLabels;
}


const _textWidthCache = {};
export function textWidth(text, size, container) {
    let c = _textWidthCache[size];
    if (!c) c = _textWidthCache[size] = {};

    if (c[text]) {
        return c[text];
    }
    const elem = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    elem.style.fontSize = `${size}px`;
    elem.textContent = text;
    container.appendChild(elem);
    c[text] = elem.getComputedTextLength();
    elem.remove();
    return c[text];
}


const nonPrimaryKeys = new Set([
    'check_date',
    'fixme',
    'layer',
    'level',
    'level:ref',
    'note'
]);
const nonPrimaryKeysRegex = /^(ref|survey|note):/;
export function isAddressPoint(tags) {
    const keys = Object.keys(tags).filter(key =>
        osmIsInterestingTag(key) &&
        !nonPrimaryKeys.has(key) &&
        !nonPrimaryKeysRegex.test(key)
    );
    return keys.length > 0 && keys.every(key =>
        key.startsWith('addr:')
    );
}
