/*
 * Copyright (C) 2023-2025  Yomitan Authors
 * Copyright (C) 2016-2022  Yomichan Authors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import {toError} from '../core/to-error.js';
import {convertMultipleRectZoomCoordinates, convertRectZoomCoordinates, getElementWritingMode, getNodesInRange, offsetDOMRects} from './document-util.js';
import {DOMTextScanner} from './dom-text-scanner.js';

/**
 * This class represents a text source that comes from text nodes in the document.
 * Sometimes a temporary "imposter" element is created and used to store the text.
 * This element is typically hidden from the page and removed after scanning has completed.
 */
export class TextSourceRange {
    /**
     * Creates a new instance of the class.
     * @param {Range} range The selection range.
     * @param {number} rangeStartOffset The `startOffset` of the range. This is somewhat redundant
     *   with the `range` parameter, but it is used when for when imposter elements are removed.
     * @param {string} content The `toString()` value of the range. This is somewhat redundant
     *   with the `range` parameter, but it is used when for when imposter elements are removed.
     * @param {?Element} imposterElement The temporary imposter element.
     * @param {?Element} imposterSourceElement The source element which the imposter is imitating.
     *   Must not be `null` if imposterElement is specified.
     * @param {?DOMRect[]} cachedRects A set of cached `DOMRect`s representing the rects of the text source,
     *   which can be used after the imposter element is removed from the page.
     *   Must not be `null` if imposterElement is specified.
     * @param {?DOMRect} cachedSourceRect A cached `DOMRect` representing the rect of the `imposterSourceElement`,
     *   which can be used after the imposter element is removed from the page.
     *   Must not be `null` if imposterElement is specified.
     * @param {boolean} disallowExpandSelection
     */
    constructor(range, rangeStartOffset, content, imposterElement, imposterSourceElement, cachedRects, cachedSourceRect, disallowExpandSelection) {
        /** @type {Range} */
        this._range = range;
        /** @type {number} */
        this._rangeStartOffset = rangeStartOffset;
        /** @type {string} */
        this._content = content;
        /** @type {?Element} */
        this._imposterElement = imposterElement;
        /** @type {?Element} */
        this._imposterSourceElement = imposterSourceElement;
        /** @type {?DOMRect[]} */
        this._cachedRects = cachedRects;
        /** @type {?DOMRect} */
        this._cachedSourceRect = cachedSourceRect;
        /** @type {boolean} */
        this._disallowExpandSelection = disallowExpandSelection;
    }

    /**
     * Gets the type name of this instance.
     * @type {'range'}
     */
    get type() {
        return 'range';
    }

    /**
     * The internal range object.
     * @type {Range}
     */
    get range() {
        return this._range;
    }

    /**
     * The starting offset for the range.
     * @type {number}
     */
    get rangeStartOffset() {
        return this._rangeStartOffset;
    }

    /**
     * The source element that the imposter element is imitating, if present.
     * @type {?Element}
     */
    get imposterSourceElement() {
        return this._imposterSourceElement;
    }

    /**
     * Creates a clone of the instance.
     * @returns {TextSourceRange} The new clone.
     */
    clone() {
        return new TextSourceRange(
            this._range.cloneRange(),
            this._rangeStartOffset,
            this._content,
            this._imposterElement,
            this._imposterSourceElement,
            this._cachedRects,
            this._cachedSourceRect,
            this._disallowExpandSelection,
        );
    }

    /**
     * Performs any cleanup that is necessary after the element has been used.
     */
    cleanup() {
        if (this._imposterElement !== null && this._imposterElement.parentNode !== null) {
            this._imposterElement.parentNode.removeChild(this._imposterElement);
        }
    }

    /**
     * Gets the selected text of element, which is the `toString()` version of the range.
     * @returns {string} The text content.
     */
    text() {
        return this._content;
    }

    /**
     * Moves the end offset of the text by a set amount of unicode codepoints.
     * @param {number} length The maximum number of codepoints to move by.
     * @param {boolean} fromEnd Whether to move the offset from the current end position (if `true`) or the start position (if `false`).
     * @param {boolean} layoutAwareScan Whether or not HTML layout information should be used to generate
     *   the string content when scanning.
     * @returns {number} The actual number of codepoints that were read.
     */
    setEndOffset(length, fromEnd, layoutAwareScan) {
        if (this._disallowExpandSelection) { return 0; }
        let node;
        let offset;
        if (fromEnd) {
            node = this._range.endContainer;
            offset = this._range.endOffset;
        } else {
            node = this._range.startContainer;
            offset = this._range.startOffset;
        }
        const state = new DOMTextScanner(node, offset, !layoutAwareScan, layoutAwareScan).seek(length);
        this._range.setEnd(state.node, state.offset);
        const expandedContent = fromEnd ? this._content + state.content : state.content;
        this._content = expandedContent;
        return length - state.remainder;
    }


    /**
     * Moves the start offset of the text backwards by a set amount of unicode codepoints.
     * @param {number} length The maximum number of codepoints to move by.
     * @param {boolean} layoutAwareScan Whether or not HTML layout information should be used to generate
     *   the string content when scanning.
     * @param {boolean} stopAtWordBoundary Whether to stop at whitespace characters.
     * @returns {number} The actual number of codepoints that were read.
     */
    setStartOffset(length, layoutAwareScan, stopAtWordBoundary = false) {
        if (this._disallowExpandSelection) { return 0; }
        let state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan, stopAtWordBoundary);
        state = state.seek(-length);
        this._range.setStart(state.node, state.offset);
        this._rangeStartOffset = this._range.startOffset;
        this._content = state.content + this._content;
        return length - state.remainder;
    }

    /**
     * Gets the rects that represent the position and bounds of the text source.
     * @returns {DOMRect[]} The rects.
     */
    getRects() {
        if (this._isImposterDisconnected()) { return this._getCachedRects(); }
        return convertMultipleRectZoomCoordinates(this._range.getClientRects(), this._range.startContainer);
    }

    /**
     * Gets writing mode that is used for this element.
     * See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
     * @returns {import('document-util').NormalizedWritingMode} The writing mode.
     */
    getWritingMode() {
        let node = this._isImposterDisconnected() ? this._imposterSourceElement : this._range.startContainer;
        if (node !== null && node.nodeType !== Node.ELEMENT_NODE) { node = node.parentElement; }
        return getElementWritingMode(/** @type {?Element} */ (node));
    }

    /**
     * Selects the text source in the document.
     */
    select() {
        if (this._imposterElement !== null) { return; }
        const selection = window.getSelection();
        if (selection === null) { return; }
        selection.removeAllRanges();
        selection.addRange(this._range);
    }

    /**
     * Deselects the text source in the document.
     */
    deselect() {
        if (this._imposterElement !== null) { return; }
        const selection = window.getSelection();
        if (selection === null) { return; }
        selection.removeAllRanges();
    }

    /**
     * Checks whether another text source has the same starting point.
     * @param {import('text-source').TextSource} other The other source to test.
     * @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
     * @throws {Error} An exception can be thrown if `Range.compareBoundaryPoints` fails,
     *   which shouldn't happen, but the handler is kept in case of unexpected errors.
     */
    hasSameStart(other) {
        if (!(
            typeof other === 'object' &&
            other !== null &&
            other instanceof TextSourceRange
        )) {
            return false;
        }
        if (this._imposterSourceElement !== null) {
            return (
                this._imposterSourceElement === other.imposterSourceElement &&
                this._rangeStartOffset === other.rangeStartOffset
            );
        } else {
            try {
                return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
            } catch (e) {
                if (toError(e).name === 'WrongDocumentError') {
                    // This can happen with shadow DOMs if the ranges are in different documents.
                    return false;
                }
                throw e;
            }
        }
    }

    /**
     * Gets a list of the nodes in this text source's range.
     * @returns {Node[]} The nodes in the range.
     */
    getNodesInRange() {
        return getNodesInRange(this._range);
    }

    /**
     * Creates a new instance for a given range.
     * @param {Range} range The source range.
     * @returns {TextSourceRange} A new instance of the class corresponding to the range.
     */
    static create(range) {
        return new TextSourceRange(range, range.startOffset, range.toString(), null, null, null, null, false);
    }

    /**
     * Creates a new instance for a given range without expanding the search.
     * @param {Range} range The source range.
     * @returns {TextSourceRange} A new instance of the class corresponding to the range.
     */
    static createLazy(range) {
        return new TextSourceRange(range, range.startOffset, range.toString(), null, null, null, null, true);
    }

    /**
     * Creates a new instance for a given range using an imposter element.
     * @param {Range} range The source range.
     * @param {Element} imposterElement The temporary imposter element.
     * @param {Element} imposterSourceElement The source element which the imposter is imitating.
     * @returns {TextSourceRange} A new instance of the class corresponding to the range.
     */
    static createFromImposter(range, imposterElement, imposterSourceElement) {
        const cachedRects = convertMultipleRectZoomCoordinates(range.getClientRects(), range.startContainer);
        const cachedSourceRect = convertRectZoomCoordinates(imposterSourceElement.getBoundingClientRect(), imposterSourceElement);
        return new TextSourceRange(range, range.startOffset, range.toString(), imposterElement, imposterSourceElement, cachedRects, cachedSourceRect, false);
    }

    /**
     * Checks whether the imposter element has been removed, if the instance is using one.
     * @returns {boolean} `true` if the instance has an imposter and it's no longer connected to the document, `false` otherwise.
     */
    _isImposterDisconnected() {
        return this._imposterElement !== null && !this._imposterElement.isConnected;
    }

    /**
     * Gets the cached rects for a disconnected imposter element.
     * @returns {DOMRect[]} The rects for the element.
     * @throws {Error}
     */
    _getCachedRects() {
        if (
            this._cachedRects === null ||
            this._cachedSourceRect === null ||
            this._imposterSourceElement === null
        ) {
            throw new Error('Cached rects not valid for this instance');
        }
        const sourceRect = convertRectZoomCoordinates(this._imposterSourceElement.getBoundingClientRect(), this._imposterSourceElement);
        return offsetDOMRects(
            this._cachedRects,
            sourceRect.left - this._cachedSourceRect.left,
            sourceRect.top - this._cachedSourceRect.top,
        );
    }
}
