// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js';
import { assert } from 'chrome://resources/js/assert.js';
import { $, getRequiredElement } from 'chrome://resources/js/util.js';
import { PageHandlerFactory } from './optimization_guide_internals.mojom-webui.js';
import { OptimizationGuideInternalsBrowserProxy } from './optimization_guide_internals_browser_proxy.js';
// Contains all the log events received when the internals page is open.
const logMessages = [];
/**
 * Class to apply filter functionality and updated related UI.
 */
class TableFilter {
    // Delay between input change to filter application, in ms.
    static FILTER_DELAY_MS = 500;
    // Main <table> element for filtering. First row is assumed to be the title,
    table;
    // The text <input> element to get "include" filter text.
    includeInput;
    // The text <input> element to get "exclude" filter text.
    excludeInput;
    // The <span> element to output filter stats.
    filterStatsSpan;
    // Assuming same number of cells in each row, list of indices of cells in each
    // row to examine for filtering. These correspond to <th> elements with
    // "filterable" in its classList.
    filterCellIndices;
    // Filter function for "include" filtering. Null if unspecified.
    includeFun;
    // Filter function for "exclude" filtering. Null if unspecified.
    excludeFun;
    // Id to setTimeout() for filter delay, so the timeout can be detected and
    // cancelled. Null if no timeout is live.
    filterDelayTimeoutId;
    // Total number of rows examined by the filter.
    numRows;
    // Total number of rows being show after filtering.
    numShown;
    /**
     * @param {!HTMLTableElement} table
     * @param {!HTMLInputElement} includeInput
     * @param {!HTMLInputElement!} excludeInput
     * @param {!HTMLSpanElement} filterStatsSpan
     */
    constructor(table, includeInput, excludeInput, filterStatsSpan) {
        this.table = table;
        this.includeInput = includeInput;
        this.excludeInput = excludeInput;
        this.filterStatsSpan = filterStatsSpan;
        this.filterCellIndices = [];
        this.readFilterCellIndices();
        this.includeFun = this.readFilter(this.includeInput);
        this.excludeFun = this.readFilter(this.excludeInput);
        this.filterDelayTimeoutId = null;
        this.numRows = 0;
        this.numShown = 0;
        this.includeInput.addEventListener('input', (e) => this.triggerUpdate(e));
        this.excludeInput.addEventListener('input', (e) => this.triggerUpdate(e));
    }
    readFilterCellIndices() {
        this.filterCellIndices.length = 0;
        const headers = this.table.rows[0].cells;
        for (const [idx, header] of Array.from(headers).entries()) {
            if (header.classList.contains('filterable')) {
                this.filterCellIndices.push(idx);
            }
        }
    }
    /**
     * Reads filter text and returns a filter predicate, or null if no filter.
     * @param {!HTMLInputElement} input
     */
    readFilter(input) {
        const t = input.value;
        return (t === '') ? null : ((s) => (s.indexOf(t) >= 0));
    }
    /**
     * Returns whether the provided <tr> element should be shown.
     * @param {!HTMLTableElement} row
     */
    shouldRowBeShown(row) {
        // Perform exclusion first since it has higher priority than inclusion.
        if (this.excludeFun != null) {
            for (const idx of this.filterCellIndices) {
                const text = row.cells[idx]?.textContent ?? '';
                if (this.excludeFun(text)) {
                    return false;
                }
            }
        }
        if (this.includeFun != null) {
            for (const idx of this.filterCellIndices) {
                const text = row.cells[idx]?.textContent ?? '';
                if (this.includeFun(text)) {
                    return true;
                }
            }
            return false;
        }
        return true;
    }
    /**
     * Shows or hides a <tr> element depending on its content.
     * @param {!HTMLTableRowElement} row
     * @return Whether the element is shown.
     */
    applyFilterToRow(row) {
        const shouldShow = this.shouldRowBeShown(row);
        row.classList.toggle('hidden', !shouldShow);
        return shouldShow;
    }
    /** Updates `filterStatsSpan` content to show filter stats. */
    writeFilterStats() {
        this.filterStatsSpan.textContent = `${this.numShown} / ${this.numRows}`;
    }
    /**
     * Visits every row (except the first, which is the titles) of `this.table`
     * and shows and hides it. Displays the number of hidden rows (as negative
     * value) in `filterStatsSpan`.
     */
    readAndApplyAllFilters() {
        this.readFilterCellIndices();
        this.includeFun = this.readFilter(this.includeInput);
        this.excludeFun = this.readFilter(this.excludeInput);
        this.numRows = 0;
        this.numShown = 0;
        let isTitle = true;
        for (const row of this.table.rows) {
            if (isTitle) {
                isTitle = false;
            }
            else {
                this.numShown += Number(this.applyFilterToRow(row));
                ++this.numRows;
            }
        }
        this.writeFilterStats();
    }
    /**
     * Applies the filter to a newly added row, and updates filter stats.
     * @param {!HTMLTableRowElement} row
     */
    filterNewRow(row) {
        this.numShown += Number(this.applyFilterToRow(row));
        ++this.numRows;
        this.writeFilterStats();
    }
    /**
     * [Re]triggers timer to call readAndApplyAllFilters() after waiting for a
     * delay that lasts `FILTER_DELAY_MS`. Debouncing is applied.
     * @param {!Event} e
     */
    triggerUpdate(e) {
        const elt = e.target;
        // Debounce: New trigger cancels an existing trigger's timeout.
        if (this.filterDelayTimeoutId != null) {
            clearTimeout(this.filterDelayTimeoutId);
            this.filterDelayTimeoutId = null;
        }
        elt.classList.add('input-dirty');
        this.filterDelayTimeoutId = setTimeout(() => {
            this.filterDelayTimeoutId = null;
            this.includeInput.classList.remove('input-dirty');
            this.excludeInput.classList.remove('input-dirty');
            this.readAndApplyAllFilters();
        }, TableFilter.FILTER_DELAY_MS);
    }
}
/**
 * Converts a mojo time to a JS time.
 * @param {!mojoBase.mojom.Time} mojoTime
 * @return {!Date}
 */
function convertMojoTimeToJS(mojoTime) {
    // The JS Date() is based off of the number of milliseconds since the
    // UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue| of the
    // base::Time (represented in mojom.Time) represents the number of
    // microseconds since the Windows FILETIME epoch (1601-01-01 00:00:00 UTC).
    // This computes the final JS time by computing the epoch delta and the
    // conversion from microseconds to milliseconds.
    const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
    const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
    // |epochDeltaInMs| equals to base::Time::kTimeTToMicrosecondsOffset.
    const epochDeltaInMs = unixEpoch - windowsEpoch;
    const timeInMs = Number(mojoTime.internalValue) / 1000;
    return new Date(timeInMs - epochDeltaInMs);
}
/**
 * Creates the chromium source URL from sourceLocation.
 * @param sourceFile
 * @param sourceLine
 * @param targetElement The element to which source link should be created.
 */
function createChromiumSourceLink(sourceFile, sourceLine, targetElement) {
    // Valid source file starts with ../../
    if (!sourceFile.startsWith('../../')) {
        targetElement.textContent = `${sourceFile}(${sourceLine})`;
        return;
    }
    const fileName = sourceFile.slice(sourceFile.lastIndexOf('/') + 1);
    if (fileName.length === 0) {
        targetElement.textContent = `${sourceFile}(${sourceLine})`;
        return;
    }
    const anchor = document.createElement('a');
    anchor.appendChild(document.createTextNode(`${fileName}(${sourceLine}`));
    anchor.href = `https://source.chromium.org/chromium/chromium/src/+/main:${sourceFile.slice(6)};l=${sourceLine}`;
    targetElement.appendChild(anchor);
}
/**
 * Maps the logSource to a human readable string representation.
 * Must be kept in sync with the |LogSource| enum in
 * //components/optimization_guide/core/optimization_guide_common.mojom.
 * @param logSource
 * @returns string
 */
function getLogSource(logSource) {
    if (logSource === 0) {
        return 'SERVICE_AND_SETTINGS';
    }
    if (logSource === 1) {
        return 'HINTS';
    }
    if (logSource === 2) {
        return 'MODEL_MANAGEMENT';
    }
    if (logSource === 3) {
        return 'PAGE_CONTENT_ANNOTATIONS';
    }
    if (logSource === 4) {
        return 'HINTS_NOTIFICATIONS';
    }
    if (logSource === 5) {
        return 'TEXT_CLASSIFIER';
    }
    if (logSource === 6) {
        return 'MODEL_EXECUTION';
    }
    if (logSource === 7) {
        return 'NTP_MODULE';
    }
    if (logSource === 8) {
        return 'CONTEXTUAL_TASKS_CONTEXT';
    }
    return logSource.toString();
}
/**
 * The callback to button#log-messages-dump to save the logs to a file.
 */
function onLogMessagesDump() {
    const data = JSON.stringify(logMessages);
    const blob = new Blob([data], { 'type': 'text/json' });
    const url = URL.createObjectURL(blob);
    const filename = 'optimization_guide_internals_logs_dump.json';
    const a = document.createElement('a');
    a.setAttribute('href', url);
    a.setAttribute('download', filename);
    const event = document.createEvent('MouseEvent');
    event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    a.dispatchEvent(event);
}
async function onModelsPageOpen() {
    const downloadedModelsContainer = getRequiredElement('downloaded-models-container');
    try {
        const response = await PageHandlerFactory.getRemote().requestDownloadedModelsInfo();
        const downloadedModelsInfo = response.downloadedModelsInfo;
        for (const { optimizationTarget, version, filePath } of downloadedModelsInfo) {
            const versionStr = version.toString();
            const existingModel = $(optimizationTarget);
            if (existingModel) {
                existingModel.querySelector('.downloaded-models-version').textContent =
                    versionStr;
                existingModel.querySelector('.downloaded-models-file-path').textContent =
                    filePath;
            }
            else {
                const downloadedModel = downloadedModelsContainer.insertRow();
                downloadedModel.id = optimizationTarget;
                appendTD(downloadedModel, optimizationTarget, 'downloaded-models-optimization-target');
                appendTD(downloadedModel, versionStr, 'downloaded-models-version');
                appendTD(downloadedModel, filePath, 'downloaded-models-file-path');
            }
        }
    }
    catch (err) {
        throw new Error(`Error resolving promise from requestDownloadedModelsInfo, ${err}`);
    }
}
async function onClientIDsPageOpen() {
    const loggedClientIdsContainer = getRequiredElement('logged-client-ids-container');
    try {
        const response = await PageHandlerFactory.getRemote()
            .requestLoggedModelQualityClientIds();
        const loggedClientIds = response.loggedClientIds;
        for (const { clientId } of loggedClientIds) {
            const clientIdStr = clientId.toString();
            const loggedClients = loggedClientIdsContainer.insertRow();
            appendTD(loggedClients, clientIdStr, 'logged-client-ids');
        }
    }
    catch (err) {
        throw new Error(`Error resolving promise from requestLoggedClientIds, ${err}`);
    }
}
async function onMqlsLogsPageOpen() {
    const mqlsLogsContainer = getRequiredElement('mqls-logs-container');
    try {
        const response = await PageHandlerFactory.getRemote().requestMqlsLogs();
        const mqlsLogs = response.mqlsLogs;
        for (const { feature, proto, status } of mqlsLogs) {
            const row = mqlsLogsContainer.insertRow();
            const featureStr = feature;
            const protoStr = proto;
            const statusStr = status;
            appendTD(row, featureStr, 'mqls-logs-feature');
            appendTD(row, protoStr, 'mqls-logs-proto');
            appendTD(row, statusStr, 'mqls-logs-status');
        }
    }
    catch (err) {
        throw new Error(`Error resolving promise from requestMqlsLogs, ${err}`);
    }
}
/**
 * Appends a new TD element to the specified |parent| element, and returns the
 * newly created element.
 *
 * @param {HTMLTableRowElement} parent The element to which a new TD element is
 *     appended.
 * @param {string} textContent The inner HTML of the element.
 * @param {string} className The class name of the element.
 */
function appendTD(parent, textContent, className) {
    const td = parent.insertCell();
    td.textContent = textContent;
    td.className = className;
    parent.appendChild(td);
    return td;
}
function getProxy() {
    return OptimizationGuideInternalsBrowserProxy.getInstance();
}
function initialize() {
    const tabbox = document.querySelector('cr-tab-box');
    assert(tabbox);
    tabbox.hidden = false;
    const logMessageContainer = getRequiredElement('log-message-container');
    const tabFilter = new TableFilter(logMessageContainer, getRequiredElement('log-message-include'), getRequiredElement('log-message-exclude'), getRequiredElement('log-message-filter-stats'));
    getRequiredElement('log-messages-dump')
        .addEventListener('click', onLogMessagesDump);
    getRequiredElement('mqls-logs-refresh')
        .addEventListener('click', onMqlsLogsPageOpen);
    getProxy().getCallbackRouter().onLogMessageAdded.addListener((eventTime, logSource, sourceFile, sourceLine, message) => {
        const eventTimeStr = convertMojoTimeToJS(eventTime).toISOString();
        const logSourceStr = getLogSource(logSource);
        logMessages.push({
            eventTime: eventTimeStr,
            logSource: logSourceStr,
            sourceLocation: `${sourceFile}:${sourceLine}`,
            message,
        });
        const logMessage = logMessageContainer.insertRow();
        logMessage.innerHTML =
            window.trustedTypes ? window.trustedTypes.emptyHTML : '';
        appendTD(logMessage, eventTimeStr, 'event-logs-time');
        appendTD(logMessage, logSourceStr, 'event-logs-log-source');
        createChromiumSourceLink(sourceFile, sourceLine, appendTD(logMessage, '', 'event-logs-source-location'));
        appendTD(logMessage, message, 'event-logs-message');
        tabFilter.filterNewRow(logMessage);
    });
    const tabpanelNodeList = document.querySelectorAll('div[slot=\'panel\']');
    const tabpanels = Array.prototype.slice.call(tabpanelNodeList, 0);
    const tabpanelIds = tabpanels.map(function (tab) {
        return tab.id;
    });
    tabbox.addEventListener('selected-index-change', e => {
        const tabpanel = tabpanels[e.detail];
        const hash = tabpanel.id.match(/(?:^tabpanel-)(.+)/)[1];
        window.location.hash = hash;
    });
    const activateTabByHash = function () {
        let hash = window.location.hash;
        // Remove the first character '#'.
        hash = hash.substring(1);
        const id = 'tabpanel-' + hash;
        const index = tabpanelIds.indexOf(id);
        if (index === -1) {
            return;
        }
        tabbox.setAttribute('selected-index', `${index}`);
        if (hash === 'models') {
            onModelsPageOpen();
        }
        else if (hash === 'client-ids') {
            onClientIDsPageOpen();
        }
        else if (hash === 'mqls-logs') {
            onMqlsLogsPageOpen();
        }
    };
    window.onhashchange = activateTabByHash;
    activateTabByHash();
}
document.addEventListener('DOMContentLoaded', initialize);
