// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// 
import { assert } from 'chrome://resources/js/assert.js';
import { sendWithPromise } from 'chrome://resources/js/cr.js';
import { getRequiredElement } from 'chrome://resources/js/util.js';
/**
 * Stores source ID and number of events shown. If there is a new source ID
 * or there are new events in UKM recorder, then all the events for
 * the new source ID will be displayed.
 */
const clearedSources = new Map();
/**
 * Cached sources to persist beyond the log cut. This will ensure that the data
 * on the page don't disappear if there is a log cut. The caching will
 * start when the page is loaded and when the data is refreshed.
 * Stored data is sourceid -> UkmSource with array of distinct entries.
 */
const cachedSources = new Map();
/**
 * Text for empty URL.
 */
const URL_EMPTY = '(missing)';
const EXPAND_ALL_BUTTON_TEXT = 'Expand All';
const COLLAPSE_ALL_BUTTON_TEXT = 'Collapse All';
/**
 * Converts a pair of JS 32 bin number to 64 bit hex string. This is used to
 * pass 64 bit numbers from UKM like client id and 64 bit metrics to
 * the javascript.
 * @param num A pair of javascript signed int.
 * @return unsigned int64 as hex number, or a decimal number if the
 *     value is smaller than 32bit.
 */
function as64Bit(num) {
    if (num.length !== 2) {
        return '0';
    }
    if (!num[0]) {
        return num[1].toString(); // Return the lsb as String.
    }
    else {
        const hi = (num[0] >>> 0).toString(16).padStart(8, '0');
        const lo = (num[1] >>> 0).toString(16).padStart(8, '0');
        return `0x${hi}${lo}`;
    }
}
/**
 * Sets the display option of all the elements in HtmlCollection to the value
 * passed.
 */
function setDisplayStyle(elements, displayValue) {
    for (const el of elements) {
        el.style.display = displayValue;
    }
}
/**
 * Removes all the child elements.
 * @param parent Parent element whose children will be removed.
 */
function removeChildren(parent) {
    while (parent.firstChild) {
        parent.removeChild(parent.firstChild);
    }
}
/**
 * Creates rows in the table body to represent Sources having the same URL
 * value.
 * @param sourcesForUrl Sources having the same URL.
 * @param sourcesTable The <tbody> element representing all Sources to which
 *     Source rows will be added.
 * @param displayStates Map from source ID to value of display property of the
 *     event-metrics tables.
 */
function createSourceRowsForTheSameUrl(sourcesForUrl, sourcesTable, displayStates) {
    if (!sourcesForUrl || sourcesForUrl.length === 0) {
        return;
    }
    for (const source of sourcesForUrl) {
        const sourceHtmlRow = document.createElement('tr');
        sourceHtmlRow.classList.add('source_container');
        sourcesTable.appendChild(sourceHtmlRow);
        // Add a row to contain the event-metrics tables.
        const eventsRow = document.createElement('tr');
        eventsRow.classList.add('events-row');
        const eventsCell = document.createElement('td');
        eventsCell.setAttribute('colspan', '3');
        eventsRow.appendChild(eventsCell);
        sourcesTable.appendChild(eventsRow);
        // Restore the display state from the map.
        const displayState = displayStates.get(as64Bit(source.id));
        // Apply the display state if any, based on whether the user has clicked
        // this Source row. Otherwise, apply the display state base on the
        // "Expand/Collapse All" button state.
        const expandedAll = getRequiredElement('toggle_expand').textContent ===
            COLLAPSE_ALL_BUTTON_TEXT;
        const isExpanded = displayState ? displayState === 'block' : expandedAll;
        populateSourceHtmlRow(source, sourceHtmlRow, eventsCell, isExpanded);
        const displayValue = isExpanded ? 'block' : 'none';
        createEventMetricTablesForSource(source, eventsCell, displayValue);
    }
    // Add a thin horizontal line at the bottom, which visually separates this
    // group of Sources with the same URL value from the next group.
    sourcesTable.lastElementChild.classList.add('thin-border-bottom');
}
/**
 * Populates a table row with the given Source data.
 * @param sourceData data pertaining to one Source.
 * @param sourceHtmlRow The HTML element whose content will be populated.
 * @param eventsCell The HTML element to which event-metric tables are appended.
 * @param isExpanded Whether the row is initially expanded.
 */
function populateSourceHtmlRow(sourceData, sourceHtmlRow, eventsCell, isExpanded) {
    const urlElement = document.createElement('td');
    urlElement.classList.add('url');
    let urlText = sourceData.url || URL_EMPTY;
    if (sourceData.redirects && sourceData.redirects.length > 1) {
        urlText += ' (from redirect)';
    }
    urlElement.innerText = urlText;
    const idElement = document.createElement('td');
    idElement.classList.add('sourceid');
    idElement.innerText = as64Bit(sourceData.id);
    const typeElement = document.createElement('td');
    typeElement.classList.add('sourcetype');
    typeElement.innerText = sourceData.type;
    if (isExpanded) {
        sourceHtmlRow.classList.add('expanded');
    }
    sourceHtmlRow.appendChild(urlElement);
    sourceHtmlRow.appendChild(idElement);
    sourceHtmlRow.appendChild(typeElement);
    const toggleHandler = () => {
        // The eventsTables child is appended later in
        // createEventMetricTablesForSource().
        const eventsTables = eventsCell.lastChild;
        if (eventsTables.style.display === 'block') {
            eventsTables.style.display = 'none';
            sourceHtmlRow.classList.remove('expanded');
        }
        else {
            eventsTables.style.display = 'block';
            sourceHtmlRow.classList.add('expanded');
        }
    };
    // Clicking on the row toggles the display state of its event-metrics tables.
    sourceHtmlRow.addEventListener('click', toggleHandler);
}
/**
 * Adds event-metrics tables of a Source. Clicking on the Source row
 * toggles their display on or off.
 * @param sourceData Data for one Source.
 * @param eventsCell The HTML element to which the event-metrics tables will be
 *     added.
 * @param displayState Display style of the event-metrics table for this Source.
 */
function createEventMetricTablesForSource(sourceData, eventsCell, displayState) {
    const eventMetricsElement = document.createElement('div');
    eventMetricsElement.classList.add('events');
    eventsCell.appendChild(eventMetricsElement);
    // Apply the display state
    eventMetricsElement.style.display = displayState;
    if (sourceData.webdx_features && sourceData.webdx_features.length > 0) {
        const featuresDiv = document.createElement('div');
        featuresDiv.classList.add('webdx-features-list');
        const label = document.createElement('b');
        label.textContent = 'WebDX Features: ';
        featuresDiv.appendChild(label);
        featuresDiv.appendChild(document.createTextNode(sourceData.webdx_features.sort().join(', ')));
        eventMetricsElement.appendChild(featuresDiv);
    }
    const redirects = sourceData.redirects;
    if (redirects) {
        const redirectsInfoDiv = document.createElement('div');
        redirectsInfoDiv.style.paddingLeft = '10px';
        const redirectsInfoLabel = document.createElement('b');
        redirectsInfoLabel.textContent = 'Full redirect chain:';
        redirectsInfoDiv.appendChild(redirectsInfoLabel);
        redirects.forEach((url, index) => {
            const redirectUrlDiv = document.createElement('div');
            const isFinalUrl = index === redirects.length - 1;
            redirectUrlDiv.textContent = url + (isFinalUrl ? '' : '  ==>');
            redirectUrlDiv.style.paddingLeft = '10px';
            redirectUrlDiv.style.paddingBottom = isFinalUrl ? '10px' : '0px';
            redirectsInfoDiv.appendChild(redirectUrlDiv);
        });
        eventMetricsElement.appendChild(redirectsInfoDiv);
    }
    if (sourceData.events.length === 0) {
        const noEventsDiv = document.createElement('div');
        noEventsDiv.textContent = '(no events)';
        eventMetricsElement.appendChild(noEventsDiv);
        return;
    }
    const sortedEvents = sourceData.events.sort((e1, e2) => e1.name.localeCompare(e2.name));
    for (const event of sortedEvents) {
        createEventMetricsTable(event, eventMetricsElement);
    }
}
/**
 * Creates a table representing metrics associated to one UKM Event.
 * @param event A UKM Event.
 * @param eventsElement The HTML element to which the event-metrics table will
 *     be appended.
 */
function createEventMetricsTable(event, eventsElement) {
    // Add first column to the table.
    const eventTable = document.createElement('table');
    eventTable.classList.add('event-table');
    eventTable.setAttribute('value', event.name);
    eventsElement.appendChild(eventTable);
    const firstRow = document.createElement('tr');
    eventTable.appendChild(firstRow);
    const eventName = document.createElement('td');
    eventName.classList.add('event-name');
    eventName.setAttribute('rowspan', '0');
    eventName.textContent = event.name;
    firstRow.appendChild(eventName);
    // Sort the metrics by name, descending.
    const sortedMetrics = event.metrics.sort((m1, m2) => m1.name.localeCompare(m2.name));
    // Add metrics rows.
    for (const metric of sortedMetrics) {
        const nextRow = document.createElement('tr');
        const metricName = document.createElement('td');
        metricName.classList.add('metric-name');
        metricName.textContent = metric.name;
        nextRow.appendChild(metricName);
        const metricValue = document.createElement('td');
        metricValue.classList.add('metric-value');
        metricValue.textContent = as64Bit(metric.value);
        nextRow.appendChild(metricValue);
        eventTable.appendChild(nextRow);
    }
}
/**
 * Collect all sources for a particular URL together. It will also sort the
 * URLs alphabetically.
 * If the URL field is missing, the source ID will be used as the
 * URL for the purpose of grouping and sorting.
 * @param sources List of UKM data for a source .
 * @return Mapping in the sorted order of URL from URL to list of sources for
 *     the URL.
 */
function urlToSourcesMapping(sources) {
    const unsorted = new Map();
    for (const source of sources) {
        const key = source.url ? source.url : as64Bit(source.id);
        if (!unsorted.has(key)) {
            unsorted.set(key, [source]);
        }
        else {
            unsorted.get(key).push(source);
        }
    }
    // Sort the map by URLs.
    return new Map(Array.from(unsorted).sort((s1, s2) => s1[0].localeCompare(s2[0])));
}
/**
 * Updates the button text for expanding or collapsing all Source rows.
 */
function addExpandAllToggleButton() {
    const toggleExpand = getRequiredElement('toggle_expand');
    toggleExpand.textContent = EXPAND_ALL_BUTTON_TEXT;
    toggleExpand.addEventListener('click', () => {
        const containers = document.body.querySelectorAll('.source_container');
        const events = document.body.querySelectorAll('.events');
        if (toggleExpand.textContent === EXPAND_ALL_BUTTON_TEXT) {
            toggleExpand.textContent = COLLAPSE_ALL_BUTTON_TEXT;
            setDisplayStyle(events, 'block');
            for (const el of containers) {
                el.classList.add('expanded');
            }
        }
        else {
            toggleExpand.textContent = EXPAND_ALL_BUTTON_TEXT;
            setDisplayStyle(events, 'none');
            for (const el of containers) {
                el.classList.remove('expanded');
            }
        }
    });
}
/**
 * Updates the button to clear all the existing URLs. Note that the hiding is
 * done in the UI only. So refreshing the page will show all the UKM again.
 * To get the new UKMs after hitting Clear click the refresh button.
 */
function addClearButton() {
    const clearButton = getRequiredElement('clear');
    clearButton.addEventListener('click', () => {
        // Note it won't be able to clear if UKM logs got cut during this call.
        sendWithPromise('requestUkmData').then((data) => {
            updateUkmCache(data);
            for (const source of cachedSources.values()) {
                clearedSources.set(as64Bit(source.id), source.events.length);
            }
        });
        getRequiredElement('toggle_expand').textContent = EXPAND_ALL_BUTTON_TEXT;
        updateUkmData();
    });
}
/**
 * Populate thread ids from the high bit of Source ID in |sources|.
 * @param sources Array of Sources.
 */
function populateThreadIds(sources) {
    const threadIdSelect = document.body.querySelector('#thread_ids');
    assert(threadIdSelect);
    const currentOptions = new Set(Array.from(threadIdSelect.options).map(o => o.value));
    // The first 32 bit of the ID is the recorder ID, convert it to a positive
    // bit patterns and then to hex. Ids that were not seen earlier will get
    // added to the end of the option list.
    const newIds = new Set(sources.map(e => (e.id[0] >>> 0).toString(16)));
    const options = ['All', ...Array.from(newIds).sort()];
    for (const id of options) {
        if (!currentOptions.has(id)) {
            const option = document.createElement('option');
            option.textContent = id;
            option.setAttribute('value', id);
            threadIdSelect.add(option);
        }
    }
}
/**
 * Get the string representation of a UKM event. The array of metrics are sorted
 * by name to ensure that two events containing the same metrics and values in
 * different orders have identical string representation to avoid cache
 * duplication.
 * @param event UKM event to be stringified.
 * @return Normalized string representation of the event.
 */
function normalizeToString(event) {
    event.metrics.sort((m1, m2) => m1.name.localeCompare(m2.name));
    return JSON.stringify(event);
}
/**
 * This function tries to preserve UKM logs around UKM log uploads. There is
 * no way of knowing if duplicate events for a log are actually produced
 * again after the log cut or if they older records since we don't maintain
 * timestamp with events. So only distinct events will be recorded in the
 * cache. i.e. if two events have exactly the same set of metrics then one
 * of the event will not be kept in the cache.
 * @param data New UKM data to add to cache.
 */
function updateUkmCache(data) {
    for (const source of data.sources) {
        const key = as64Bit(source.id);
        if (!cachedSources.has(key)) {
            const mergedSource = {
                id: source.id,
                type: source.type,
                events: source.events,
                webdx_features: source.webdx_features || [],
                url: source.url,
                redirects: source.redirects,
            };
            cachedSources.set(key, mergedSource);
        }
        else {
            const cached = cachedSources.get(key);
            if (source.redirects && !cached.redirects) {
                cached.redirects = source.redirects;
            }
            // Merge distinct events from the source.
            const existingEvents = new Set(cached.events.map(event => normalizeToString(event)));
            for (const event of source.events) {
                if (!existingEvents.has(normalizeToString(event))) {
                    cachedSources.get(key).events.push(event);
                }
            }
            // Merge distinct webdx features.
            const existingFeatures = new Set(cachedSources.get(key).webdx_features);
            for (const feature of source.webdx_features || []) {
                if (!existingFeatures.has(feature)) {
                    cachedSources.get(key).webdx_features.push(feature);
                }
            }
        }
    }
}
/**
 * Fetches data from the UKM service and updates the DOM to display it as a
 * table.
 */
function updateUkmData() {
    sendWithPromise('requestUkmData').then((data) => {
        updateUkmCache(data);
        if (document.body.querySelector('#include_cache').checked) {
            data.sources = [...cachedSources.values()];
        }
        getRequiredElement('state').innerText = data.state ? 'ENABLED' : 'DISABLED';
        getRequiredElement('msbb_state').innerText =
            data.msbb_state ? 'ENABLED' : 'DISABLED';
        getRequiredElement('extension_state').innerText =
            data.extension_state ? 'ENABLED' : 'DISABLED';
        getRequiredElement('app_state').innerText =
            data.app_state ? 'ENABLED' : 'DISABLED';
        getRequiredElement('clientid').innerText = '0x' + data.client_id;
        getRequiredElement('sessionid').innerText = data.session_id;
        getRequiredElement('is_sampling_enabled').innerText =
            data.is_sampling_enabled.toString();
        const sourcesTable = getRequiredElement('sources');
        removeChildren(sourcesTable);
        // Setup the Source table header.
        const tableHead = document.createElement('thead');
        const headerRow = document.createElement('tr');
        const urlTitleElement = document.createElement('td');
        urlTitleElement.classList.add('url');
        urlTitleElement.textContent = 'URL';
        const sourceIdTitleElement = document.createElement('td');
        sourceIdTitleElement.classList.add('sourceid');
        sourceIdTitleElement.textContent = 'Source ID';
        const sourceTypeTitleElement = document.createElement('td');
        sourceTypeTitleElement.classList.add('sourcetype');
        sourceTypeTitleElement.textContent = 'Source Type';
        headerRow.appendChild(urlTitleElement);
        headerRow.appendChild(sourceIdTitleElement);
        headerRow.appendChild(sourceTypeTitleElement);
        tableHead.appendChild(headerRow);
        sourcesTable.appendChild(tableHead);
        const tableBody = document.createElement('tbody');
        tableBody.classList.add('url_card');
        sourcesTable.appendChild(tableBody);
        // Setup the display state map, which captures the current display settings,
        // for example, expanded state.
        const currentDisplayStates = new Map();
        for (const el of document.getElementsByClassName('source_container')) {
            const sourceId = el.querySelector('.sourceid').textContent;
            // .events is inside the next sibling (.events-row)
            const eventsRow = el.nextElementSibling;
            const eventsElement = eventsRow.querySelector('.events');
            currentDisplayStates.set(sourceId, eventsElement.style.display);
        }
        const urlToSources = urlToSourcesMapping(filterSourcesUsingFormOptions(data.sources));
        for (const url of urlToSources.keys()) {
            const sourcesForUrl = urlToSources.get(url);
            createSourceRowsForTheSameUrl(sourcesForUrl, tableBody, currentDisplayStates);
        }
        populateThreadIds(data.sources);
    });
}
/**
 * Filters sources that have been recorded previously. If it sees a source ID
 * where number of events has decreased then it will add a warning.
 * @param sources All the sources currently in the UKM recorder.
 * @return Sources which are new or have a new event logged for them.
 */
function filterSourcesUsingFormOptions(sources) {
    // Filter sources based on if they have been cleared.
    const newSources = sources.filter(source => (
    // Keep a Source if it is newly created since clearing earlier.
    !clearedSources.has(as64Bit(source.id)) ||
        // Keep a Source if it contains more events since clearing earlier.
        (source.events.length > clearedSources.get(as64Bit(source.id)))));
    // Apply the event name filtering.
    const newSourcesWithEventsCleared = newSources.map(source => {
        const eventNameFilterValue = document.body.querySelector('#events_select').value;
        if (eventNameFilterValue) {
            const filterRe = new RegExp(eventNameFilterValue);
            source.events = source.events.filter(event => filterRe.test(event.name));
        }
        return source;
    });
    // Filter sources based on the status of check-boxes.
    const filteredSources = newSourcesWithEventsCleared.filter(source => {
        const noUrlCheckbox = document.body.querySelector('#hide_no_url');
        assert(noUrlCheckbox);
        const noMetricsCheckbox = document.body.querySelector('#hide_no_events');
        assert(noMetricsCheckbox);
        return (!noUrlCheckbox.checked || source.url) &&
            (!noMetricsCheckbox.checked || source.events.length);
    });
    const threadIds = document.body.querySelector('#thread_ids');
    assert(threadIds);
    // Filter sources based on thread id (High bits of UKM Recorder ID).
    const threadsFilteredSource = filteredSources.filter(source => {
        // Get current selection for thread id. It is either -
        // "All" for no restriction.
        // "0" for the default thread. This is the thread that record f.e PageLoad
        // <lowercase hex string for first 32 bit of source id> for other threads.
        //     If a UKM is recorded with a custom source id or in renderer, it will
        //     have a unique value for this shared by all metrics that use the
        //     same thread.
        const selectedOption = threadIds.options[threadIds.selectedIndex];
        // Return true if either of the following is true -
        // No option is selected or selected option is "All" or the hexadecimal
        // representation of source id is matching.
        return !selectedOption || (selectedOption.value === 'All') ||
            ((source.id[0] >>> 0).toString(16) === selectedOption.value);
    });
    // Filter URLs based on URL selector input.
    const urlSelect = document.body.querySelector('#url_select');
    assert(urlSelect);
    return threadsFilteredSource.filter(source => {
        const urlFilterValue = urlSelect.value;
        if (urlFilterValue) {
            const urlRe = new RegExp(urlFilterValue);
            // Will also match missing URLs by default.
            return !source.url || urlRe.test(source.url);
        }
        return true;
    });
}
/**
 * DomContentLoaded handler.
 */
function onLoad() {
    addExpandAllToggleButton();
    addClearButton();
    updateUkmData();
    getRequiredElement('refresh').addEventListener('click', updateUkmData);
    getRequiredElement('hide_no_events').addEventListener('click', updateUkmData);
    getRequiredElement('hide_no_url').addEventListener('click', updateUkmData);
    getRequiredElement('thread_ids').addEventListener('click', updateUkmData);
    getRequiredElement('include_cache').addEventListener('click', updateUkmData);
    getRequiredElement('events_select').addEventListener('keyup', e => {
        if (e.key === 'Enter') {
            updateUkmData();
        }
    });
    getRequiredElement('url_select').addEventListener('keyup', e => {
        if (e.key === 'Enter') {
            updateUkmData();
        }
    });
}
document.addEventListener('DOMContentLoaded', onLoad);
setInterval(updateUkmData, 2 * 60 * 1000); // Refresh every 2 minutes.
