// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import { EventTracker } from '//resources/js/event_tracker.js';
import { loadTimeData } from '//resources/js/load_time_data.js';
// 
import { getInstance as getAnnouncerInstance } from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import { ZoomAction } from './glic.mojom-webui.js';
import { DetailedWebClientState, GlicApiCommunicator, GlicApiHost, WebClientState } from './glic_api_impl/host/glic_api_host.js';
import { ObservableValue } from './observable.js';
import { GlicRequestHeaderInjector } from './shared/glic_request_headers.js';
import { isFullWebView } from './shared/web_view_type.js';
import { OneShotTimer } from './timer.js';
// LINT.IfChange(WebviewExitReason)
var WebviewExitReason;
(function (WebviewExitReason) {
    WebviewExitReason[WebviewExitReason["NORMAL"] = 0] = "NORMAL";
    WebviewExitReason[WebviewExitReason["ABNORMAL"] = 1] = "ABNORMAL";
    WebviewExitReason[WebviewExitReason["CRASHED"] = 2] = "CRASHED";
    WebviewExitReason[WebviewExitReason["KILLED"] = 3] = "KILLED";
    WebviewExitReason[WebviewExitReason["OOM_KILLED"] = 4] = "OOM_KILLED";
    WebviewExitReason[WebviewExitReason["OOM"] = 5] = "OOM";
    WebviewExitReason[WebviewExitReason["FAILED_TO_LAUNCH"] = 6] = "FAILED_TO_LAUNCH";
    WebviewExitReason[WebviewExitReason["INTEGRITY_FAILURE"] = 7] = "INTEGRITY_FAILURE";
    WebviewExitReason[WebviewExitReason["UNKNOWN"] = 8] = "UNKNOWN";
})(WebviewExitReason || (WebviewExitReason = {}));
// LINT.ThenChange(//tools/metrics/histograms/metadata/glic/enums.xml:GlicWebviewExitReason)
const WEBVIEW_EXIT_REASON_MAP = {
    'normal': WebviewExitReason.NORMAL,
    'abnormal': WebviewExitReason.ABNORMAL,
    'crashed': WebviewExitReason.CRASHED,
    'killed': WebviewExitReason.KILLED,
    'oom killed': WebviewExitReason.OOM_KILLED,
    'oom': WebviewExitReason.OOM,
    'failed to launch': WebviewExitReason.FAILED_TO_LAUNCH,
    'integrity failure': WebviewExitReason.INTEGRITY_FAILURE,
};
function webviewExitReasonStringToEnum(reason) {
    return WEBVIEW_EXIT_REASON_MAP[reason] ?? WebviewExitReason.UNKNOWN;
}
// To match needed pieces of tools/typescript/definitions/web_request.d.ts,
// because this enum isn't actually available in this context.
var ResourceType;
(function (ResourceType) {
    ResourceType["MAIN_FRAME"] = "main_frame";
})(ResourceType || (ResourceType = {}));
// State for the WebviewController which lives as long as the WebUI content.
// This is necessary because we may destroy and rebuild the WebviewController
// multiple times.
export class WebviewPersistentState {
    // Normally, we load only the glicGuestURL. However, if that guest decides to
    // navigate to a different URL after the client connects, we will remember
    // that URL for loading later. To avoid getting stuck on a bad URL, we will
    // allow using `loadUrl` only once unless a client successfully connects.
    // Note that this supports internal development.
    loadUrl;
    loadUrlUsed = false;
    useLoadUrl() {
        if (this.loadUrl && !this.loadUrlUsed) {
            this.loadUrlUsed = true;
            return this.loadUrl;
        }
        else {
            return loadTimeData.getString('glicGuestURL');
        }
    }
    onCommitAfterConnect(newUrl) {
        this.loadUrl = newUrl;
        this.loadUrlUsed = false;
    }
    onClientReady() {
        // Web client became ready, allow loadUrl to be used again.
        this.loadUrlUsed = false;
    }
}
// Creates and manages the <webview> element, and the GlicApiHost which
// communicates with it.
export class WebviewController {
    container;
    browserProxy;
    delegate;
    hostEmbedder;
    persistentState;
    webview;
    host;
    dormant = false;
    communicator;
    hostSubscriber;
    onDestroy = [];
    eventTracker = new EventTracker();
    hasPendingCrossDocumentNavigation = false;
    webClientState = ObservableValue.withValue(WebClientState.UNINITIALIZED);
    oneMinuteTimer = new OneShotTimer(1000 * 60);
    glicRequestHeaderInjector;
    constructor(container, browserProxy, delegate, hostEmbedder, persistentState) {
        this.container = container;
        this.browserProxy = browserProxy;
        this.delegate = delegate;
        this.hostEmbedder = hostEmbedder;
        this.persistentState = persistentState;
        this.webview = document.createElement('webview');
        this.glicRequestHeaderInjector = new GlicRequestHeaderInjector(this.webview, loadTimeData.getString('chromeVersion'), loadTimeData.getString('chromeChannel'), loadTimeData.getString('glicHeaderRequestTypes'));
        if (isFullWebView(this.webview)) {
            // Intercept all main frame requests, and block them if they are not
            // allowed origins.
            const onBeforeRequest = this.onBeforeRequest.bind(this);
            this.webview.request.onBeforeRequest.addListener(onBeforeRequest, {
                types: [ResourceType.MAIN_FRAME],
                urls: ['<all_urls>'],
            }, ['blocking']);
            this.onDestroy.push(() => {
                // Need to check the type again as this function runs in a different
                // scope.
                if (isFullWebView(this.webview)) {
                    this.webview.request.onBeforeRequest.removeListener(onBeforeRequest);
                }
            });
        }
        else {
            // 
        }
        this.webview.id = 'guestFrame';
        this.webview.setAttribute('partition', 'persist:glicpart');
        this.container.appendChild(this.webview);
        this.eventTracker.add(this.webview, 'loadcommit', this.onLoadCommit.bind(this));
        this.eventTracker.add(this.webview, 'contentload', this.contentLoaded.bind(this));
        this.eventTracker.add(this.webview, 'loadstop', this.onLoadStop.bind(this));
        this.eventTracker.add(this.webview, 'newwindow', this.onNewWindow.bind(this));
        this.eventTracker.add(this.webview, 'permissionrequest', this.onPermissionRequest.bind(this));
        this.eventTracker.add(this.webview, 'unresponsive', this.onUnresponsive.bind(this));
        this.eventTracker.add(this.webview, 'exit', this.onExit.bind(this));
        // 
        if (isFullWebView(this.webview)) {
            this.eventTracker.add(this.webview, 'zoomchange', (e) => {
                const percentage = Math.round(e.newZoomFactor * 100);
                const message = loadTimeData.getStringF('zoomLabel', percentage + '%');
                getAnnouncerInstance().announce(message);
                this.browserProxy.pageHandler.onZoomLevelChange(e.newZoomFactor);
                this.host?.onZoomLevelChanged(e.newZoomFactor);
            });
        }
        // 
        this.eventTracker.add(this.webview, 'loadstart', this.onLoadStart.bind(this));
        this.eventTracker.add(this.webview, 'loadabort', this.onLoadAbort.bind(this));
        this.webview.src = this.persistentState.useLoadUrl();
        this.oneMinuteTimer.start(() => {
            if (this.host) {
                chrome.histograms.recordEnumerationValue('Glic.Host.WebClientState.AtOneMinute', this.host.getDetailedWebClientState(), DetailedWebClientState.MAX_VALUE + 1);
            }
        });
    }
    getWebClientState() {
        return this.webClientState;
    }
    focus() {
        this.webview.focus();
    }
    destroy() {
        if (this.glicRequestHeaderInjector !== undefined) {
            this.glicRequestHeaderInjector.destroy();
            this.glicRequestHeaderInjector = undefined;
        }
        this.oneMinuteTimer.reset();
        this.reportOnDestroy();
        this.destroyHost(this.webClientState.getCurrentValue() === WebClientState.ERROR ?
            WebClientState.ERROR :
            WebClientState.UNINITIALIZED);
        this.eventTracker.removeAll();
        this.onDestroy.forEach(f => f());
        this.onDestroy = [];
        this.webview.remove();
    }
    // Destroys the host and prevents the host from being recreated. This results
    // in a webview which effectively cannot communicate with Chrome. Useful for
    // debugging.
    setDormant() {
        if (this.dormant) {
            return;
        }
        this.dormant = true;
        this.reportOnDestroy();
        this.destroyHost();
    }
    reportOnDestroy() {
        if (this.host) {
            chrome.histograms.recordEnumerationValue('Glic.Host.WebClientState.OnDestroy', this.host.getDetailedWebClientState(), DetailedWebClientState.MAX_VALUE + 1);
        }
    }
    destroyHost(webClientState) {
        if (this.hostSubscriber) {
            this.hostSubscriber.unsubscribe();
            this.hostSubscriber = undefined;
        }
        if (this.host) {
            this.host.destroy();
            this.host = undefined;
        }
        if (this.communicator) {
            this.communicator.destroy();
            this.communicator = undefined;
        }
        if (webClientState !== undefined) {
            this.webClientState.assignAndSignal(webClientState);
        }
    }
    zoom(zoomAction) {
        // `WebViewType` is a union of `chrome.webviewTag.WebView` and
        // `SlimWebviewElement`. Only full webviews support zoom.
        // TODO(crbug.com/500052160): Support zoom for slim webviews.
        if (!isFullWebView(this.webview)) {
            return;
        }
        const webview = this.webview;
        if (zoomAction === ZoomAction.kReset) {
            webview.setZoom(1.0);
            return;
        }
        // Any changes to the range of supported zoom factors must be mirrored in
        // GlicPageHandler.OnZoomLevelChange.
        const zoomFactors = [
            1.0,
            1.1,
            1.25,
            1.5,
            1.75,
            2.0,
        ];
        webview.getZoom((currentZoom) => {
            // Find the closest standard zoom level to move to given the current zoom
            // level and zoom action.
            const newFactor = zoomAction === ZoomAction.kZoomIn ?
                zoomFactors.find(f => f - currentZoom >= 0.01) :
                zoomFactors.findLast(f => currentZoom - f >= 0.01);
            if (newFactor !== undefined) {
                webview.setZoom(newFactor);
            }
        });
    }
    waitingOnPanelWillOpen() {
        return this.host?.waitingOnPanelWillOpen() ?? false;
    }
    onLoadTimeOut() {
        if (this.host) {
            chrome.histograms.recordEnumerationValue('Glic.Host.WebClientState.OnLoadTimeOut', this.host.getDetailedWebClientState(), DetailedWebClientState.MAX_VALUE + 1);
        }
    }
    onLoadStart(e) {
        // This event is only called for document navigations, not for fragment
        // navigations.
        if (e.isTopLevel) {
            this.hasPendingCrossDocumentNavigation = true;
        }
    }
    onLoadAbort(e) {
        if (e.isTopLevel) {
            this.hasPendingCrossDocumentNavigation = false;
        }
    }
    onLoadCommit(e) {
        this.loadCommit(e.url, e.isTopLevel);
    }
    onLoadStop() {
        // Focus the webview only if it is visible. When it is not visible, the
        // focus will fail to focus the client page (document.hasFocus() is false).
        // GlicAppController.showPanel() will make a separate call to focus the
        // webview when the panel is shown.
        if (this.webview.checkVisibility()) {
            this.webview.focus();
        }
    }
    onNewWindow(e) {
        this.onNewWindowEvent(e);
    }
    async onPermissionRequest(e) {
        e.preventDefault();
        if (!this.host) {
            e.request.deny();
            return;
        }
        switch (e.permission) {
            case 'media': {
                // TODO(b/416092165): Block mic requests if the mic permission was not
                // granted.
                e.request.allow();
                return;
            }
            case 'geolocation': {
                const isGeolocationAllowed = await this.host.shouldAllowGeolocationPermissionRequest();
                if (isGeolocationAllowed) {
                    e.request.allow();
                }
                else {
                    e.request.deny();
                }
                return;
            }
            default:
                break;
        }
        console.warn(`Webview permission request was denied: ${e.permission}`);
        e.request.deny();
    }
    onUnresponsive() {
        this.delegate.webviewUnresponsive();
    }
    onExit(event) {
        chrome.histograms.recordEnumerationValue('Glic.Session.WebClientCrash.ExitReason', webviewExitReasonStringToEnum(event.reason), Object.keys(WEBVIEW_EXIT_REASON_MAP).length);
        if (event.reason !== 'normal') {
            this.destroyHost(WebClientState.ERROR);
            chrome.histograms.recordUserAction('GlicSessionWebClientCrash');
            console.warn(`webview exit. processId: ${event.processId}, reason: ${event.reason}`);
        }
    }
    loadCommit(url, isTopLevel) {
        if (!isTopLevel) {
            return;
        }
        const isCrossDocumentNavigation = this.hasPendingCrossDocumentNavigation;
        this.hasPendingCrossDocumentNavigation = false;
        if (!isCrossDocumentNavigation) {
            return;
        }
        if (this.dormant) {
            return;
        }
        if (this.host) {
            chrome.histograms.recordEnumerationValue('Glic.Host.WebClientState.OnCommit', this.host.getDetailedWebClientState(), DetailedWebClientState.MAX_VALUE + 1);
        }
        const wasResponsive = this.getWebClientState().getCurrentValue() ===
            WebClientState.RESPONSIVE;
        this.destroyHost(WebClientState.UNINITIALIZED);
        const urlObj = URL.parse(url);
        if (urlObj && this.webview.contentWindow &&
            urlMatchesApiAllowedOrigin(urlObj)) {
            this.communicator =
                new GlicApiCommunicator(urlObj.origin, this.webview.contentWindow);
            this.host = new GlicApiHost(this.browserProxy, this.communicator, this.hostEmbedder);
            this.hostSubscriber = this.host.getWebClientState().subscribe(state => {
                if (state === WebClientState.RESPONSIVE) {
                    this.persistentState.onClientReady();
                }
                this.webClientState.assignAndSignal(state);
            });
        }
        this.browserProxy.pageHandler.webviewCommitted(url);
        if (!this.host) {
            this.delegate.webviewPageCommit('loadError');
            return;
        }
        if (url.startsWith('https://login.corp.google.com/') ||
            url.startsWith('https://accounts.google.com/') ||
            url.startsWith('https://accounts.googlers.com/') ||
            url.startsWith('https://gaiastaging.corp.google.com/')) {
            this.delegate.webviewPageCommit('login');
            return;
        }
        if (urlObj?.pathname.startsWith('/sorry/')) {
            this.delegate.webviewPageCommit('guestError');
            return;
        }
        if (wasResponsive) {
            this.persistentState.onCommitAfterConnect(url);
        }
        if (loadTimeData.getBoolean('reloadAfterNavigation')) {
            this.delegate.webviewPageCommit('regular');
        }
    }
    contentLoaded() {
        this.communicator?.contentLoaded();
    }
    onNewWindowEvent(event) {
        if (!this.host) {
            return;
        }
        event.preventDefault();
        if (loadTimeData.getBoolean('glicPopupWindowsEnabled') &&
            event.windowOpenDisposition === 'new_popup') {
            this.host.openLinkInPopup(event.targetUrl, event.initialWidth, event.initialHeight);
        }
        else {
            this.host.openLinkInNewTab(event.targetUrl);
        }
        event.stopPropagation();
    }
    urlMatchesAdminBlockedUrl(url) {
        const adminBlockedRedirectPatterns = loadTimeData.getString('adminBlockedRedirectPatterns');
        if (!adminBlockedRedirectPatterns) {
            return false;
        }
        if (adminBlockedRedirectPatterns.split(' ').some(pattern => new URLPattern(pattern.trim()).test(url))) {
            console.warn(`Admin blocked error page detected.`);
            return true;
        }
        return false;
    }
    onBeforeRequest = (details) => {
        // Allow subframe requests.
        if (details.frameId !== 0) {
            return {};
        }
        if (this.urlMatchesAdminBlockedUrl(details.url)) {
            this.delegate.webviewDeniedByAdmin();
            return { cancel: true };
        }
        return { cancel: !urlMatchesAllowedOrigin(new URL(details.url)) };
    };
}
/**
 * Returns a URLPattern given an origin pattern string that has the syntax:
 * <protocol>://<hostname>[:<port>]
 * where <protocol>, <hostname> and <port> are inserted into URLPattern.
 */
export function matcherForOrigin(originPattern) {
    // This regex is overly permissive in what characters can exist in protocol
    // or hostname. This isn't a problem because we're just passing data to
    // URLPattern.
    const match = originPattern.match(/([^:]+):\/\/([^:]*)(?::(\d+))?[/]?/);
    if (!match) {
        return null;
    }
    const [protocol, hostname, port] = [match[1], match[2], match[3] ?? '*'];
    try {
        return new URLPattern({ protocol, hostname, port });
    }
    catch (_) {
        return null;
    }
}
// 
export function urlMatchesAllowedOrigin(url) {
    if (urlMatchesApiAllowedOrigin(url)) {
        return true;
    }
    return loadTimeData.getString('glicAllowedOrigins')
        .split(' ')
        .some(origin => matcherForOrigin(origin.trim())?.test(url));
}
export function urlMatchesApiAllowedOrigin(url) {
    if (url.origin === 'null') {
        return false;
    }
    // For development.
    if (loadTimeData.getBoolean('devMode')) {
        return true;
    }
    // A URL is allowed to have API access if it either matches glicGuestURL's
    // origin, or it matches any of the explicit API allowed origins.
    const defaultUrl = new URL(loadTimeData.getString('glicGuestURL'));
    if (matcherForOrigin(defaultUrl.origin)?.test(url)) {
        return true;
    }
    const apiAllowedOrigins = loadTimeData.getString('glicApiAllowedOrigins');
    if (!apiAllowedOrigins) {
        return false;
    }
    return apiAllowedOrigins.split(' ').some(origin => matcherForOrigin(origin.trim())?.test(url));
}
