// SSE connection for live spot updates
let evtSource;
let restartSSEOnErrorTimeoutId;
// Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user
// visible content
let updateBandsDebounceId;

// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
// can't just ask the elements themselves for their dimensions.
BAND_COLUMN_HEIGHT_EM = 62;
BAND_COLUMN_CANVAS_WIDTH_EM = 4;
BAND_COLUMN_FONT_SIZE = 16;
BAND_COLUMN_HEIGHT_PX = BAND_COLUMN_HEIGHT_EM * BAND_COLUMN_FONT_SIZE;
BAND_COLUMN_CANVAS_WIDTH_PX = BAND_COLUMN_CANVAS_WIDTH_EM * BAND_COLUMN_FONT_SIZE;
BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;

// Load spots and populate the bands display.
function loadSpots() {
    // Close any existing SSE connection before fetching fresh data
    if (evtSource != null) {
        evtSource.close();
    }
    $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
            // Store data
            spots = jsonData;
            // Update bands display
            updateBands();
            // Start the ongoing SSE connection
            startSSEConnection();
        }
    });
}

// Start an SSE connection to receive new spots as they arrive.
function startSSEConnection() {
    if (evtSource != null) {
        evtSource.close();
    }
    evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());

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

        // Replace any existing spot for this callsign
        spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]);
        spots.unshift(newSpot);

        // Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes
        clearTimeout(updateBandsDebounceId);
        updateBandsDebounceId = setTimeout(updateBands, 5000);
    };

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

// Remove spots from the display 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;
    const before = spots.length;
    spots = spots.filter(s => s["time"] && s["time"] >= cutoff);
    if (spots.length !== before) {
        updateBands();
    }
}

// Build a query string for the API, based on the filters that the user has selected. There's no need for credentials
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
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 bands view: No dupes, no QRT
    str = str + "&dedupe=true&allow_qrt=false";
    return str;
}

// Update the bands display
function updateBands() {
    // Stop here if nothing to display
    const bandsContainer = $("#bands-container");
    if (spots.length === 0) {
        bandsContainer.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
        return;
    }

    // Do some harsher de-duping. Because we only display callsign, frequency and mode here, the previous
    // de-duplication could have let some through that don't look like dupes on the map, but would do here.
    // Typically that's a person activating two programs at the same time, e.g. POTA & WWFF.
    const spotList = removeDuplicatesForBandPanel(spots);

    // Convert to a map of band names to the spots on that band. Bands with no
    // spots in view will not be present.
    const bandToSpots = new Map();
    options["bands"].forEach(function (band) {
        const matchingSpots = spotList.filter(function (s) {
            return s.band === band.name;
        });
        if (matchingSpots.length > 0) {
            bandToSpots.set(band.name, matchingSpots);
        }
    });

    // Track if any columns end up taller than expected, so we can resize the container and avoid vertical scroll.
    let maxHeightBand = 0;

    // Build up table content for each band
    const table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
    bandToSpots.forEach(function (spotList, bandName) {
        // Get the colours for the band from the first spot, and prepare the header
        table.find('thead tr').append(`<th style='background-color:${bandToColor(spotList[0].band)}; color:${bandToContrastColor(spotList[0].band)}'>${spotList[0].band}</th>`);

        // Get the band data to fetch start and end frequencies
        let band = options["bands"].filter(function (b) {
            return b.name === bandName;
        })[0];

        // Print the frequency band markers. This is 41 steps to divide the band evenly into 40 markers. One in every
        // four will show the actual frequency, the others will just be dashes.
        const bandMarkersDiv = $('<div class="band-markers">');
        const freqStep = (band.end_freq - band.start_freq) / 40.0;
        for (let i = 0; i <= 40; i++) {
            if (i % 4 === 0) {
                bandMarkersDiv.append("&mdash;" + ((band.start_freq + i * freqStep) / 1000000).toFixed(3) + "<br/>");
            } else if (i % 4 === 2) {
                bandMarkersDiv.append("&ndash;<br/>");
            } else {
                bandMarkersDiv.append("-<br/>");
            }
        }

        // Prepare the spots list
        const bandSpotsDiv = $("<div class='band-spots'>");
        let lastSpotPxDownBand = -999;
        // Sort by frequency so have a consistent order in which to plan where they will appear on the band div.
        spotList.sort(function (a, b) {
            return a.freq - b.freq;
        });
        // First calculate how we should be displaying the spots. There are three "modes" to try to place them in a
        // visually appealing way:
        // 1) Spaced normally, not going over the end of the band, so we populate them forwards.
        // 2) Would go over the end, but the spots don't fill the band, so we populate them backwards.
        // 3) Spots totally fill the band (or more), so we space them evenly starting at the top.
        // In each case, we don't add anything to the DOM yet, we just calculate "pxDownBandLabel" (how far the *top* of
        // the label is from the top of the div) and add that as a property to the spot for later use.
        if (spotList.length >= BAND_COLUMN_HEIGHT_PX / BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
            // Mode 3.
            // Just lay out all spots simply, starting at 0px offset and working down with each one touching.
            lastSpotPxDownBand = 0 - BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
            spotList.forEach(s => {
                lastSpotPxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
                s["pxDownBandLabel"] = lastSpotPxDownBand;
            });
        } else {
            // Mode 1 or 2. Run through adding things to the list forwards as a test.
            spotList.forEach(s => {
                // Work out how far down the div to draw it
                const percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
                let pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
                if (pxDownBand < lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
                    pxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
                }
                s["pxDownBandLabel"] = pxDownBand;
                lastSpotPxDownBand = pxDownBand;
            });

            // Work out if we overflowed the end.
            if (lastSpotPxDownBand <= BAND_COLUMN_HEIGHT_PX) {
                // Mode 1. Current positions are fine and there's nothing to do.
            } else {
                // Mode 2. Repeat the process but backwards, starting at the end and working upwards.
                lastSpotPxDownBand = 999999;
                spotList.reverse().forEach(s => {
                    // Work out how far down the div to draw it
                    const percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
                    let pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
                    if (pxDownBand > lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
                        pxDownBand = lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
                    }
                    s["pxDownBandLabel"] = pxDownBand;
                    lastSpotPxDownBand = pxDownBand;
                });
            }
        }

        // Now each spot is tagged with how far down the div it should go, add them to the DOM.
        spotList.forEach(s => {
            let worked = alreadyWorked(s["dx_call"], s["band"], s["mode"]);
            bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${bandToColor(s['band'])}; border-left: 5px solid ${bandToColor(s['band'])}; border-bottom: 1px solid ${bandToColor(s['band'])}; border-right: 1px solid ${bandToColor(s['band'])}; text-decoration: ${worked ? 'line-through' : 'none'};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq / 1000000).toFixed(3)} ${s.mode}</span></div>`);
        });

        // Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
        // spots have gone off the end of the band markers and stretched their div, we need to resize the canvas to
        // match, otherwise we have nowhere to draw their connecting lines.
        const canvasHeight = Math.max(BAND_COLUMN_HEIGHT_PX, lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX);
        maxHeightBand = Math.max(maxHeightBand, canvasHeight);

        // Draw horizontal or diagonal lines to join up the "real" frequency with where the spot div ended up
        const bandLinesCanvas = $(`<canvas class='band-lines-canvas' width='${BAND_COLUMN_CANVAS_WIDTH_PX}px' height='${canvasHeight}px' style='height:${canvasHeight}px !important;'>`);
        spotList.forEach(s => {
            // Work out how far down the div to draw it
            const percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
            const pxDownBandFreq = (percentDownBand + 0.015) * BAND_COLUMN_HEIGHT_PX; // same fudge but add half to put the left end of the line in the right place
            const pxDownBandLabel = s["pxDownBandLabel"] + (BAND_COLUMN_SPOT_DIV_HEIGHT_PX / 1.75); // line should be to the vertical text-centre spot, not to the top corner

            // Draw the line on the canvas
            const ctx = bandLinesCanvas[0].getContext('2d');
            ctx.beginPath();
            ctx.lineWidth = 2;
            ctx.lineCap = "round";
            ctx.strokeStyle = bandToColor(s['band']);
            ctx.moveTo(0, pxDownBandFreq);
            ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
            ctx.stroke();
        });

        // Assemble the table cell
        const td = $("<td>");
        const container = $("<div class='band-container'>");
        container.append(bandLinesCanvas);
        container.append(bandMarkersDiv);
        container.append(bandSpotsDiv);
        td.append(container);
        table.find('tbody tr').append(td);
    });

    // Update the DOM with the band HTML
    bandsContainer.html(table);

    // Increase the height of the bands container so we don't have any vertical scroll bars except the browser ones
    bandsContainer.css("min-height", `${maxHeightBand + 42}px`);

    // Desktop mouse wheel to scroll bands horizontally if used on the headers
    table.find('thead tr').on("wheel", () => {
        bandsContainer.scrollLeft(bandsContainer.scrollLeft() + event.deltaY / 10.0);
        return false;
    });
}

// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more
// spots with the activator, mode and frequency are found, these will be merged and reduced until only one remains,
// with the best data. Note that unlike removeDuplicates(), which operates on the main spot map, this operates only
// on the temporary array of spots provided as an argument, and returns the output, for use when constructing the
// band panel.
function removeDuplicatesForBandPanel(spotList) {
    const spotsToRemove = [];
    spotList.forEach(function (check) {
        spotList.forEach(function (s) {
            if (s !== check) {
                if (s.dx_call === check.dx_call && s.freq === check.freq && s.mode === check.mode) {
                    // Find which one to keep and which to delete
                    const checkSpotNewer = check.time > s.time;
                    const deleteSpot = checkSpotNewer ? s : check;
                    // Aggregate list of spots to remove
                    spotsToRemove.push(deleteSpot.uid);
                }
            }
        });
    });
    // Perform the removal
    return spotList.filter(s => !spotsToRemove.includes(s.uid));
}

// 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();

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

        // 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 bands display and persist settings.
function displayUpdated() {
    updateBands();
    saveSettings();
}

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

    // Call loadOptions(), this will then trigger loading spots and setting up timers.
    loadOptions();
});