// 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 { assert } from '//resources/js/assert.js';
import { loadTimeData } from '//resources/js/load_time_data.js';
import { previousReadHighlightClass } from '../read_aloud/movement.js';
import { getReadAloudModel } from '../read_aloud/read_aloud_model_browser_proxy.js';
import { ReadAloudNode } from '../read_aloud/read_aloud_types.js';
import { SpeechController } from '../read_aloud/speech_controller.js';
import { getReadingModeTextNodes, isDistilledByReadability, LOG_EMPTY_DELAY_MS } from '../shared/common.js';
import { LinkStatus, ReadAnythingLogger } from '../shared/read_anything_logger.js';
import { NodeStore } from './node_store.js';
import { removeExtraneousElementsFrom } from './readability_content_processing.js';
import { ReadabilityImageClassifier } from './readability_image_classifier.js';
const DATA_PREFIX = 'data-';
const LINK_DATA_ATTR = 'link';
const LINKS_OFF_TAG = 'span';
const LINKS_ON_TAG = 'a';
const LINKS_OFF_SELECTOR = `${LINKS_OFF_TAG}[${DATA_PREFIX}${LINK_DATA_ATTR}]`;
export const HIGHLIGHTED_LINK_CLASS = 'highlighted-link';
// Reading mode sometimes needs to use a different html tag to display a
// particular node than the one used in the main panel. This maps the tags
// received from the renderer to the tag to use in Reading mode.
const SCREEN2X_TAG_TO_RM_TAG = new Map([
    // getHtmlTag might return '#document' which is not a valid to pass to
    // createElement.
    ['#document', 'div'],
    // Only one body tag is allowed per document.
    ['body', 'div'],
    // details tags hide content beneath them if closed. If opened, there is
    // content underneath we should show, but surrounding it with a generic
    // details tag causes it to be hidden in reading mode. So use a div instead.
    // In the cases that the details are closed, then nothing will be returned
    // beneath the details tag so nothing is rendered on reading mode.
    ['details', 'div'],
    ['img', 'canvas'],
    // Sometimes videos are marked with the role of image, especially if they're
    // gifs. Draw a still image on the canvas instead of a moving image.
    // TODO(crbug.com/439634112): Consider a setting to allow moving images.
    ['video', 'canvas'],
    // Buttons are sometimes distilled but button click logic isn't handled
    // by reading mode, so these shouldn't be distilled as clickable elements.
    ['button', 'div'],
    // Reading mode shouldn't be distilling input elements, but if this ever
    // happens, ensure they're converted to divs so that an interactable
    // element isn't shown on the reading mode panel.
    ['input', 'div'],
]);
// Readability doesn't need to replace as many tags as Screen2x. If there's
// more overlap in the future, the Screen2x map and the Readability map
// may need to be merged more.
const READABILITY_TAG_TO_RM_TAG = new Map([
    ['button', 'div'],
    ['details', 'div'],
    ['mark', 'div'],
]);
export var ContentType;
(function (ContentType) {
    // Reading mode is loading and may or may not have content when it finishes.
    ContentType[ContentType["LOADING"] = 0] = "LOADING";
    // There is no content to display but the user can try to select text to get
    // content.
    ContentType[ContentType["NO_CONTENT"] = 1] = "NO_CONTENT";
    // There is no content to display and the user cannot select text to get
    // content.
    ContentType[ContentType["NO_SELECTABLE_CONTENT"] = 2] = "NO_SELECTABLE_CONTENT";
    // There is content displayed in Reading mode.
    ContentType[ContentType["HAS_CONTENT"] = 3] = "HAS_CONTENT";
})(ContentType || (ContentType = {}));
// Use a Record to enforce that every ContentType has a corresponding
// ContentState.
const CONTENT_STATES = {
    [ContentType.LOADING]: {
        type: ContentType.LOADING,
        imagePath: '//resources/images/throbber_small.svg',
        darkImagePath: '//resources/images/throbber_small_dark.svg',
        heading: loadTimeData.getString('readAnythingLoadingMessage'),
        subheading: '',
    },
    [ContentType.NO_CONTENT]: {
        type: ContentType.NO_CONTENT,
        imagePath: './images/empty_state.svg',
        darkImagePath: './images/empty_state.svg',
        heading: loadTimeData.getString('emptyStateHeader'),
        subheading: loadTimeData.getString('emptyStateSubheader'),
    },
    [ContentType.NO_SELECTABLE_CONTENT]: {
        type: ContentType.NO_SELECTABLE_CONTENT,
        imagePath: './images/empty_state.svg',
        darkImagePath: './images/empty_state.svg',
        heading: loadTimeData.getString('notSelectableHeader'),
        subheading: loadTimeData.getString('emptyStateSubheader'),
    },
    [ContentType.HAS_CONTENT]: {
        type: ContentType.HAS_CONTENT,
        imagePath: '',
        darkImagePath: '',
        heading: '',
        subheading: '',
    },
};
// TODO: crbug.com/440400392- Investigate extracting instances of referencing
// read aloud model directly to an observer.
// Handles the business logic for the visual content of the Reading mode panel.
// This class also is responsible for updating read aloud model when the
// visual content changes.
export class ContentController {
    nodeStore_ = NodeStore.getInstance();
    speechController_ = SpeechController.getInstance();
    logger_ = ReadAnythingLogger.getInstance();
    listeners_ = [];
    currentState_ = CONTENT_STATES[ContentType.NO_CONTENT];
    previousRootId_;
    // The Trusted Types policy is registered globally on the window object.
    // The browser only allows a policy name to be registered once per page load.
    // Making this static ensures that multiple instances of ContentController
    // (which occur frequently during WebUI test runs) share the same policy
    // reference and do not trigger a "Policy already exists" TypeError.
    static trustedUpdatePolicy;
    // Holds the text nodes extracted from the current rendered distillation.
    // This array ensures that we can link a DOM node back to its AXTree
    // mapping for text selection via its index in this array.
    renderedTextNodes_ = [];
    getState() {
        return this.currentState_;
    }
    setState(type) {
        if (type === this.currentState_.type) {
            return;
        }
        this.currentState_ = CONTENT_STATES[type];
        this.listeners_.forEach(l => l.onContentStateChange());
    }
    hasContent() {
        return this.currentState_.type === ContentType.HAS_CONTENT;
    }
    isEmpty() {
        return this.currentState_.type === ContentType.NO_CONTENT ||
            this.currentState_.type === ContentType.NO_SELECTABLE_CONTENT;
    }
    setEmpty() {
        const noContentType = this.getNoContentType_();
        if (this.isEmpty() && this.currentState_.type === noContentType) {
            return;
        }
        // Log the empty state only after a short delay. Sometimes the empty state
        // is only shown very briefly before the content is distilled, so we don't
        // need to count those instances as a failure to distill.
        setTimeout(() => {
            if (this.isEmpty()) {
                this.logger_.logEmptyState();
            }
        }, LOG_EMPTY_DELAY_MS);
        this.setState(noContentType);
    }
    getNoContentType_() {
        return chrome.readingMode.isGoogleDocs ? ContentType.NO_SELECTABLE_CONTENT :
            ContentType.NO_CONTENT;
    }
    addListener(listener) {
        this.listeners_.push(listener);
    }
    onNodeWillBeDeleted(nodeId) {
        const deletedNode = this.nodeStore_.getDomNode(nodeId);
        // When a node is deleted, the read aloud model can get out of sync with the
        // DOM. To be safe, delete the node from the model.
        if (deletedNode && !chrome.readingMode.isPhraseHighlightingEnabled) {
            getReadAloudModel().onNodeWillBeDeleted?.(deletedNode);
        }
        if (deletedNode) {
            this.nodeStore_.removeDomNode(deletedNode);
            deletedNode.remove();
            this.listeners_.forEach(l => l.onContentChange());
        }
        const root = this.nodeStore_.getDomNode(chrome.readingMode.rootId);
        if (this.hasContent() && !root?.textContent?.trim()) {
            this.setState(this.getNoContentType_());
            chrome.readingMode.onNoTextContent();
        }
    }
    updateContent(shadowRoot) {
        // This shouldn't happen. If it does, there is likely a bug, so log it so
        // we can monitor it.
        if (this.speechController_.isSpeechActive()) {
            console.error('updateContent called while speech is active. ', 'There may be a bug.');
            this.logger_.logSpeechStopSource(chrome.readingMode.unexpectedUpdateContentStopSource);
        }
        this.speechController_.saveReadAloudState();
        this.speechController_.resetForNewContent();
        this.nodeStore_.clear();
        if (isDistilledByReadability()) {
            return this.updateContentForReadability();
        }
        return this.updateContentForScreen2x(shadowRoot);
    }
    updateContentForReadability() {
        if (isDistilledByReadability()) {
            // Readability Path: Build DOM from the HTML string.
            const title = chrome.readingMode.htmlTitle;
            const contentHtml = chrome.readingMode.htmlContent;
            if (!contentHtml || !contentHtml.trim()) {
                this.setEmpty();
                return null;
            }
            const contentFragment = document.createDocumentFragment();
            if (title) {
                const titleElement = document.createElement('h1');
                titleElement.textContent = title;
                contentFragment.appendChild(titleElement);
            }
            const contentContainer = document.createElement('div');
            contentContainer.innerHTML = this.getTrustedHtml(contentHtml);
            // Strip single newlines from text nodes to prevent unexpected sentence
            // breaks, as Readability preserves raw HTML newlines which are visually
            // rendered as spaces. Consecutive newlines are preserved as they may
            // denote intentional line breaks.
            const walker = document.createTreeWalker(contentContainer, NodeFilter.SHOW_TEXT);
            while (walker.nextNode()) {
                const node = walker.currentNode;
                if (node.nodeValue && !node.parentElement?.closest('pre')) {
                    node.nodeValue = node.nodeValue.replace(/(?<!\n)\n(?!\n)/g, ' ');
                }
            }
            // Replace tags that shouldn't be interactive or have special behavior
            // in reading mode. This is similar to what happens in `buildSubtree_`
            // for Screen2x.
            for (const [tag, replacement] of READABILITY_TAG_TO_RM_TAG) {
                const elements = contentContainer.querySelectorAll(tag);
                for (const element of elements) {
                    const replacementEl = document.createElement(replacement);
                    while (element.firstChild) {
                        replacementEl.appendChild(element.firstChild);
                    }
                    for (const attr of element.attributes) {
                        replacementEl.setAttribute(attr.name, attr.value);
                    }
                    element.replaceWith(replacementEl);
                }
            }
            // Process images from distillation. If images are disabled or there is
            // no text content, images shouldn't be considered "content" and the
            // empty state page should be shown. If there is a valid selection, then
            // the images may be shown.
            // TODO: crbug.com/483140075- Right now, selection with Readability does
            // not work, so selected images will not be distilled.
            const hasImages = this.updateImagesForReadability_(contentContainer);
            const hasImagesAsContent = chrome.readingMode.imagesEnabled &&
                chrome.readingMode.hasValidSelection && hasImages;
            if (!contentContainer.textContent?.trim() && !hasImagesAsContent) {
                this.setEmpty();
                return null;
            }
            this.logger_.logNewPage(/*speechPlayed=*/ false);
            this.setState(ContentType.HAS_CONTENT);
            contentFragment.appendChild(contentContainer);
            // Ensure link visibility is updated with user preferences.
            this.updateLinksForReadability(contentContainer);
            this.applyReadabilityContentPostProcessing_(contentContainer);
            this.updateReadAloudState(contentFragment);
            this.listeners_.forEach(l => l.onContentChange());
            return contentFragment;
        }
        return null;
    }
    applyReadabilityContentPostProcessing_(contentContainer) {
        if (!isDistilledByReadability()) {
            return;
        }
        // TODO(crbug.com/478229109): Remove duplicated code once we share code with
        // mobile's Reading Mode.
        removeExtraneousElementsFrom(contentContainer);
        ReadabilityImageClassifier.processImagesIn(contentContainer);
    }
    updateContentForScreen2x(shadowRoot) {
        // Construct a dom subtree starting with the display root. The display
        // root may be invalid if there are no content nodes and no selection.
        // This does not use Lit's templating abstraction, which would create a
        // shadow node element representing each AXNode, because experimentation
        // (with Polymer) found the shadow node creation to be ~8-10x slower than
        // constructing and appending nodes directly to the container element.
        const rootId = chrome.readingMode.rootId;
        if (!rootId) {
            return null;
        }
        const node = this.buildSubtree_(rootId);
        // If there is no text or images in the tree, do not proceed. The empty
        // state container will show instead.
        // We should only consider images as content when determining whether or
        // not to show the empty state page if they are enabled and if
        // the user specifically selected the content.
        const hasImagesAsContent = chrome.readingMode.imagesEnabled &&
            chrome.readingMode.hasValidSelection &&
            this.nodeStore_.hasImagesToFetch();
        if (!node.textContent?.trim() && !hasImagesAsContent) {
            // Sometimes the controller thinks there will be content and redraws
            // without showing the empty page, but we end up not actually having any
            // content and also not showing the empty page sometimes. In this case,
            // send that info back to the controller.
            if (this.hasContent()) {
                this.setEmpty();
                chrome.readingMode.onNoTextContent();
            }
            else if (!this.isEmpty()) {
                // This is possible when the AXTree returns bad selection data and
                // reading mode believes it has selected content to distll but
                // nothing valid is selected. This can cause the loading screen
                // to never switch to the empty state.
                this.setEmpty();
            }
            return null;
        }
        if (this.previousRootId_ !== rootId) {
            this.previousRootId_ = rootId;
            this.logger_.logNewPage(/*speechPlayed=*/ false);
        }
        // Always load images even if they are disabled to ensure a fast response
        // when toggling.
        this.loadImages();
        this.setState(ContentType.HAS_CONTENT);
        this.updateImages(shadowRoot);
        this.listeners_.forEach(l => l.onContentChange());
        this.updateReadAloudState(node);
        return node;
    }
    onRenderedTextMappingReady() {
        if (!isDistilledByReadability() ||
            !chrome.readingMode.isReadabilitySelectTextEnabled) {
            return;
        }
        // Iterate through the rendered text nodes by their index.
        for (let i = 0; i < this.renderedTextNodes_.length; i++) {
            const node = this.renderedTextNodes_[i];
            if (!(node instanceof Text) || !node.parentNode) {
                continue;
            }
            // Retrieve the mapping segments for this specific block index.
            const segments = chrome.readingMode.getAxMapping(i);
            if (segments && segments.length > 0) {
                this.mapBlockToAxNodes_(node, segments);
            }
        }
        // After populating the NodeStore, trigger a selection update to synchronize
        // any existing selection state.
        chrome.readingMode.updateSelection();
    }
    mapBlockToAxNodes_(node, segments) {
        // Link the block (rendered text node) to it's equivalent segment in the
        // AXnode. For multiple segments, mapping to a single block, we split the
        // block to create a 1:1 mapping between rendered text and an AXNode.
        let currentNode = node;
        let lastOffset = 0;
        for (const segment of segments) {
            if (segment.start > lastOffset) {
                const gapLength = segment.start - lastOffset;
                currentNode = currentNode.splitText(gapLength);
                lastOffset = segment.start;
            }
            const nodeLength = currentNode.textContent?.length || 0;
            const segmentLength = Math.min(segment.end - lastOffset, nodeLength);
            // Only split if there is text remaining in the node to avoid creating
            // empty trailing nodes.
            if (segmentLength < nodeLength) {
                const remainingNode = currentNode.splitText(segmentLength);
                this.nodeStore_.setDomNode(currentNode, segment.axNodeId);
                this.nodeStore_.setAxNodeOffset(currentNode, segment.axNodeOffset);
                currentNode = remainingNode;
            }
            else {
                this.nodeStore_.setDomNode(currentNode, segment.axNodeId);
                this.nodeStore_.setAxNodeOffset(currentNode, segment.axNodeOffset);
            }
            lastOffset = segment.end;
        }
    }
    updateReadAloudState(rootNode) {
        // If the previous reading position still exists and we haven't reached the
        // end of speech, keep that spot.
        const setPreviousReadingPosition = this.speechController_.setPreviousReadingPositionIfExists();
        requestAnimationFrame(() => {
            // Count this as a new page as long as there's no reading position to keep
            // from before.
            if (!setPreviousReadingPosition) {
                this.listeners_.forEach(l => l.onNewPageDrawn());
            }
            this.nodeStore_.estimateWordsSeenWithDelay();
            // Initialize the speech tree with the new content.
            if (!chrome.readingMode.isPhraseHighlightingEnabled) {
                const contextNode = ReadAloudNode.create(rootNode);
                if (contextNode) {
                    // Don't initialize until after drawing otherwise, the DOM nodes might
                    // not yet exist in the tree.
                    getReadAloudModel().init(contextNode);
                }
            }
        });
    }
    buildSubtree_(nodeId) {
        let htmlTag = chrome.readingMode.getHtmlTag(nodeId);
        const dataAttributes = new Map();
        // Text nodes do not have an html tag.
        if (!htmlTag.length) {
            return this.createTextNode_(nodeId);
        }
        // For Google Docs, we extract text from Annotated Canvas. The Annotated
        // Canvas elements with text are leaf nodes with <rect> html tag.
        if (chrome.readingMode.isGoogleDocs &&
            chrome.readingMode.isLeafNode(nodeId)) {
            return this.createTextNode_(nodeId);
        }
        if (SCREEN2X_TAG_TO_RM_TAG.has(htmlTag)) {
            htmlTag = SCREEN2X_TAG_TO_RM_TAG.get(htmlTag);
        }
        const url = chrome.readingMode.getUrl(nodeId);
        if (!this.shouldShowLinks_() && htmlTag === LINKS_ON_TAG) {
            htmlTag = LINKS_OFF_TAG;
            dataAttributes.set(LINK_DATA_ATTR, url ?? '');
        }
        const element = document.createElement(htmlTag);
        const htmlId = chrome.readingMode.getHtmlId(nodeId);
        if (htmlId) {
            element.id = htmlId;
        }
        // Add required data attributes.
        for (const [attr, val] of dataAttributes) {
            element.dataset[attr] = val;
        }
        this.nodeStore_.setDomNode(element, nodeId);
        const direction = chrome.readingMode.getTextDirection(nodeId);
        if (direction) {
            element.setAttribute('dir', direction);
        }
        if (element.nodeName === 'CANVAS') {
            this.nodeStore_.addImageToFetch(nodeId);
            const altText = chrome.readingMode.getAltText(nodeId);
            element.setAttribute('alt', altText);
            element.style.display = chrome.readingMode.imagesEnabled ? '' : 'none';
            element.classList.add('downloaded-image');
        }
        if (element.nodeName === 'FIGURE') {
            element.style.display = chrome.readingMode.imagesEnabled ? '' : 'none';
        }
        if (url && element.nodeName === 'A') {
            this.setLinkAttributes_(element, url, nodeId);
        }
        const language = chrome.readingMode.getLanguage(nodeId);
        if (language) {
            element.setAttribute('lang', language);
        }
        this.appendChildSubtrees_(element, nodeId);
        return element;
    }
    appendChildSubtrees_(node, nodeId) {
        for (const childNodeId of chrome.readingMode.getChildren(nodeId)) {
            const childNode = this.buildSubtree_(childNodeId);
            node.appendChild(childNode);
        }
    }
    safeParseUrl_(url, base) {
        try {
            return base ? new URL(url, base) : new URL(url);
        }
        catch (e) {
            console.warn('Failed to parse URL:', url, base, e);
            return null;
        }
    }
    scrollToAnchor(url, root) {
        if (!url) {
            return false;
        }
        const pageUrlString = chrome.readingMode.documentUrl;
        if (!pageUrlString) {
            return false;
        }
        const pageUrl = this.safeParseUrl_(pageUrlString);
        const linkUrl = pageUrl ? this.safeParseUrl_(url, pageUrl.href) : null;
        if (!pageUrl || !linkUrl) {
            return false;
        }
        // The browser is only sending same document links, but this is an
        // additional safety check.
        const isSameDocument = linkUrl.origin === pageUrl.origin &&
            linkUrl.pathname === pageUrl.pathname &&
            linkUrl.search === pageUrl.search;
        if (!isSameDocument) {
            return false;
        }
        let targetId = null;
        if (linkUrl.hash) {
            try {
                targetId = decodeURIComponent(linkUrl.hash.substring(1));
            }
            catch (e) {
                targetId = linkUrl.hash.substring(1);
            }
        }
        // Case 1: We have a specific target ID. Try to scroll to it.
        if (targetId) {
            const target = root.getElementById(targetId);
            if (target) {
                target.scrollIntoView({ behavior: 'smooth' });
                return true;
            }
            if (targetId.toLowerCase() !== 'top') {
                return false; // Target ID was specified but element not found.
            }
            // Fall through to Case 2 for 'top'
        }
        // Case 2: Same-document navigation with no hash (navigated back to top).
        const scroller = root.getElementById('containerScroller');
        if (scroller) {
            scroller.scrollTo({ top: 0, behavior: 'smooth' });
            return true;
        }
        return false;
    }
    setLinkAttributes_(element, url, nodeId) {
        element.setAttribute('href', url);
        element.onclick = (event) => {
            event.preventDefault();
            if (nodeId) {
                chrome.readingMode.onLinkClicked(nodeId);
            }
        };
    }
    createTextNode_(nodeId) {
        const textContent = chrome.readingMode.getTextContent(nodeId);
        const textNode = document.createTextNode(textContent);
        this.nodeStore_.setDomNode(textNode, nodeId);
        const isOverline = chrome.readingMode.isOverline(nodeId);
        const shouldBold = chrome.readingMode.shouldBold(nodeId);
        // When creating text nodes, save the first text node id. We need this
        // node id to call InitAXPosition in playSpeech. If it's not saved here,
        // we have to retrieve it through a DOM search such as createTreeWalker,
        // which can be computationally expensive.
        // This needs to be done after the text node is created and added to the
        // node store.
        if (chrome.readingMode.isPhraseHighlightingEnabled) {
            this.speechController_.initializeSpeechTree(textNode);
        }
        if (!shouldBold && !isOverline) {
            return textNode;
        }
        const htmlTag = shouldBold ? 'b' : 'span';
        const parentElement = document.createElement(htmlTag);
        if (isOverline) {
            parentElement.style.textDecoration = 'overline';
        }
        parentElement.appendChild(textNode);
        return parentElement;
    }
    updateLinks(shadowRoot) {
        if (!shadowRoot || !this.hasContent()) {
            return;
        }
        const showLinks = this.shouldShowLinks_();
        const selector = showLinks ? LINKS_OFF_SELECTOR : LINKS_ON_TAG;
        const elements = shadowRoot.querySelectorAll(selector);
        for (const elem of elements) {
            this.transformLinkContainer_(elem, showLinks);
        }
    }
    // TODO: crbug.com/458961470- This should be merged with updateLinks. This
    // is being kept as a separate method temporarily in order to make things
    // slightly safer for cherrypicking.
    updateLinksForReadability(root) {
        if (!root || !this.hasContent()) {
            return;
        }
        const showLinks = this.shouldShowLinks_();
        const selector = showLinks ? LINKS_OFF_SELECTOR : LINKS_ON_TAG;
        const elements = root.querySelectorAll(selector);
        for (const elem of elements) {
            this.transformLinkContainer_(elem, showLinks);
        }
        // Ensure the link attributes are set initially when reading mode is
        // first opened.
        if (isDistilledByReadability()) {
            const links = root.querySelectorAll('a');
            for (const link of links) {
                const nodeId = this.nodeStore_.getAxId(link);
                // Use raw href attribute instead of resolved link.href
                const rawHref = link.getAttribute('href') || '';
                this.setLinkAttributes_(link, rawHref, nodeId);
            }
        }
    }
    transformLinkContainer_(elemToReplace, showLinks) {
        const nodeId = this.nodeStore_.getAxId(elemToReplace);
        // If using Readability distillation, we don't expect there to be an
        // AX id for the node, so don't assert that it exists.
        if (!isDistilledByReadability()) {
            assert(nodeId !== undefined, 'link node id is undefined');
        }
        const newTag = showLinks ? LINKS_ON_TAG : LINKS_OFF_TAG;
        const newElem = document.createElement(newTag);
        // Move children to preserve inner highlighting or other formatting.
        while (elemToReplace.firstChild) {
            // appendChild moves the child to the new element, so the next call to
            // elemToReplace.firstChild will get the next child.
            newElem.appendChild(elemToReplace.firstChild);
        }
        // Copy all attributes from the old element to the new one.
        for (const attrName of elemToReplace.getAttributeNames()) {
            // Skip the attributes we are manually changing.
            if (attrName === 'href' ||
                attrName === `${DATA_PREFIX}${LINK_DATA_ATTR}`) {
                continue;
            }
            const attrValue = elemToReplace.getAttribute(attrName);
            newElem.setAttribute(attrName, attrValue);
        }
        // Set the url information on the new element.
        if (showLinks) {
            const url = elemToReplace.dataset[LINK_DATA_ATTR] ?? '';
            this.setLinkAttributes_(newElem, url, nodeId);
        }
        else {
            const url = elemToReplace.getAttribute('href') ?? '';
            newElem.dataset[LINK_DATA_ATTR] = url;
        }
        // Remove the highlighting formatting when showing links, and add it back
        // when hiding links if they were highlighted.
        const originalClass = showLinks ? previousReadHighlightClass : HIGHLIGHTED_LINK_CLASS;
        const newClass = showLinks ? HIGHLIGHTED_LINK_CLASS : previousReadHighlightClass;
        const highlightedNodes = Array.from(newElem.querySelectorAll(`.${originalClass}`));
        if (newElem.classList.contains(originalClass)) {
            highlightedNodes.push(newElem);
        }
        highlightedNodes.forEach(node => {
            node.classList.replace(originalClass, newClass);
        });
        this.nodeStore_.replaceDomNode(elemToReplace, newElem);
    }
    // TODO(crbug.com/40910704): Potentially hide links during distillation.
    shouldShowLinks_() {
        // Links should only show when Read Aloud is paused.
        return chrome.readingMode.linksEnabled &&
            !this.speechController_.isSpeechActive();
    }
    onSelectionChange(shadowRoot) {
        if (!shadowRoot) {
            return;
        }
        const highlightedNodes = shadowRoot.querySelectorAll(`.${HIGHLIGHTED_LINK_CLASS}`);
        highlightedNodes.forEach(node => node.classList.remove(HIGHLIGHTED_LINK_CLASS));
    }
    loadImages() {
        if (!chrome.readingMode.imagesFeatureEnabled) {
            return;
        }
        this.nodeStore_.fetchImages();
    }
    async onImageDownloaded(nodeId) {
        const data = chrome.readingMode.getImageBitmap(nodeId);
        const element = this.nodeStore_.getDomNode(nodeId);
        if (data && element && element instanceof HTMLCanvasElement) {
            element.width = data.width;
            element.height = data.height;
            element.style.zoom = data.scale.toString();
            const context = element.getContext('2d');
            // Context should not be null unless another was already requested.
            assert(context);
            const imgData = new ImageData(data.data, data.width);
            const bitmap = await createImageBitmap(imgData, {
                colorSpaceConversion: 'none',
                premultiplyAlpha: 'premultiply',
            });
            context.drawImage(bitmap, 0, 0);
            this.listeners_.forEach(l => l.onContentChange());
        }
    }
    onRenderedTextBlocksAvailable(container) {
        if (!isDistilledByReadability() ||
            !chrome.readingMode.isReadabilitySelectTextEnabled) {
            return;
        }
        // Capture the specific node instances currently rendered in the UI.
        // We store them in an array so that the index becomes the identifier that
        // links this node to its AXTree mapping in the renderer.
        this.renderedTextNodes_ = getReadingModeTextNodes(container);
        // Extract the raw text content from each node to send to the mapping
        // algorithm in the renderer.
        const blocks = this.renderedTextNodes_.map(n => n.textContent || '');
        chrome.readingMode.onRenderedTextBlocksAvailable(blocks);
    }
    updateImages(shadowRoot) {
        if (!shadowRoot || !this.hasContent()) {
            return;
        }
        const imagesUpdated = isDistilledByReadability() ?
            this.updateImagesForReadability_(shadowRoot) :
            this.updateImagesForAxTree_(shadowRoot);
        if (!imagesUpdated) {
            return;
        }
        this.listeners_.forEach(l => l.onContentChange());
        // Update read aloud state to ensure it's updated for text related to
        // image captions.
        if (this.speechController_.hasSpeechBeenTriggered()) {
            this.speechController_.saveReadAloudState();
            this.speechController_.resetForNewContent();
            const container = shadowRoot.getElementById('container') || shadowRoot;
            this.updateReadAloudState(container);
        }
        else if (!chrome.readingMode.isPhraseHighlightingEnabled) {
            getReadAloudModel().resetModel?.();
        }
    }
    updateAnchorsForReadability(root) {
        if (!chrome.readingMode.isReadabilityEnabled ||
            !isDistilledByReadability()) {
            return;
        }
        if (!root || !this.hasContent()) {
            return;
        }
        const anchors = Array.from(root.querySelectorAll('a'));
        const originalAnchors = chrome.readingMode.axTreeAnchors;
        let successCount = 0;
        let noHrefCount = 0;
        let noMatchCount = 0;
        let tooManyMatchesCount = 0;
        for (const anchor of anchors) {
            // Use raw href attribute to match axTreeAnchors keys correctly
            const url = anchor.getAttribute('href') || '';
            if (!url) {
                noHrefCount++;
                this.transformLinkContainer_(anchor, false);
                continue;
            }
            const options = originalAnchors[url];
            if (!options) {
                noMatchCount++;
                this.transformLinkContainer_(anchor, false);
                continue;
            }
            let matchIndex = null;
            if (options.length === 1) {
                // Use the first item in the list if there is a single anchor for this
                // URL.
                matchIndex = 0;
            }
            else {
                // Otherwise, try to find an anchor that matches.
                matchIndex = this.findStrictMatch_(anchor, options);
            }
            if (matchIndex === null || matchIndex >= options.length) {
                tooManyMatchesCount++;
                // Convert the anchor to text if no match is found.
                this.transformLinkContainer_(anchor, false);
                continue;
            }
            const match = options[matchIndex];
            if (!match) {
                continue;
            }
            const lastIndex = options.length - 1;
            const lastElement = options[lastIndex];
            if (lastElement !== undefined) {
                // If there is a match, remove it from the list of options so it
                // cannot be reused by another anchor.
                options[matchIndex] = lastElement;
                options.pop();
            }
            const nodeID = match.axId;
            this.nodeStore_.setDomNode(anchor, nodeID);
            this.setLinkAttributes_(anchor, url, nodeID);
            successCount++;
        }
        // Only log if there were anchors on the page.
        if (anchors.length > 0) {
            this.logger_.logLinkStatusCount(LinkStatus.SUCCESS, successCount);
            this.logger_.logLinkStatusCount(LinkStatus.NO_HREF, noHrefCount);
            this.logger_.logLinkStatusCount(LinkStatus.NO_MATCH, noMatchCount);
            this.logger_.logLinkStatusCount(LinkStatus.TOO_MANY_MATCHES, tooManyMatchesCount);
        }
    }
    findStrictMatch_(domNode, candidates) {
        let bestCandidateIndex = null;
        let highestScore = -1;
        let tieDetected = false;
        for (let i = 0; i < candidates.length; i++) {
            const metaCandidate = candidates[i];
            if (!metaCandidate) {
                continue;
            }
            const score = this.calculateMatchScore_(domNode, metaCandidate);
            if (score > highestScore) {
                highestScore = score;
                bestCandidateIndex = i;
                tieDetected = false;
            }
            else if (score === highestScore && score > 0) {
                tieDetected = true;
            }
        }
        if (tieDetected) {
            return null;
        }
        return bestCandidateIndex;
    }
    // Calculates a heuristic match score to bind a distilled DOM node to the
    // correct AXNodeID.
    // Note: The score values below are heuristics based on the relative
    // importance of different signals. They can be tuned or changed in the future
    // if needed.
    //
    // High-level scoring hierarchy:
    // 1. HTML ID: Highest confidence (guaranteed match).
    // 2. Visible Text: Strongest indicator of user intent.
    // 3. Surrounding Context: Heavy tie-breaker to disambiguate identical generic
    //    links (e.g., "Read More").
    // 4. Attributes (Title/Target): Micro tie-breakers for edge cases.
    calculateMatchScore_(domNode, axLink) {
        if (!domNode || !axLink) {
            return 0;
        }
        if (axLink.htmlId && domNode.id && axLink.htmlId === domNode.id) {
            // 10,000: Treated as an exact match.
            return 10000;
        }
        let score = 0;
        const domText = (domNode.textContent || '').trim();
        const metaText = (axLink.name || '').trim();
        if (domText && metaText) {
            if (domText === metaText) {
                // +60: Exact text match. Weighted high enough so a perfect text match
                // without context beats a partial match with perfect context
                // (15 + 50 = 65).
                score += 60;
            }
            else if (domText.includes(metaText) || metaText.includes(domText)) {
                // +15: Partial text match. Needs strong context to win.
                score += 15;
            }
        }
        // Context readability: Check adjacent text nodes to disambiguate identical
        // links.
        const domPrev = (domNode.previousSibling?.textContent || '').trim();
        const metaPrev = (axLink.textBefore || '').trim();
        if (metaPrev && domPrev) {
            if (domPrev.endsWith(metaPrev) || metaPrev.endsWith(domPrev)) {
                // +25: Previous sibling match. Heavy tie-breaker.
                score += 25;
            }
        }
        const domNext = (domNode.nextSibling?.textContent || '').trim();
        const metaNext = (axLink.textAfter || '').trim();
        if (metaNext && domNext) {
            if (domNext.startsWith(metaNext) || metaNext.startsWith(domNext)) {
                // +25: Next sibling match. Heavy tie-breaker.
                score += 25;
            }
        }
        // Micro tie-breakers: Low weight to never override primary text/context
        // signals.
        if (axLink.title && domNode.title && axLink.title === domNode.title) {
            score += 7;
        }
        if (axLink.target && domNode.target &&
            axLink.target.toLowerCase() === domNode.target.toLowerCase()) {
            score += 3;
        }
        return score;
    }
    updateImagesForAxTree_(shadowRoot) {
        if (!chrome.readingMode.imagesFeatureEnabled) {
            return false;
        }
        const imagesEnabled = chrome.readingMode.imagesEnabled;
        if (imagesEnabled) {
            this.nodeStore_.clearHiddenImageNodes();
        }
        // There is some strange issue where the HTML css application does not work
        // on canvases.
        const canvases = shadowRoot.querySelectorAll('canvas, figure');
        for (const canvas of canvases) {
            canvas.style.display = imagesEnabled ? '' : 'none';
            this.markTextNodesHiddenIfImagesHidden_(canvas);
        }
        return canvases.length > 0;
    }
    updateImagesForReadability_(container) {
        if (!isDistilledByReadability()) {
            return false;
        }
        // If chrome.readingMode.imagesFeatureEnabled is disabled, hide images also.
        const imagesEnabled = chrome.readingMode.imagesEnabled &&
            chrome.readingMode.imagesFeatureEnabled;
        // We look for 'img' tags (standard HTML) and 'figure' tags like screen2x.
        const images = container.querySelectorAll('img, figure');
        for (const element of images) {
            element.style.display = imagesEnabled ? '' : 'none';
        }
        return images.length > 0;
    }
    async markTextNodesHiddenIfImagesHidden_(node) {
        if (chrome.readingMode.imagesEnabled) {
            return;
        }
        // Do this asynchronously so we don't block the UI on large pages.
        await new Promise(() => {
            setTimeout(() => {
                const id = this.nodeStore_.getAxId(node);
                if (node.nodeType === Node.TEXT_NODE) {
                    if (id) {
                        this.nodeStore_.hideImageNode(id);
                    }
                    return;
                }
                // Since read aloud looks at the text nodes, we want to store those ids
                // so we don't read out text that is not visible.
                const startTreeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ALL);
                while (startTreeWalker.nextNode()) {
                    const id = this.nodeStore_.getAxId(startTreeWalker.currentNode);
                    if (id) {
                        this.nodeStore_.hideImageNode(id);
                    }
                }
            });
        });
    }
    getTrustedHtml(html) {
        if (!ContentController.trustedUpdatePolicy || !isDistilledByReadability()) {
            return window.trustedTypes.emptyHTML;
        }
        return ContentController.trustedUpdatePolicy.createHTML(html);
    }
    configureTrustedTypes() {
        if (!window.trustedTypes || ContentController.trustedUpdatePolicy) {
            return;
        }
        ContentController.trustedUpdatePolicy =
            window.trustedTypes.createPolicy('reader-mode-policy', {
                createHTML: (s) => s,
                createScript: () => '',
                createScriptURL: () => '',
            });
    }
    static getInstance() {
        return instance || (instance = new ContentController());
    }
    static setInstance(obj) {
        instance = obj;
    }
}
let instance = null;
