// SSE event source
let evtSource;
let restartSSEOnErrorTimeoutId;
// Table row count, to alternate shading
let rowCount = 0;

// Set up a listener to close the SSE connection nicely when we navigate away from the page, to prevent console errors
// and keep things nice and tidy for the server.
window.addEventListener('beforeunload', function () {
    if (evtSource != null) {
        evtSource.close();
    }
});

// Load spots and populate the table.
function loadSpots() {
    // If we have an ongoing SSE connection, stop it so it doesn't interfere with our reload
    if (evtSource != null) {
        evtSource.close();
    }

    // Make the new query
    $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
        // Store data
        spots = jsonData;
        // Update table
        updateTable();
        // Start SSE connection to fetch updates in the background, if we are in "run" mode
        let run = $('#runButton:checked').val();
        if (run) {
            startSSEConnection();
        }
    }});
}

// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
// fly.
function startSSEConnection() {
    if (evtSource != null) {
        evtSource.close();
    }
    evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());

    evtSource.onmessage = function (event) {
        // Get the new spot
        const newSpot = JSON.parse(event.data);
        // Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look
        // like they belong further down the list. If the spot is older than the latest one we already have, bump its
        // time up to match it. This isn't great but since we poll spot providers every 2 minutes anyway, it shouldn't
        // be too far wrong.
        if (spots.length > 0) {
            newSpot["time"] = Math.max(newSpot["time"], Math.max(...spots.map(s => s["time"])))
        }

        // Add spot to internal data store
        spots.unshift(newSpot);
        // Work out if we need to remove an old spot
        if (spots.length > $("#spots_to_fetch option:selected").val()) {
            spots = spots.slice(0, -1);
            // Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows
            $("#table tbody tr").last().remove();
            $("#table tbody tr").last().remove();
        }
        // If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now
        // that we have one.
        if (spots.length === 1) {
            $("#table tbody tr").last().remove();
        }

        // Add the new spot to table
        addSpotToTopOfTable(newSpot, true);

        // Ping if we need to
        if ($("#pingOnNewSpots")[0].checked) {
            new Audio("/audio/ping.mp3").play();
        }
    };

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

// 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 + "limit=" + $("#spots_to_fetch option:selected").val();
    if ($("#search").val() !== "") {
        str = str + "&text_includes=" + encodeURIComponent($("#search").val());
    }
    return str;
}

// Update the spots table
function updateTable() {
    // Use local time instead of UTC?
    const useLocalTime = $("#timeZone")[0].value === "local";

    // Get user grid if valid, this will be null if it's not.
    const userPos = latLonForGridCentre($("#userGrid").val());

    // Table data toggles
    const showTime = $("#tableShowTime")[0].checked;
    const showDX = $("#tableShowDX")[0].checked;
    const showFreq = $("#tableShowFreq")[0].checked;
    const showMode = $("#tableShowMode")[0].checked;
    const showComment = $("#tableShowComment")[0].checked;
    const showBearing = $("#tableShowBearing")[0].checked && userPos != null;
    const showDistance = $("#tableShowDistance")[0].checked && userPos != null;
    const showType = $("#tableShowType")[0].checked;
    const showRef = $("#tableShowRef")[0].checked;
    const showDE = $("#tableShowDE")[0].checked;
    const showWorkedCheckbox = $("#tableShowWorkedCheckbox")[0].checked;

    // Populate table with headers
    let table = $("#table");
    table.find('thead tr').empty();
    if (showTime) {
        table.find('thead tr').append(`<th class="bg-primary-subtle">${useLocalTime ? "Local" : "UTC"}</th>`);
    }
    if (showDX) {
        table.find('thead tr').append(`<th class="bg-primary-subtle">DX</th>`);
    }
    if (showFreq) {
        table.find('thead tr').append(`<th class="bg-primary-subtle">Freq<span class='bg-primary-subtle hideonmobile'>uency</span></th>`);
    }
    if (showMode) {
        table.find('thead tr').append(`<th class="bg-primary-subtle">Mode</th>`);
    }
    if (showComment) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`);
    }
    if (showBearing) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Bearing</th>`);
    }
    if (showDistance) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Distance</th>`);
    }
    if (showType) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Type</th>`);
    }
    if (showRef) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
    }
    if (showDE) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>DE</th>`);
    }
    if (showWorkedCheckbox) {
        table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'></th>`);
    }

    table.find('tbody').empty();
    if (spots.length === 0) {
        table.find('tbody').append('<tr class="bg-danger-subtle"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
    }

    // We are regenerating the entire table not just adding a new row, so reset the row counter
    rowCount = 0;

    let spotsNewestFirst = spots.toReversed();
    spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false));
}

// Add rows corresponding to a new spot to the top of the table
// highlightNew = false for an initial load, true for new SSE-loaded spots
function addSpotToTopOfTable(s, highlightNew) {
    let rows = createNewTableRowsForSpot(s, highlightNew);
    $("#table").find('tbody').prepend(rows[1]);
    $("#table").find('tbody').prepend(rows[0]);
}

// Turn a spot into a set of table rows to represent it. This is actually two table rows because we need a second
// separate row for the mobile view.
// highlightNew = false for an initial load, true for new SSE-loaded spots
function createNewTableRowsForSpot(s, highlightNew) {
    // Use local time instead of UTC?
    const useLocalTime = $("#timeZone")[0].value === "local";

    // Get user grid if valid, this will be null if it's not.
    const userPos = latLonForGridCentre($("#userGrid").val());

    // Table data toggles
    const showTime = $("#tableShowTime")[0].checked;
    const showDX = $("#tableShowDX")[0].checked;
    const showFreq = $("#tableShowFreq")[0].checked;
    const showMode = $("#tableShowMode")[0].checked;
    const showComment = $("#tableShowComment")[0].checked;
    const showBearing = $("#tableShowBearing")[0].checked && userPos != null;
    const showDistance = $("#tableShowDistance")[0].checked && userPos != null;
    const showType = $("#tableShowType")[0].checked;
    const showRef = $("#tableShowRef")[0].checked;
    const showDE = $("#tableShowDE")[0].checked;
    const showWorkedCheckbox = $("#tableShowWorkedCheckbox")[0].checked;

    // Create row
    let $tr = $('<tr>');

    // Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
    // extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
    // which cause the table-striped colouring to go awry.
    if (rowCount % 2 === 1) {
        $tr.addClass("table-active");
    }

    // Show faded out if QRT or already worked
    let alreadyWorkedThis = alreadyWorked(s["dx_call"], s["band"], s["mode"]);
    if (s["qrt"] === true || alreadyWorkedThis) {
        $tr.addClass("table-faded");
    }

    // If we are asked to highlight new rows (i.e. this row is being added "live" via the SSE client and not as a bulk
    // reload of the whole table)
    if (highlightNew) {
        $tr.addClass("new");
    }

    // Format a UTC or local time for display
    const time = moment.unix(s["time"]).utc();
    if (useLocalTime) {
        time.local();
    }
    const time_formatted = time.format("HH:mm");

    // 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 country
    let dx_country = s["dx_country"];
    if (dx_country == null) {
        dx_country = "Unknown or not a country";
    }

    // Format DX flag
    let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
    if (dx_call == null) {
        dx_flag = "";
    }
    if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] !== 0) {
        dx_flag = `<img src="static/img/flags/${s['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
    }

    // 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 the mode
    let mode_string = s["mode"];
    if (s["mode"] == null) {
        mode_string = "";
    } else if (s["mode_source"] === "BANDPLAN") {
        mode_string = mode_string + "<span class='mode-q hideonmobile'><i class='fa-solid fa-circle-question' title='The mode was not reported via the spotting service. This is a guess based on the frequency.'></i></span>";
    }

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

    // Format bearing text
    let bearingText = "---<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service, and we could not determine one. A bearing to this DX is not available.'></i></span>";
    if (userPos != null && s["dx_latitude"] != null && s["dx_longitude"] != null) {
        const bearing = calcBearing(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]);
        bearingText = bearing.toFixed(0).padStart(3, '0') + "°";
        if (s["dx_location_good"] == null || s["dx_location_good"] === false) {
            if (s["dx_location_source"] === "HOME QTH") {
                bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to a QRZ \"home\" location for a portable/mobile/alternative spot, so this bearing may not be accurate if the DX is close to you.'></i></span>";
            } else {
                bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to just using the centre of a DXCC entity, so this bearing may not be accurate if the DX is close to you.'></i></span>";
            }
        }
    }

    // Format distance text
    let distanceText = "---<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service, and we could not determine one. A distance to this DX is not available.'></i></span>";
    if (userPos != null && s["dx_latitude"] != null && s["dx_longitude"] != null) {
        const distance = calcDistanceKm(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]);
        distanceText = distance.toFixed(0) + " km";
        if (s["dx_location_good"] == null || s["dx_location_good"] === false) {
            if (s["dx_location_source"] === "HOME QTH") {
                distanceText = distanceText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to a QRZ \"home\" location for a portable/mobile/alternative spot, so this distance may not be accurate if the DX is close to you.'></i></span>";
            } else {
                distanceText = distanceText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to just using the centre of a DXCC entity, so this distance may not be accurate if the DX is close to you.'></i></span>";
            }
        }
    }

    // Format "type" (Sig or fallback to source)
    let typeText = s["source"];
    if (s["sig"]) {
        typeText = 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] = `<span style="white-space: nowrap;"><a href='${encodeURI(s["sig_refs"][i]["url"])}' title='${escapeHtml(s["sig_refs"][i]["name"])}' target='_new' class='sig-ref-link'>${escapeHtml(s["sig_refs"][i]["id"])}</a></span>`
            } else {
                items[i] = `<span style="white-space: nowrap;">${escapeHtml(s["sig_refs"][i]["id"])}</span>`
            }
        }
        sig_refs = items.join(", ");
    }

    // Format de country
    let de_country = s["de_country"];
    if (de_country == null) {
        de_country = "Unknown or not a country";
    }

    // Format DE flag
    let de_flag = "<i class='fa-solid fa-circle-question'></i>";
    if (s["de_dxcc_id"] && s["de_dxcc_id"] != null && s["de_dxcc_id"] !== 0) {
        de_flag = `<img src="static/img/flags/${s['de_dxcc_id']}.png" class="flag" width="24" alt="${de_country}" title="${de_country}"/>`;
    }

    // Format de call
    let de_call = s["de_call"];
    if (de_call == null) {
        de_call = "";
        de_flag = "";
    }
    if (s["de_ssid"] != null) {
        de_call = de_call + "-" + s["de_ssid"];
    }

    // Format band name
    const bandFullName = s['band'] ? s['band'] + " band" : "Unknown band";

    // Format "worked" checkbox
    const workedCheckbox = `<input type="checkbox" ${alreadyWorkedThis ? "checked" : ""} onClick="setWorkedState('${s['dx_call']}', '${s['band']}', '${s['mode']}', ${alreadyWorkedThis ? "false" : "true"});" title="Check this box to record that you have worked this callsign on their current band and mode.">`;

    // Populate the row
    if (showTime) {
        $tr.append(`<td class='nowrap'>${time_formatted}</td>`);
    }
    if (showDX) {
        $tr.append(`<td class='nowrap'><span class='flag-wrapper' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
    }
    if (showFreq) {
        $tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + bandToColor(s["band"]) : "display: none;"}'>&#9632;</span>${freq_string}</td>`);
    }
    if (showMode) {
        $tr.append(`<td class='nowrap'>${mode_string}</td>`);
    }
    if (showComment) {
        $tr.append(`<td class='hideonmobile'>${commentText}</td>`);
    }
    if (showBearing) {
        $tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
    }
    if (showDistance) {
        $tr.append(`<td class='nowrap hideonmobile'>${distanceText}</td>`);
    }
    if (showType) {
        $tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
    }
    if (showRef) {
        $tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
    }
    if (showDE) {
        $tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
    }
    if (showWorkedCheckbox) {
        $tr.append(`<td class='nowrap hideonmobile'>${workedCheckbox}</td>`);
    }

    // Second row for mobile view only, containing type, ref & comment
    const $tr2 = $("<tr class='hidenotonmobile'>");

    // Apply styles as per the first row
    if (rowCount % 2 === 1) {
        $tr2.addClass("table-active");
    }
    if (s["qrt"] === true || alreadyWorkedThis) {
        $tr2.addClass("table-faded");
    }
    if (highlightNew) {
        $tr2.addClass("new");
    }

    const $td2 = $("<td colspan='100'>");
    const $td2floatleft = $(`<div style="float: left;">`);
    if (showType) {
        $td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText} `);
    }
    if (showRef) {
        $td2floatleft.append(`${sig_refs} `);
    }
    $td2.append($td2floatleft);
    const $td2floatright = $(`<div style="float: right;">`);
    if (showBearing) {
        $td2floatright.append(`${bearingText} &nbsp;`);
    }
    if (showDistance) {
        $td2floatright.append(`${distanceText} &nbsp;`);
    }
    if (showDE) {
        $td2floatright.append(` de ${de_call} &nbsp;`);
    }
    if (showWorkedCheckbox) {
        $td2floatright.append(` ${workedCheckbox} &nbsp;`);
    }
    $td2.append($td2floatright);
    $td2.append(`</div><div style="clear: both;"></div>`);
    if (showComment) {
        $td2.append(`${commentText}`);
    }
    $tr2.append($td2);

    rowCount++;

    return [$tr, $tr2];
}

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

        // Extra setting - the toggle for the "bearing" & "distance" columns are disabled if the user has not entered a
        // valid grid, and normally this logic is handled on user input to the grid field, but we might have just loaded
        // a value direct into the field, so apply the same logic here.
        $("#tableShowBearing").prop('disabled', !isUserGridValid());
        $("#tableShowDistance").prop('disabled', !isUserGridValid());
        if (!isUserGridValid()) {
            $("#tableShowBearing").prop('checked', false);
            $("#tableShowDistance").prop('checked', false);
        }

        // Load spots (this will also set up the SSE connection to update them too)
        loadSpots();
    });
}

// Work out if the user's entered grid is a valid Maidenhead grid
function isUserGridValid() {
    const userGrid = $("#userGrid").val().toUpperCase();
    return latLonForGridCentre(userGrid) != null;
}

// Method called when the user's grid input is changed.
function userGridUpdated() {
    const userGridValid = isUserGridValid();
    if (userGridValid) {
        updateTable();
    }
    // Enable/disable bearing column depending on grid validity
    $("#tableShowBearing").prop('disabled', !userGridValid);
    $("#tableShowDistance").prop('disabled', !userGridValid);
    if (!userGridValid) {
        $("#tableShowBearing").prop('checked', false);
        $("#tableShowDistance").prop('checked', false);
    }
    // Save settings even if not a valid grid, this allows the user to clear their grid and have it save.
    saveSettings();
}

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

// Mark a callsign-band-mode combination as worked (or unmark it). Persist this to localStorage.
function setWorkedState(callsign, band, mode, nowWorked) {
    let combo = callsign + "-" + band + "-" + mode;
    if (nowWorked && !worked.includes(combo)) {
        worked.push(combo);
        updateTable();
        localStorage.setItem("worked", JSON.stringify(worked));
    } else if (!nowWorked && worked.includes(combo)) {
        worked.splice(worked.indexOf(combo), 1);
        updateTable();
        localStorage.setItem("worked", JSON.stringify(worked));
    }
}

// Clear the list of worked calls
function clearWorked() {
    worked = [];
    updateTable();
    localStorage.setItem("worked", JSON.stringify(worked));
}

// Startup
$(document).ready(function () {
    // Call loadOptions(), this will then trigger loading spots and setting up timers.
    loadOptions();
    // Display intro box
    displayIntroBox();

    // Set up run/pause toggles
    $("#runButton").change(function () {
        // Need to start the SSE connection but also do a full re-query to catch up anything that we missed, so we
        // might as well just call loadSpots again which will trigger it all
        loadSpots();
    });
    $("#pauseButton").change(function () {
        // If we are pausing and have an open SSE connection, stop it
        if (evtSource != null) {
            evtSource.close();
        }
    });
});