package com.wytehat.btlogger;

import java.io.File;
import java.util.List;

/**
 * Builds the Leaflet page the map WebView loads: one marker per device, an
 * optional trail of history points, and - in normalized mode - the points
 * normalization removed, drawn in red (impossible jump) or amber (duplicate)
 * with a dashed line back to the last point that was trusted.
 *
 * Everything the page needs is pushed in through {@link Options} and
 * {@link DataSource}, so this knows nothing about the activity's widgets.
 */
public final class MapHtmlBuilder {

    /** Where the per-device history and removal lists come from. */
    public interface DataSource {

        /** History for this device, already date-filtered. */
        List<LocationPoint> historyFor(DeviceRecord record);

        /** Points normalization removed, already date-filtered. */
        List<HistoryNormalizer.Decision> removedPointsFor(String address);
    }

    /** What the filter bar currently says. */
    public static class Options {

        public boolean normalizedMode;
        public boolean showRemovedPoints;
        public boolean showHistory;

        public int aggressiveness = HistoryNormalizer.DEFAULT_AGGRESSIVENESS;
    }

    /*
     * Font Awesome Free 6.5.2 "link" and "link-slash" (solid), inlined as
     * SVG paths rather than pulled from a CDN - the whole icon font would be
     * a ~75KB download over the network for two glyphs, and the popups have
     * to render when the map is loaded from cache.
     *
     * Icons: CC BY 4.0 (https://fontawesome.com/license/free),
     * Copyright 2024 Fonticons, Inc.
     */
    private static final String ICON_LINK_PATH =
        "M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4"
        + "l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1"
        + "c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8"
        + "c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6"
        + "c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6"
        + "C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7z"
        + "M60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4"
        + "l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1"
        + "c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5L217.7 177.2"
        + "c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6"
        + "c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6"
        + "C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z";

    private static final String ICON_LINK_SLASH_PATH =
        "M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464"
        + "c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5"
        + "c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1"
        + "c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1"
        + "c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25"
        + "C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7"
        + "L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5"
        + "L239 162zM116.6 187.9L60.2 244.3c-56.5 56.5-56.5 148 0 204.5"
        + "c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6"
        + "s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6"
        + "C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9zM220.9 270"
        + "c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3L220.9 270z";

    /** Diameter of a device's own marker; history points draw at half. */
    private static final int DEVICE_MARKER_SIZE = 22;

    /** A location-locked device draws smaller, since its position is fixed. */
    private static final int LOCKED_MARKER_SIZE = 10;

    private MapHtmlBuilder() {
    }

    /** One of the inlined Font Awesome glyphs, tinted and sized for a popup line. */
    private static String icon(String path, String color) {

        return "<svg viewBox='0 0 640 512' width='15' height='12' "
            + "fill='" + color + "' "
            + "style='vertical-align:-1px;margin-right:5px'>"
            + "<path d='" + path + "'/></svg>";
    }

    /** The round white-bordered marker, shared by devices and history points. */
    private static String divIcon(
            String color,
            int markerSize,
            int iconSize,
            int anchor) {

        return "L.divIcon({className:'',html:\""
            + "<div style='width:" + markerSize
            + "px;height:" + markerSize
            + "px;border-radius:50%;background:" + color
            + ";border:2px solid white;box-shadow:0 1px 4px #333'>"
            + "</div>\",iconSize:[" + iconSize + "," + iconSize
            + "],iconAnchor:[" + anchor + "," + anchor + "]})";
    }

    public static String build(
            List<DeviceRecord> rows,
            String selectedAddress,
            Options options,
            DataSource data) {

        StringBuilder script = new StringBuilder();

        for (int i = 0; i < rows.size(); i++) {

            DeviceRecord record = rows.get(i);

            if (!record.hasLocation) continue;

            String color = MapFormat.validColor(record.pinColor);

            List<LocationPoint> history = data.historyFor(record);

            if (options.normalizedMode && options.showRemovedPoints) {
                appendRemovedPoints(script, record, options, data);
            }

            double markerLatitude = record.latitude;
            double markerLongitude = record.longitude;

            long markerTime = record.updatedAt;

            /*
             * The device's stored fix may itself have been normalized away,
             * so pin it to the newest surviving point instead.
             */
            if (options.normalizedMode && history.size() > 0) {

                LocationPoint latest = history.get(history.size() - 1);

                if (latest != null) {

                    markerLatitude = latest.latitude;
                    markerLongitude = latest.longitude;

                    long normalizedTime =
                        HistoryNormalizer.pointTime(latest);

                    if (normalizedTime > 0) markerTime = normalizedTime;
                }
            }

            if (options.showHistory && history.size() > 0) {

                if (record.showTrail == 1 && history.size() > 1) {
                    appendTrail(script, i, history, color);
                }

                appendHistoryPoints(script, i, record, history, color, options);
            }

            appendDeviceMarker(
                script,
                i,
                record,
                markerLatitude,
                markerLongitude,
                markerTime,
                color,
                selectedAddress,
                options);
        }

        return page(script.toString(), selectedAddress);
    }

    // ------------------------------------------------------------------
    // Layers
    // ------------------------------------------------------------------

    private static void appendRemovedPoints(
            StringBuilder script,
            DeviceRecord record,
            Options options,
            DataSource data) {

        List<HistoryNormalizer.Decision> decisions =
            data.removedPointsFor(record.address);

        for (int d = 0; d < decisions.size(); d++) {

            HistoryNormalizer.Decision decision = decisions.get(d);

            if (decision == null || decision.point == null) continue;

            LocationPoint removed = decision.point;

            /*
             * Red = rejected GPS jump
             * Yellow = duplicate/suppressed point
             */
            boolean jump =
                decision.reason != null
                    && decision.reason.indexOf("Impossible") >= 0;

            String removedColor = jump ? "#E53935" : "#F9A825";

            if (decision.previousAccepted != null) {

                script.append("L.polyline([[")
                    .append(decision.previousAccepted.latitude)
                    .append(',')
                    .append(decision.previousAccepted.longitude)
                    .append("],[")
                    .append(removed.latitude)
                    .append(',')
                    .append(removed.longitude)
                    .append("]],{color:'")
                    .append(removedColor)
                    .append("',weight:2,dashArray:'6,6',opacity:.8})")
                    .append(".addTo(map);");
            }

            script.append("L.circleMarker([")
                .append(removed.latitude)
                .append(',')
                .append(removed.longitude)
                .append("],{radius:8,color:'")
                .append(removedColor)
                .append("',fillColor:'")
                .append(removedColor)
                .append("',fillOpacity:.85,weight:3})")
                .append(".addTo(map).bindPopup('")
                .append(MapFormat.js(removedPopup(record, decision, options)))
                .append("');");

            script.append("bounds.push([")
                .append(removed.latitude)
                .append(',')
                .append(removed.longitude)
                .append("]);");
        }
    }

    private static void appendTrail(
            StringBuilder script,
            int index,
            List<LocationPoint> history,
            String color) {

        script.append("var trail").append(index).append("=[");

        for (int p = 0; p < history.size(); p++) {

            if (p > 0) script.append(',');

            LocationPoint point = history.get(p);

            script.append('[')
                .append(point.latitude)
                .append(',')
                .append(point.longitude)
                .append(']');
        }

        script.append("];L.polyline(trail")
            .append(index)
            .append(",{color:'")
            .append(color)
            .append("',weight:4,opacity:.65}).addTo(map);");
    }

    private static void appendHistoryPoints(
            StringBuilder script,
            int index,
            DeviceRecord record,
            List<LocationPoint> history,
            String color,
            Options options) {

        /*
         * Same marker the device itself uses, at half size. Defined once per
         * device and shared by all of its history points -- a divIcon is a
         * DOM node per marker, so building one per point would be costly on
         * a long trail.
         */
        int markerSize = DEVICE_MARKER_SIZE / 2;
        int iconSize = markerSize + 6;
        int anchor = iconSize / 2;

        script.append("var hicon").append(index).append('=')
            .append(divIcon(color, markerSize, iconSize, anchor))
            .append(';');

        for (int p = 0; p < history.size(); p++) {

            LocationPoint point = history.get(p);

            if (point == null) continue;

            script.append("L.marker([")
                .append(point.latitude)
                .append(',')
                .append(point.longitude)
                .append("],{icon:hicon")
                .append(index)
                .append("})")
                .append(".addTo(map).bindPopup('")
                .append(MapFormat.js(historyPopup(record, point, options)))
                .append("');");
        }
    }

    private static void appendDeviceMarker(
            StringBuilder script,
            int index,
            DeviceRecord record,
            double latitude,
            double longitude,
            long time,
            String color,
            String selectedAddress,
            Options options) {

        boolean locked = record.locationLocked == 1;

        int markerSize = locked
            ? LOCKED_MARKER_SIZE
            : DEVICE_MARKER_SIZE;

        int iconSize = markerSize + 6;
        int anchor = iconSize / 2;

        String markerColor = locked ? "#00A86B" : color;

        script.append("var icon").append(index).append('=')
            .append(divIcon(markerColor, markerSize, iconSize, anchor))
            .append(';');

        script.append("var marker").append(index)
            .append("=L.marker([")
            .append(latitude)
            .append(',')
            .append(longitude)
            .append("],{icon:icon")
            .append(index)
            .append("}).addTo(map).bindPopup('")
            .append(MapFormat.js(
                devicePopup(record, latitude, longitude, time, options)))
            .append("');");

        script.append("bounds.push([")
            .append(latitude)
            .append(',')
            .append(longitude)
            .append("]);");

        if (record.address != null
                && record.address.equals(selectedAddress)) {

            script.append("marker").append(index)
                .append(".openPopup();map.setView([")
                .append(latitude)
                .append(',')
                .append(longitude)
                .append("],21);");
        }
    }

    // ------------------------------------------------------------------
    // Popups
    // ------------------------------------------------------------------

    /**
     * Coordinates as a tappable geo: link. MapActivity's WebViewClient
     * intercepts geo: and hands it to the maps app, so every popup that
     * shows a position should use this rather than plain text.
     */
    private static String gpsLink(double latitude, double longitude) {

        return "<a href='geo:"
            + latitude + "," + longitude
            + "?q=" + latitude + "," + longitude
            + "'>GPS: "
            + latitude + ", " + longitude
            + "</a>";
    }

    private static String devicePopup(
            DeviceRecord record,
            double latitude,
            double longitude,
            long time,
            Options options) {

        StringBuilder html = new StringBuilder();

        if (record.photoUri != null && new File(record.photoUri).exists()) {

            html.append("<img src='file://")
                .append(MapFormat.htmlText(record.photoUri))
                .append("' style='width:72px;height:72px;")
                .append("object-fit:cover;border-radius:10px'><br>");
        }

        html.append("<b>")
            .append(MapFormat.htmlText(record.displayName()))
            .append("</b><br>");

        html.append(gpsLink(latitude, longitude));

        if (record.accuracy > 0) {

            html.append(" (±")
                .append(Math.round(record.accuracy))
                .append(" m)");
        }

        if (MapFormat.hasValue(record.category)
                && !"Other".equalsIgnoreCase(record.category)) {

            html.append("<br>Category: ")
                .append(MapFormat.htmlText(record.category));
        }

        if (MapFormat.hasValue(record.vendorName)
                && !"Unknown".equalsIgnoreCase(record.vendorName)
                && !"Manufacturer unknown"
                    .equalsIgnoreCase(record.vendorName)) {

            html.append("<br>Manufacturer: ")
                .append(MapFormat.htmlText(record.vendorName));
        }

        if (time > 0) {

            html.append("<br>Last seen: ")
                .append(MapFormat.htmlText(MapFormat.date(time)));
        }

        if (record.battery >= 0) {

            html.append("<br>Battery: ")
                .append(record.battery)
                .append("%");
        }

        if (record.rssi != 0 && record.lastRssiAt > 0) {

            html.append("<br>RSSI: ")
                .append(record.rssi)
                .append(" dBm");
        }

        String status = MapFormat.reason(record.reason);

        if (MapFormat.hasValue(status)
                && !"Status unknown".equalsIgnoreCase(status)) {

            html.append("<br>Status: ")
                .append(MapFormat.htmlText(status));
        }

        if (options.normalizedMode) {

            html.append("<br>Normalization: ")
                .append(MapFormat.htmlText(
                    HistoryNormalizer.levelName(options.aggressiveness)));
        }

        return html.toString();
    }

    private static String removedPopup(
            DeviceRecord record,
            HistoryNormalizer.Decision decision,
            Options options) {

        StringBuilder html = new StringBuilder();

        html.append("<b>")
            .append(MapFormat.htmlText(record.displayName()))
            .append("</b>");

        html.append("<br><span style='color:#E53935'><b>")
            .append(MapFormat.htmlText(decision.reason))
            .append("</b></span>");

        long time = HistoryNormalizer.pointTime(decision.point);

        if (time > 0) {

            html.append("<br>Date/time: ")
                .append(MapFormat.htmlText(MapFormat.date(time)));
        }

        html.append("<br>")
            .append(gpsLink(
                decision.point.latitude,
                decision.point.longitude));

        html.append("<br>Distance from previous accepted point: ")
            .append(MapFormat.number(decision.distanceMeters))
            .append(" m");

        html.append("<br>Elapsed time: ")
            .append(MapFormat.number(decision.elapsedSeconds))
            .append(" s");

        if (decision.elapsedSeconds > 0) {

            html.append("<br>Calculated speed: ")
                .append(MapFormat.number(decision.speedKmh))
                .append(" km/h");
        }

        if (decision.threshold > 0) {

            boolean jump =
                decision.reason != null
                    && decision.reason.indexOf("Impossible") >= 0;

            if (jump) {

                html.append("<br>Allowed speed at current setting: ")
                    .append(MapFormat.number(decision.threshold))
                    .append(" km/h");

            } else {

                html.append("<br>Allowed distance: ")
                    .append(MapFormat.number(decision.threshold))
                    .append(" m");
            }
        }

        html.append("<br>Normalization strength: ")
            .append(options.aggressiveness)
            .append("%");

        return html.toString();
    }

    private static String historyPopup(
            DeviceRecord record,
            LocationPoint point,
            Options options) {

        StringBuilder html = new StringBuilder();

        html.append("<b>")
            .append(MapFormat.htmlText(record.displayName()))
            .append("</b>");

        html.append("<br><b>")
            .append(
                options.normalizedMode
                    ? "Normalized recorded point"
                    : "Previous recorded point")
            .append("</b>");

        /*
         * Why the point exists at all. Rows logged before the event column
         * was added carry no event, and a plain position refresh carries
         * none either, so say nothing rather than guess.
         */
        if (LocationPoint.EVENT_CONNECTED.equals(point.event)) {

            html.append("<br>")
                .append(icon(ICON_LINK_PATH, "#00A86B"))
                .append("Connected to the phone here");

        } else if (LocationPoint.EVENT_DISCONNECTED.equals(point.event)) {

            html.append("<br>")
                .append(icon(ICON_LINK_SLASH_PATH, "#E53935"))
                .append("Link to the phone dropped here");
        }

        long time = HistoryNormalizer.pointTime(point);

        if (time > 0) {

            html.append("<br>Date/time: ")
                .append(MapFormat.htmlText(MapFormat.date(time)));
        }

        html.append("<br>")
            .append(gpsLink(point.latitude, point.longitude));

        double accuracy =
            HistoryNormalizer.pointDouble(point, "accuracy", -1);

        if (accuracy > 0) {

            html.append("<br>Accuracy: ±")
                .append(Math.round(accuracy))
                .append(" m");
        }

        double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0);

        if (rssi != 0) {

            html.append("<br>RSSI: ")
                .append(Math.round(rssi))
                .append(" dBm");
        }

        double battery = HistoryNormalizer.pointDouble(point, "battery", -1);

        if (battery >= 0) {

            html.append("<br>Battery: ")
                .append(Math.round(battery))
                .append("%");
        }

        return html.toString();
    }

    // ------------------------------------------------------------------
    // Page shell
    // ------------------------------------------------------------------

    private static String page(String script, String selectedAddress) {

        return "<!doctype html><html><head>"
            + "<meta name='viewport' content='width=device-width,initial-scale=1'>"

            + "<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'>"

            + "<style>"
            + "html,body,#map{height:100%;margin:0}"
            + ".leaflet-popup-content{font:14px sans-serif;line-height:1.5}"
            + ".leaflet-control-layers{font:13px sans-serif}"

            + ".phone-bearing{"
            + "position:relative;"
            + "width:38px;"
            + "height:38px;"
            + "transform-origin:19px 19px"
            + "}"

            + ".phone-dot{"
            + "position:absolute;"
            + "left:9px;"
            + "top:9px;"
            + "width:16px;"
            + "height:16px;"
            + "border-radius:50%;"
            + "background:#4285F4;"
            + "border:3px solid white;"
            + "box-shadow:0 1px 4px #555"
            + "}"

            + ".phone-tip{"
            + "position:absolute;"
            + "left:15px;"
            + "top:1px;"
            + "width:0;"
            + "height:0;"
            + "border-left:5px solid transparent;"
            + "border-right:5px solid transparent;"
            + "border-bottom:10px solid #4285F4"
            + "}"

            + "</style></head><body>"

            + "<div id='map'></div>"

            + "<script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script>"

            + "<script>"

            + "var streets=L.tileLayer("
            + "'https://tile.openstreetmap.org/{z}/{x}/{y}.png',"
            + "{maxNativeZoom:19,maxZoom:22,"
            + "attribution:'© OpenStreetMap contributors'}),"

            + "sat=L.tileLayer("
            + "'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',"
            + "{maxNativeZoom:19,maxZoom:22,"
            + "attribution:'Esri World Imagery'}),"

            + "map=L.map('map',{layers:[streets]}).setView([20,0],2);"

            // collapsed:true is what makes this the floating corner button
            // that opens on tap. collapsed:false pins the whole list open
            // over the map as a permanent box.
            + "L.control.layers({"
            + "'Streets':streets,"
            + "'Satellite':sat"
            + "},null,{collapsed:true}).addTo(map);"

            + "var phone=null,phoneShown=false,"

            + "phoneIcon=L.divIcon({"
            + "className:'',"
            + "html:'<div id=phoneBearing class=phone-bearing><div class=phone-dot></div><div class=phone-tip></div></div>',"
            + "iconSize:[38,38],"
            + "iconAnchor:[19,19]"
            + "});"

            + "function updatePhone(a,b,h){"
            + "var p=[a,b];"

            + "if(!phone)"
            + "phone=L.marker(p,{icon:phoneIcon,zIndexOffset:1100}).addTo(map);"
            + "else phone.setLatLng(p);"

            + "if(!phoneShown){"
            + "phoneShown=true;"
            + "var visible=bounds.slice(0);"
            + "visible.push(p);"

            + "if(visible.length>1)"
            + "map.fitBounds(visible,{padding:[35,35],maxZoom:20});"
            + "else map.setView(p,20);"
            + "}"

            + "var e=document.getElementById('phoneBearing');"
            + "if(e)e.style.transform='rotate('+h+'deg)';"
            + "}"

            + "var bounds=[];"

            + script

            + "if(bounds.length>0&&"
            + "'"
            + MapFormat.js(selectedAddress == null ? "" : selectedAddress)
            + "'=='')"
            + "map.fitBounds(bounds,{padding:[30,30],maxZoom:20});"

            + "</script></body></html>";
    }
}
