// Colours
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
const ITU_ZONES_COLOR_LIGHT = 'rgba(200, 200, 140, 1.0)';
const WAB_WAI_GRID_COLOR_LIGHT = 'rgba(140, 140, 200, 1.0)';
const MAIDENHEAD_GRID_COLOR_DARK = 'rgba(120, 60, 60, 1.0)';
const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';

// SSE connection for live spot updates
let evtSource;
let restartSSEOnErrorTimeoutId;
// Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show
// one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for
// if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating
// between the two as updates come in.
let spotMarkers = new Map();

// Map layers
let backgroundTileLayer;
let markersLayer;
let geodesicsLayer;
let oms;
let terminator;
let maidenheadGrid;
let cqZones;
let ituZones;
let wabwaiGrid;
// Tracks the currently-loaded basemap provider string to avoid unnecessary tile reloads
let loadedBasemap;
// Tracks whether this is the first display of markers after page load
let firstLoad = true;

// Load spots and populate the map.
function loadSpots() {
    // Close any existing SSE connection before fetching fresh data
    if (evtSource != null) {
        evtSource.close();
    }
    // Including QRZ/HamQTH lookups to improve positions causes the load to be really slow, so on first load
    // the user would be waiting ages without data and will think it's broken. We therefore load in several
    // stages:
    // 1) Load without any credentials
    // 2) (If we have credentials) reload with them, replacing what's already there,
    // 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
    // data if they can.
    $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
        // Store data
        spots = jsonData;
        // Update map
        updateMap();
        if ($("#showTerminator")[0].checked) {
            terminator.setTime();
        }
        // Check if we have any credentials to use
        if (getCredentialQueryString() !== "") {
            // OK, we have credentials and have loaded once without them so the user has a basic map. Now reload
            // with the credentials and replace what's on the map, so we can improve the data.
            $.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData2) {
                spots = jsonData2;
                updateMap();
                // Now start the ongoing SSE connection
                startSSEConnection();
            });
        } else {
            // We had no credentials with which to augment the data anyway, so just start the SSE connection
            // now
            startSSEConnection();
        }
    }});
}

// Start the SSE connection to receive new spots as they arrive
function startSSEConnection() {
    if (evtSource != null) {
        evtSource.close();
    }
    // SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
    evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));

    evtSource.onmessage = function (event) {
        const newSpot = JSON.parse(event.data);
        const key = spotKey(newSpot);

        // Remove existing marker/geodesic for this callsign if present
        removeSpotFromMap(key);
        spots = spots.filter(s => spotKey(s) !== key);

        // Skip spots with no map coordinates
        if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) {
            return;
        }

        // Add to data store and map
        spots.unshift(newSpot);
        addSpotToMap(newSpot);
    };

    evtSource.onerror = function () {
        if (evtSource != null) {
            evtSource.close();
        }
        clearTimeout(restartSSEOnErrorTimeoutId);
        restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
    };
}

// Remove spots from the map that are older than the selected max age.
function expireOldSpots() {
    const maxAgeSeconds = parseInt($("#max_spot_age option:selected").val());
    const cutoff = (Date.now() / 1000) - maxAgeSeconds;
    spots = spots.filter(function (s) {
        if (s["time"] && s["time"] < cutoff) {
            removeSpotFromMap(spotKey(s));
            return false;
        }
        return true;
    });
}

// Returns a unique key for a spot based on its callsign and SSID. This is in case the APRS spot source is enabled and
// we want to display all SSIDs for a given callsign, not just the latest one.
function spotKey(s) {
    return s["dx_call"] + (s["dx_ssid"] ? "-" + s["dx_ssid"] : "");
}

// Add a single spot's marker and geodesic to the map and to spotMarkers
function addSpotToMap(s) {
    const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
    m.bindPopup(getTooltipText(s));
    markersLayer.addLayer(m);
    oms.addMarker(m);

    let geodesic = null;
    if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
        try {
            geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
                color: bandToColor(s['band']),
                wrap: false,
                steps: 5
            });
            geodesicsLayer.addLayer(geodesic);
        } catch (e) {
            // Not sure what causes these but better to continue than to crash out
        }
    }

    spotMarkers.set(spotKey(s), {marker: m, geodesic: geodesic});
}

// Remove a spot's marker and geodesic from the map and from spotMarkers.
function removeSpotFromMap(key) {
    const entry = spotMarkers.get(key);
    if (entry) {
        markersLayer.removeLayer(entry.marker);
        oms.removeMarker(entry.marker);
        if (entry.geodesic) {
            geodesicsLayer.removeLayer(entry.geodesic);
        }
        spotMarkers.delete(key);
    }
}

// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() {
    let str = "?";
    ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
        if (!allFilterOptionsSelected(fn)) {
            str = str + getQueryStringFor(fn) + "&";
        }
    });
    str = str + "max_age=" + $("#max_spot_age option:selected").val();
    // Additional filters for the map view: No dupes, no QRT, only spots with good locations
    str = str + "&dedupe=true&allow_qrt=false";
    return str;
}

// Update the spots map
function updateMap() {
    // Clear existing content
    markersLayer.clearLayers();
    geodesicsLayer.clearLayers();
    oms.clearMarkers();
    spotMarkers.clear();

    // Make new markers for all spots
    spots.forEach(function (s) {
        if (s["dx_latitude"] == null || s["dx_longitude"] == null) {
            return;
        }
        addSpotToMap(s);
    });

    // On first load, zoom to the extent of the markers
    if (firstLoad) {
        if (markersLayer.getLayers().length >= 2) {
            const group = new L.featureGroup(markersLayer.getLayers());
            map.fitBounds(group.getBounds().pad(0.1));
        }
        firstLoad = false;
    }
}

// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
function getIcon(s) {
    return L.ExtraMarkers.icon({
        icon: sigToIcon(s["sig"], "fa-tower-cell"),
        iconColor: bandToContrastColor(s["band"]),
        markerColor: bandToColor(s["band"]),
        shape: 'circle',
        prefix: 'fa',
        svg: true
    });
}

// Tooltip text for the markers
function getTooltipText(s) {
    // Format DX call
    let dx_call = s["dx_call"];
    if (dx_call == null) {
        dx_call = "";
    }
    if (s["dx_ssid"] != null) {
        dx_call = dx_call + "-" + s["dx_ssid"];
    }

    // Format DX flag
    let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
    if (dx_call == null) {
        dx_flag = "";
    }
    if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] !== "") {
        dx_flag = s["dx_flag"];
    }

    // Format the frequency
    let freq_string = "Unknown";
    if (s["freq"] != null) {
        const mhz = Math.floor(s["freq"] / 1000000.0);
        const khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0);
        const hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0));
        const hz_string = (hz > 0) ? hz.toFixed(0)[0] : "";
        freq_string = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
    }

    // Format comment
    let commentText = "";
    if (s["comment"] != null) {
        commentText = escapeHtml(s["comment"]);
    }

    // Sig or fallback to source
    let sigSourceText = s["source"];
    if (s["sig"]) {
        sigSourceText = s["sig"];
    }

    // Format sig_refs
    let sig_refs = "";
    if (s["sig_refs"] != null) {
        const items = [];
        for (let i = 0; i < s["sig_refs"].length; i++) {
            if (s["sig_refs"][i]["url"] != null) {
                items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
            } else {
                items[i] = `${s["sig_refs"][i]["id"]}`
            }
        }
        sig_refs = items.join(", ");
    }

    // DX
    let ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${dx_call}' target='_blank' class="dx-link">${dx_call}</a></span><br/>`;

    // Frequency & band
    ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span>&nbsp;${freq_string}`;
    if (s["band"] != null) {
        ttt += ` (${s["band"]})`;
    }
    // Mode
    if (s["mode"] != null) {
        ttt += ` &nbsp;&nbsp; <i class='fa-solid fa-wave-square markerPopupIcon'></i>&nbsp;${s["mode"]}`;
    }
    ttt += "<br/>";

    // Source / SIG / Ref
    ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;

    // Time
    ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`;

    // Comment
    if (commentText.length > 0) {
        ttt += `<br/><span class='icon-wrapper'><i class='fa-solid fa-comment markerPopupIcon'></i></span> ${commentText}`;
    }

    return ttt;
}

// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
    $.getJSON('/api/v2/options', function (jsonData) {
        // Store options
        options = jsonData;

        // First pass loading settings, so we can load the band colour scheme before the filters that need to use it
        loadSettings();
        setColorScheme($("#color_scheme option:selected").val());
        setBandColorScheme($("#band_color_scheme option:selected").val());

        // Add CSS for band toggle buttons
        addBandToggleColourCSS(options["bands"]);

        // Populate the filters panel
        generateBandsMultiToggleFilterCard(options["bands"]);
        generateSIGsMultiToggleFilterCard(options["sigs"]);
        generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
        generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
        generateModesMultiToggleFilterCard(options["modes"]);
        generateSourcesMultiToggleFilterCard(options["spot_providers"], options["spot_providers_enabled_by_default"]);

        // Load URL params. These may select things from the various filter & display options, so the function needs
        // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
        // loading settings, so this needs to be called before that.
        loadURLParams();
        loadMapURLParams();

        // Load settings from settings storage now all the controls are available
        loadSettings();

        // If no basemap has been explicitly saved and the UI is in dark mode, default to dark Mapnik
        if (localStorage.getItem("#basemap:value") === null) {
            if (document.documentElement.getAttribute("data-bs-theme") === "dark") {
                $("#basemap").val("OpenStreetMap.Mapnik.Dark");
            }
        }

        // Apply basemap and overlay settings now that controls have their saved values
        setBasemap($("#basemap").val());
        setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
        enableTerminator($("#showTerminator")[0].checked);
        enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
        enableCQZones($("#showCQZones")[0].checked);
        enableITUZones($("#showITUZones")[0].checked);
        enableWABWAIGrid($("#showWABWAIGrid")[0].checked);

        // Load spots and start SSE for live updates
        loadSpots();
        // Set up spot expiry checker
        setInterval(expireOldSpots, 60 * 1000);
    });
}

// Method called when any display property is changed to reload the map and persist the display settings.
function displayUpdated() {
    updateMap();
    setBasemap($("#basemap").val());
    setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
    enableTerminator($("#showTerminator")[0].checked);
    enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
    enableCQZones($("#showCQZones")[0].checked);
    enableITUZones($("#showITUZones")[0].checked);
    enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
    saveSettings();
}

// Set the basemap
function setBasemap(basemapname) {
    // Only change if we have to, to avoid a flash of reloading content
    if (loadedBasemap !== basemapname) {
        loadedBasemap = basemapname;
        if (typeof backgroundTileLayer !== 'undefined') {
            map.removeLayer(backgroundTileLayer);
        }
        // OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
        const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
        backgroundTileLayer = L.tileLayer.provider(providerName, {
            opacity: parseFloat($("#basemapOpacity").val()),
            edgeBufferTiles: 1
        });
        backgroundTileLayer.addTo(map);
        backgroundTileLayer.bringToBack();
        if (basemapname === "OpenStreetMap.Mapnik.Dark") {
            const container = backgroundTileLayer.getContainer();
            if (container) {
                container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
            }
        }

        // Identify dark basemaps to ensure we use white text for unselected icons
        // and change the background colour appropriately
        const basemapIsDark = basemapname === "CartoDB.DarkMatter" || basemapname === "Esri.WorldImagery" || basemapname === "OpenStreetMap.Mapnik.Dark";
        $("#map").css('background-color', basemapIsDark ? "black" : "white");

        // Change the colour of the grid and zone overlays to match
        if (basemapIsDark) {
            maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_DARK;
            cqZones.options.color = CQ_ZONES_COLOR_DARK;
            ituZones.options.color = ITU_ZONES_COLOR_DARK;
            wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_DARK;
        } else {
            maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_LIGHT;
            cqZones.options.color = CQ_ZONES_COLOR_LIGHT;
            ituZones.options.color = ITU_ZONES_COLOR_LIGHT;
            wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_LIGHT;
        }

        // Force regenerate overlays in the new colours
        map.removeLayer(maidenheadGrid);
        map.removeLayer(cqZones);
        map.removeLayer(ituZones);
        map.removeLayer(wabwaiGrid);
        enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
        enableCQZones($("#showCQZones")[0].checked);
        enableITUZones($("#showITUZones")[0].checked);
        enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
    }
}

// Set the basemap opacity
function setBasemapOpacity(opacity) {
    if (typeof backgroundTileLayer !== 'undefined') {
        backgroundTileLayer.setOpacity(opacity);
    }
}

// Shows/hides the terminator/greyline overlay
function enableTerminator(show) {
    if (show) {
        terminator.setTime();
        terminator.addTo(map);
    } else {
        map.removeLayer(terminator);
    }
}

// Shows/hides the Maidenhead grid overlay
function enableMaidenheadGrid(show) {
    if (show) {
        maidenheadGrid.addTo(map);
        backgroundTileLayer.bringToBack();
    } else {
        map.removeLayer(maidenheadGrid);
    }
}

// Shows/hides the CQ zone overlay
function enableCQZones(show) {
    if (show) {
        cqZones.addTo(map);
        backgroundTileLayer.bringToBack();
    } else {
        map.removeLayer(cqZones);
    }
}

// Shows/hides the ITU zone overlay
function enableITUZones(show) {
    if (show) {
        ituZones.addTo(map);
        backgroundTileLayer.bringToBack();
    } else {
        map.removeLayer(ituZones);
    }
}

// Shows/hides the WAB/WAI grid overlay
function enableWABWAIGrid(show) {
    if (show) {
        wabwaiGrid.addTo(map);
        backgroundTileLayer.bringToBack();
    } else {
        map.removeLayer(wabwaiGrid);
    }
}

// Load map-specific URL parameters for center position and zoom level.
// These set Leaflet state directly rather than form controls, so they live here rather than in loadURLParams().
// If any parameter is applied, firstLoad is set to false so updateMap() does not override the position.
function loadMapURLParams() {
    let params = new URLSearchParams(document.location.search);
    let lat = parseFloat(params.get("map-center-lat"));
    let lon = parseFloat(params.get("map-center-lon"));
    let zoom = parseFloat(params.get("map-zoom"));

    let hasLatLon = !isNaN(lat) && !isNaN(lon);
    let hasZoom = !isNaN(zoom);

    if (hasLatLon || hasZoom) {
        if (hasLatLon && hasZoom) {
            map.setView([lat, lon], zoom);
        } else if (hasLatLon) {
            map.setView([lat, lon], map.getZoom());
        } else {
            map.setZoom(zoom);
        }
        firstLoad = false;
    }
}

// Set up the map
function setUpMap() {
    // Create map
    map = L.map('map', {
        zoomControl: false,
        minZoom: 2,
        maxZoom: 12
    });

    // Add basemap
    loadedBasemap = $("#basemap").val();
    const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
    backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
        opacity: parseFloat($("#basemapOpacity").val()),
        edgeBufferTiles: 1
    });
    backgroundTileLayer.addTo(map);
    backgroundTileLayer.bringToBack();
    if (loadedBasemap === "OpenStreetMap.Mapnik.Dark") {
        const container = backgroundTileLayer.getContainer();
        if (container) {
            container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
        }
    }

    // Add marker layer
    markersLayer = new L.LayerGroup();
    markersLayer.addTo(map);

    // Set up spiderfy for overlapping markers
    oms = new OverlappingMarkerSpiderfier(map, {keepSpiderfied: true});
    oms.addListener('click', function (marker) {
        marker.openPopup();
    });

    // Add geodesic layer
    geodesicsLayer = new L.LayerGroup();
    geodesicsLayer.addTo(map);

    // Add terminator/greyline (toggleable)
    terminator = L.terminator({
        interactive: false
    });
    terminator.setStyle({fillColor: '#00000050'});
    if ($("#showTerminator")[0].checked) {
        terminator.addTo(map);
    }

    // Add Maidenhead grid (toggleable)
    maidenheadGrid = L.maidenhead({
        color: MAIDENHEAD_GRID_COLOR_LIGHT
    });
    if ($("#showMaidenheadGrid")[0].checked) {
        maidenheadGrid.addTo(map);
        backgroundTileLayer.bringToBack();
    }

    // Add CQ zone layer (toggleable)
    cqZones = L.cqzones({
        color: CQ_ZONES_COLOR_LIGHT
    });
    if ($("#showCQZones")[0].checked) {
        cqZones.addTo(map);
        backgroundTileLayer.bringToBack();
    }

    // Add ITU zone layer (toggleable)
    ituZones = L.ituzones({
        color: ITU_ZONES_COLOR_LIGHT
    });
    if ($("#showITUZones")[0].checked) {
        ituZones.addTo(map);
        backgroundTileLayer.bringToBack();
    }

    // Add WAB/WAI grid layer (toggleable)
    wabwaiGrid = L.workedAllBritainIreland({
        color: WAB_WAI_GRID_COLOR_LIGHT
    });
    if ($("#showWABWAIGrid")[0].checked) {
        wabwaiGrid.addTo(map);
        backgroundTileLayer.bringToBack();
    }

    // Display a default view. This will only last until the spots are first loaded, at which point the map will zoom
    // to the extent of ths spots.
    map.setView([30, 0], 3);
}

// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
    if (localStorage.getItem("map-intro-box-dismissed") == null) {
        $("#map-intro-box").show();
    }
    $("#map-intro-box-dismiss").click(function () {
        localStorage.setItem("map-intro-box-dismissed", true);
    });
}

// Startup
$(document).ready(function () {
    // Close SSE connection cleanly when navigating away
    window.addEventListener('beforeunload', function () {
        if (evtSource != null) {
            evtSource.close();
        }
    });

    // Hide the extra things that need to be hidden on this page
    $(".hideonmap").hide();
    // Set up map
    setUpMap();
    // Call loadOptions(), this will then trigger loading spots and setting up timers.
    loadOptions();
    // Display intro box
    displayIntroBox();
    // Prevent mouse scroll and touch actions in the popup menus being passed through to the map
    L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
    L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
});