"use strict";
/**
 * Copyright 2022 Google LLC.
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.BrowsingContextImpl = void 0;
const unitConversions_js_1 = require("../../../utils/unitConversions.js");
const protocol_js_1 = require("../../../protocol/protocol.js");
const log_js_1 = require("../../../utils/log.js");
const deferred_js_1 = require("../../../utils/deferred.js");
const realm_js_1 = require("../script/realm.js");
class BrowsingContextImpl {
    /** The ID of this browsing context. */
    #id;
    /**
     * The ID of the parent browsing context.
     * If null, this is a top-level context.
     */
    #parentId;
    /** Direct children browsing contexts. */
    #children = new Set();
    #browsingContextStorage;
    #deferreds = {
        documentInitialized: new deferred_js_1.Deferred(),
        Page: {
            navigatedWithinDocument: new deferred_js_1.Deferred(),
            lifecycleEvent: {
                DOMContentLoaded: new deferred_js_1.Deferred(),
                load: new deferred_js_1.Deferred(),
            },
        },
    };
    #url = 'about:blank';
    #eventManager;
    #realmStorage;
    #loaderId;
    #cdpTarget;
    #maybeDefaultRealm;
    #isNavigating = false;
    #logger;
    constructor(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger) {
        this.#cdpTarget = cdpTarget;
        this.#realmStorage = realmStorage;
        this.#id = id;
        this.#parentId = parentId;
        this.#eventManager = eventManager;
        this.#browsingContextStorage = browsingContextStorage;
        this.#logger = logger;
    }
    static create(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger) {
        const context = new BrowsingContextImpl(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger);
        context.#initListeners();
        browsingContextStorage.addContext(context);
        if (!context.isTopLevelContext()) {
            context.parent.addChild(context.id);
        }
        eventManager.registerEvent({
            method: protocol_js_1.BrowsingContext.EventNames.ContextCreatedEvent,
            params: context.serializeToBidiValue(),
        }, context.id);
        return context;
    }
    static getTimestamp() {
        // `timestamp` from the event is MonotonicTime, not real time, so
        // the best Mapper can do is to set the timestamp to the epoch time
        // of the event arrived.
        // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-MonotonicTime
        return new Date().getTime();
    }
    /**
     * @see https://html.spec.whatwg.org/multipage/document-sequences.html#navigable
     */
    get navigableId() {
        return this.#loaderId;
    }
    delete() {
        this.#deleteAllChildren();
        this.#realmStorage.deleteRealms({
            browsingContextId: this.id,
        });
        // Remove context from the parent.
        if (!this.isTopLevelContext()) {
            this.parent.#children.delete(this.id);
        }
        this.#eventManager.registerEvent({
            method: protocol_js_1.BrowsingContext.EventNames.ContextDestroyedEvent,
            params: this.serializeToBidiValue(),
        }, this.id);
        this.#browsingContextStorage.deleteContextById(this.id);
    }
    /** Returns the ID of this context. */
    get id() {
        return this.#id;
    }
    /** Returns the parent context ID. */
    get parentId() {
        return this.#parentId;
    }
    /** Returns the parent context. */
    get parent() {
        if (this.parentId === null) {
            return null;
        }
        return this.#browsingContextStorage.getContext(this.parentId);
    }
    /** Returns all direct children contexts. */
    get directChildren() {
        return [...this.#children].map((id) => this.#browsingContextStorage.getContext(id));
    }
    /** Returns all children contexts, flattened. */
    get allChildren() {
        const children = this.directChildren;
        return children.concat(...children.map((child) => child.allChildren));
    }
    /**
     * Returns true if this is a top-level context.
     * This is the case whenever the parent context ID is null.
     */
    isTopLevelContext() {
        return this.#parentId === null;
    }
    get top() {
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        let topContext = this;
        let parent = topContext.parent;
        while (parent) {
            topContext = parent;
            parent = topContext.parent;
        }
        return topContext;
    }
    addChild(childId) {
        this.#children.add(childId);
    }
    #deleteAllChildren() {
        this.directChildren.map((child) => child.delete());
    }
    get #defaultRealm() {
        if (this.#maybeDefaultRealm === undefined) {
            throw new Error(`No default realm for browsing context ${this.#id}`);
        }
        return this.#maybeDefaultRealm;
    }
    get cdpTarget() {
        return this.#cdpTarget;
    }
    updateCdpTarget(cdpTarget) {
        this.#cdpTarget = cdpTarget;
        this.#initListeners();
    }
    get url() {
        return this.#url;
    }
    async awaitLoaded() {
        await this.#deferreds.Page.lifecycleEvent.load;
    }
    awaitUnblocked() {
        return this.#cdpTarget.targetUnblocked;
    }
    async getOrCreateSandbox(sandbox) {
        if (sandbox === undefined || sandbox === '') {
            return this.#defaultRealm;
        }
        let maybeSandboxes = this.#realmStorage.findRealms({
            browsingContextId: this.id,
            sandbox,
        });
        if (maybeSandboxes.length === 0) {
            await this.#cdpTarget.cdpClient.sendCommand('Page.createIsolatedWorld', {
                frameId: this.id,
                worldName: sandbox,
            });
            // `Runtime.executionContextCreated` should be emitted by the time the
            // previous command is done.
            maybeSandboxes = this.#realmStorage.findRealms({
                browsingContextId: this.id,
                sandbox,
            });
        }
        if (maybeSandboxes.length !== 1) {
            throw Error(`Sandbox ${sandbox} wasn't created.`);
        }
        return maybeSandboxes[0];
    }
    serializeToBidiValue(maxDepth = 0, addParentField = true) {
        return {
            context: this.#id,
            url: this.url,
            children: maxDepth > 0
                ? this.directChildren.map((c) => c.serializeToBidiValue(maxDepth - 1, false))
                : null,
            ...(addParentField ? { parent: this.#parentId } : {}),
        };
    }
    onTargetInfoChanged(params) {
        this.#url = params.targetInfo.url;
        if (this.#isNavigating) {
            this.#eventManager.registerEvent({
                method: protocol_js_1.BrowsingContext.EventNames.NavigationStarted,
                params: {
                    context: this.id,
                    // TODO: The network event is send before the CDP Page.frameStartedLoading
                    // It theory there should be a way to get the data.
                    navigation: null,
                    timestamp: BrowsingContextImpl.getTimestamp(),
                    url: this.#url,
                },
            }, this.id);
            this.#isNavigating = false;
        }
    }
    #initListeners() {
        this.#cdpTarget.cdpClient.on('Page.frameNavigated', (params) => {
            if (this.id !== params.frame.id) {
                return;
            }
            const timestamp = BrowsingContextImpl.getTimestamp();
            this.#url = params.frame.url + (params.frame.urlFragment ?? '');
            // At the point the page is initialized, all the nested iframes from the
            // previous page are detached and realms are destroyed.
            // Remove children from context.
            this.#deleteAllChildren();
            this.#eventManager.registerEvent({
                method: protocol_js_1.BrowsingContext.EventNames.FragmentNavigated,
                params: {
                    context: this.id,
                    navigation: this.#loaderId ?? null,
                    timestamp,
                    url: this.#url,
                },
            }, this.id);
        });
        this.#cdpTarget.cdpClient.on('Page.navigatedWithinDocument', (params) => {
            if (this.id !== params.frameId) {
                return;
            }
            const timestamp = BrowsingContextImpl.getTimestamp();
            this.#url = params.url;
            this.#deferreds.Page.navigatedWithinDocument.resolve(params);
            // TODO: Remove this once History event for BiDi are added
            this.#eventManager.registerEvent({
                method: protocol_js_1.BrowsingContext.EventNames.FragmentNavigated,
                params: {
                    context: this.id,
                    navigation: null,
                    timestamp,
                    url: this.#url,
                },
            }, this.id);
        });
        this.#cdpTarget.cdpClient.on('Page.frameStartedLoading', (params) => {
            if (this.id !== params.frameId) {
                return;
            }
            this.#isNavigating = true;
        });
        this.#cdpTarget.cdpClient.on('Page.frameStoppedLoading', (params) => {
            if (this.id !== params.frameId) {
                return;
            }
            this.#isNavigating = false;
        });
        this.#cdpTarget.cdpClient.on('Page.lifecycleEvent', (params) => {
            if (this.id !== params.frameId) {
                return;
            }
            if (params.name === 'init') {
                this.#documentChanged(params.loaderId);
                this.#deferreds.documentInitialized.resolve();
                return;
            }
            if (params.name === 'commit') {
                this.#loaderId = params.loaderId;
                return;
            }
            // Ignore event from not current navigation.
            if (params.loaderId !== this.#loaderId) {
                return;
            }
            const timestamp = BrowsingContextImpl.getTimestamp();
            switch (params.name) {
                case 'DOMContentLoaded':
                    this.#deferreds.Page.lifecycleEvent.DOMContentLoaded.resolve(params);
                    this.#eventManager.registerEvent({
                        method: protocol_js_1.BrowsingContext.EventNames.DomContentLoadedEvent,
                        params: {
                            context: this.id,
                            navigation: this.#loaderId ?? null,
                            timestamp,
                            url: this.#url,
                        },
                    }, this.id);
                    break;
                case 'load':
                    this.#deferreds.Page.lifecycleEvent.load.resolve(params);
                    this.#eventManager.registerEvent({
                        method: protocol_js_1.BrowsingContext.EventNames.LoadEvent,
                        params: {
                            context: this.id,
                            navigation: this.#loaderId ?? null,
                            timestamp,
                            url: this.#url,
                        },
                    }, this.id);
                    break;
            }
        });
        this.#cdpTarget.cdpClient.on('Runtime.executionContextCreated', (params) => {
            if (params.context.auxData.frameId !== this.id) {
                return;
            }
            // Only this execution contexts are supported for now.
            if (!['default', 'isolated'].includes(params.context.auxData.type)) {
                return;
            }
            const realm = new realm_js_1.Realm(this.#realmStorage, this.#browsingContextStorage, params.context.uniqueId, this.id, params.context.id, this.#getOrigin(params), 
            // XXX: differentiate types.
            'window', 
            // Sandbox name for isolated world.
            params.context.auxData.type === 'isolated'
                ? params.context.name
                : undefined, this.#cdpTarget.cdpSessionId, this.#cdpTarget.cdpClient, this.#eventManager, this.#logger);
            if (params.context.auxData.isDefault) {
                this.#maybeDefaultRealm = realm;
                // Initialize ChannelProxy listeners for all the channels of all the
                // preload scripts related to this BrowsingContext.
                // TODO: extend for not default realms by the sandbox name.
                void Promise.all(this.#cdpTarget
                    .getChannels(this.id)
                    .map((channel) => channel.startListenerFromWindow(realm, this.#eventManager)));
            }
        });
        this.#cdpTarget.cdpClient.on('Runtime.executionContextDestroyed', (params) => {
            this.#realmStorage.deleteRealms({
                cdpSessionId: this.#cdpTarget.cdpSessionId,
                executionContextId: params.executionContextId,
            });
        });
        this.#cdpTarget.cdpClient.on('Runtime.executionContextsCleared', () => {
            this.#realmStorage.deleteRealms({
                cdpSessionId: this.#cdpTarget.cdpSessionId,
            });
        });
    }
    #getOrigin(params) {
        if (params.context.auxData.type === 'isolated') {
            // Sandbox should have the same origin as the context itself, but in CDP
            // it has an empty one.
            return this.#defaultRealm.origin;
        }
        // https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin
        return ['://', ''].includes(params.context.origin)
            ? 'null'
            : params.context.origin;
    }
    #documentChanged(loaderId) {
        // Same document navigation.
        if (loaderId === undefined || this.#loaderId === loaderId) {
            if (this.#deferreds.Page.navigatedWithinDocument.isFinished) {
                this.#deferreds.Page.navigatedWithinDocument =
                    new deferred_js_1.Deferred();
            }
            else {
                this.#logger?.(log_js_1.LogType.browsingContexts, 'Document changed (navigatedWithinDocument)');
            }
            return;
        }
        this.#resetDeferredsIfFinished();
        this.#loaderId = loaderId;
    }
    #resetDeferredsIfFinished() {
        if (this.#deferreds.documentInitialized.isFinished) {
            this.#deferreds.documentInitialized = new deferred_js_1.Deferred();
        }
        else {
            this.#logger?.(log_js_1.LogType.browsingContexts, 'Document changed (document initialized)');
        }
        if (this.#deferreds.Page.lifecycleEvent.DOMContentLoaded.isFinished) {
            this.#deferreds.Page.lifecycleEvent.DOMContentLoaded =
                new deferred_js_1.Deferred();
        }
        else {
            this.#logger?.(log_js_1.LogType.browsingContexts, 'Document changed (DOMContentLoaded)');
        }
        if (this.#deferreds.Page.lifecycleEvent.load.isFinished) {
            this.#deferreds.Page.lifecycleEvent.load =
                new deferred_js_1.Deferred();
        }
        else {
            this.#logger?.(log_js_1.LogType.browsingContexts, 'Document changed (load)');
        }
    }
    async navigate(url, wait) {
        await this.awaitUnblocked();
        // TODO: handle loading errors.
        const cdpNavigateResult = await this.#cdpTarget.cdpClient.sendCommand('Page.navigate', {
            url,
            frameId: this.id,
        });
        if (cdpNavigateResult.errorText) {
            throw new protocol_js_1.Message.UnknownErrorException(cdpNavigateResult.errorText);
        }
        this.#documentChanged(cdpNavigateResult.loaderId);
        switch (wait) {
            case 'none':
                break;
            case 'interactive':
                // No `loaderId` means same-document navigation.
                if (cdpNavigateResult.loaderId === undefined) {
                    await this.#deferreds.Page.navigatedWithinDocument;
                }
                else {
                    await this.#deferreds.Page.lifecycleEvent.DOMContentLoaded;
                }
                break;
            case 'complete':
                // No `loaderId` means same-document navigation.
                if (cdpNavigateResult.loaderId === undefined) {
                    await this.#deferreds.Page.navigatedWithinDocument;
                }
                else {
                    await this.awaitLoaded();
                }
                break;
        }
        return {
            result: {
                navigation: cdpNavigateResult.loaderId ?? null,
                url,
            },
        };
    }
    async reload(ignoreCache, wait) {
        await this.awaitUnblocked();
        await this.#cdpTarget.cdpClient.sendCommand('Page.reload', {
            ignoreCache,
        });
        this.#resetDeferredsIfFinished();
        switch (wait) {
            case 'none':
                break;
            case 'interactive':
                await this.#deferreds.Page.lifecycleEvent.DOMContentLoaded;
                break;
            case 'complete':
                await this.awaitLoaded();
                break;
        }
        return { result: {} };
    }
    async setViewport(viewport) {
        if (viewport === null) {
            await this.#cdpTarget.cdpClient.sendCommand('Emulation.clearDeviceMetricsOverride');
        }
        else {
            try {
                await this.#cdpTarget.cdpClient.sendCommand('Emulation.setDeviceMetricsOverride', {
                    width: viewport.width,
                    height: viewport.height,
                    deviceScaleFactor: 0,
                    mobile: false,
                    dontSetVisibleSize: true,
                });
            }
            catch (err) {
                if (err.message.startsWith(
                // https://crsrc.org/c/content/browser/devtools/protocol/emulation_handler.cc;l=257;drc=2f6eee84cf98d4227e7c41718dd71b82f26d90ff
                'Width and height values must be positive')) {
                    throw new protocol_js_1.Message.UnsupportedOperationException('Provided viewport dimensions are not supported');
                }
                throw err;
            }
        }
    }
    async captureScreenshot() {
        // XXX: Focus the original tab after the screenshot is taken.
        // This is needed because the screenshot gets blocked until the active tab gets focus.
        await this.#cdpTarget.cdpClient.sendCommand('Page.bringToFront');
        let clip;
        if (this.isTopLevelContext()) {
            const { cssContentSize, cssLayoutViewport } = await this.#cdpTarget.cdpClient.sendCommand('Page.getLayoutMetrics');
            clip = {
                x: cssContentSize.x,
                y: cssContentSize.y,
                width: cssLayoutViewport.clientWidth,
                height: cssLayoutViewport.clientHeight,
            };
        }
        else {
            const { result: { value: iframeDocRect }, } = await this.#cdpTarget.cdpClient.sendCommand('Runtime.callFunctionOn', {
                functionDeclaration: String(() => {
                    const docRect = globalThis.document.documentElement.getBoundingClientRect();
                    return JSON.stringify({
                        x: docRect.x,
                        y: docRect.y,
                        width: docRect.width,
                        height: docRect.height,
                    });
                }),
                executionContextId: this.#defaultRealm.executionContextId,
            });
            clip = JSON.parse(iframeDocRect);
        }
        const result = await this.#cdpTarget.cdpClient.sendCommand('Page.captureScreenshot', {
            clip: {
                ...clip,
                scale: 1.0,
            },
        });
        return {
            result: {
                data: result.data,
            },
        };
    }
    async print(params) {
        const cdpParams = {};
        if (params.background !== undefined) {
            cdpParams.printBackground = params.background;
        }
        if (params.margin?.bottom !== undefined) {
            cdpParams.marginBottom = (0, unitConversions_js_1.inchesFromCm)(params.margin.bottom);
        }
        if (params.margin?.left !== undefined) {
            cdpParams.marginLeft = (0, unitConversions_js_1.inchesFromCm)(params.margin.left);
        }
        if (params.margin?.right !== undefined) {
            cdpParams.marginRight = (0, unitConversions_js_1.inchesFromCm)(params.margin.right);
        }
        if (params.margin?.top !== undefined) {
            cdpParams.marginTop = (0, unitConversions_js_1.inchesFromCm)(params.margin.top);
        }
        if (params.orientation !== undefined) {
            cdpParams.landscape = params.orientation === 'landscape';
        }
        if (params.page?.height !== undefined) {
            cdpParams.paperHeight = (0, unitConversions_js_1.inchesFromCm)(params.page.height);
        }
        if (params.page?.width !== undefined) {
            cdpParams.paperWidth = (0, unitConversions_js_1.inchesFromCm)(params.page.width);
        }
        if (params.pageRanges !== undefined) {
            cdpParams.pageRanges = params.pageRanges.join(',');
        }
        if (params.scale !== undefined) {
            cdpParams.scale = params.scale;
        }
        if (params.shrinkToFit !== undefined) {
            cdpParams.preferCSSPageSize = !params.shrinkToFit;
        }
        const result = await this.#cdpTarget.cdpClient.sendCommand('Page.printToPDF', cdpParams);
        return {
            result: {
                data: result.data,
            },
        };
    }
}
exports.BrowsingContextImpl = BrowsingContextImpl;
//# sourceMappingURL=browsingContextImpl.js.map