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;
    }

    private MapHtmlBuilder() {
    }

    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, 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,
            DeviceRecord record,
            List<LocationPoint> history,
            String color,
            Options options) {

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

            LocationPoint point = history.get(p);

            if (point == null) continue;

            script.append("L.circleMarker([")
                .append(point.latitude)
                .append(',')
                .append(point.longitude)
                .append("],{radius:5,color:'")
                .append(color)
                .append("',fillColor:'")
                .append(color)
                .append("',fillOpacity:.9,weight:2})")
                .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 ? 10 : 22;
        int iconSize = markerSize + 6;
        int anchor = iconSize / 2;

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

        script.append("var icon").append(index)
            .append("=L.divIcon({className:'',html:\"")
            .append("<div style='width:")
            .append(markerSize)
            .append("px;height:")
            .append(markerSize)
            .append("px;border-radius:50%;background:")
            .append(markerColor)
            .append(";border:2px solid white;box-shadow:0 1px 4px #333'>")
            .append("</div>\",iconSize:[")
            .append(iconSize)
            .append(',')
            .append(iconSize)
            .append("],iconAnchor:[")
            .append(anchor)
            .append(',')
            .append(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
    // ------------------------------------------------------------------

    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("<a href='geo:")
            .append(latitude)
            .append(",")
            .append(longitude)
            .append("?q=")
            .append(latitude)
            .append(",")
            .append(longitude)
            .append("'>GPS: ")
            .append(latitude)
            .append(", ")
            .append(longitude)
            .append("</a>");

        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><b>View: Normalized</b>");

            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>GPS: ")
            .append(decision.point.latitude)
            .append(", ")
            .append(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>");

        long time = HistoryNormalizer.pointTime(point);

        if (time > 0) {

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

        html.append("<br>GPS: ")
            .append(point.latitude)
            .append(", ")
            .append(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}"

            + ".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);"

            + "L.control.layers({"
            + "'Streets':streets,"
            + "'Satellite':sat"
            + "},null,{collapsed:false}).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>";
    }
}
