/*
 * Copyright (C) 2023-2024  Yomitan Authors
 * Copyright (C) 2020-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/>.
 */

/**
 * Class which is used to observe elements matching a selector in specific element.
 * @template [T=unknown]
 */
export class SelectorObserver {
    /**
     * Creates a new instance.
     * @param {import('selector-observer').ConstructorDetails<T>} details The configuration for the object.
     */
    constructor({selector, ignoreSelector = null, onAdded = null, onRemoved = null, onChildrenUpdated = null, isStale = null}) {
        /** @type {string} */
        this._selector = selector;
        /** @type {?string} */
        this._ignoreSelector = ignoreSelector;
        /** @type {?import('selector-observer').OnAddedCallback<T>} */
        this._onAdded = onAdded;
        /** @type {?import('selector-observer').OnRemovedCallback<T>} */
        this._onRemoved = onRemoved;
        /** @type {?import('selector-observer').OnChildrenUpdatedCallback<T>} */
        this._onChildrenUpdated = onChildrenUpdated;
        /** @type {?import('selector-observer').IsStaleCallback<T>} */
        this._isStale = isStale;
        /** @type {?Element} */
        this._observingElement = null;
        /** @type {MutationObserver} */
        this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
        /** @type {Map<Node, import('selector-observer').Observer<T>>} */
        this._elementMap = new Map(); // Map([element => observer]...)
        /** @type {Map<Node, Set<import('selector-observer').Observer<T>>>} */
        this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...)
        /** @type {boolean} */
        this._isObserving = false;
    }

    /**
     * Returns whether or not an element is currently being observed.
     * @returns {boolean} `true` if an element is being observed, `false` otherwise.
     */
    get isObserving() {
        return this._observingElement !== null;
    }

    /**
     * Starts DOM mutation observing the target element.
     * @param {Element} element The element to observe changes in.
     * @param {boolean} [attributes] A boolean for whether or not attribute changes should be observed.
     * @throws {Error} An error if element is null.
     * @throws {Error} An error if an element is already being observed.
     */
    observe(element, attributes = false) {
        if (element === null) {
            throw new Error('Invalid element');
        }
        if (this.isObserving) {
            throw new Error('Instance is already observing an element');
        }

        this._observingElement = element;
        this._mutationObserver.observe(element, {
            attributes: !!attributes,
            childList: true,
            subtree: true
        });

        const {parentNode} = element;
        this._onMutation([{
            type: 'childList',
            target: parentNode !== null ? parentNode : element,
            addedNodes: [element],
            removedNodes: []
        }]);
    }

    /**
     * Stops observing the target element.
     */
    disconnect() {
        if (!this.isObserving) { return; }

        this._mutationObserver.disconnect();
        this._observingElement = null;

        for (const observer of this._elementMap.values()) {
            this._removeObserver(observer);
        }
    }

    /**
     * Returns an iterable list of [element, data] pairs.
     * @yields {[element: Element, data: T]} A sequence of [element, data] pairs.
     * @returns {Generator<[element: Element, data: T], void, unknown>}
     */
    *entries() {
        for (const {element, data} of this._elementMap.values()) {
            yield [element, data];
        }
    }

    /**
     * Returns an iterable list of data for every element.
     * @yields {T} A sequence of data values.
     * @returns {Generator<T, void, unknown>}
     */
    *datas() {
        for (const {data} of this._elementMap.values()) {
            yield data;
        }
    }

    // Private

    /**
     * @param {(MutationRecord|import('selector-observer').MutationRecordLike)[]} mutationList
     */
    _onMutation(mutationList) {
        for (const mutation of mutationList) {
            switch (mutation.type) {
                case 'childList':
                    this._onChildListMutation(mutation);
                    break;
                case 'attributes':
                    this._onAttributeMutation(mutation);
                    break;
            }
        }
    }

    /**
     * @param {MutationRecord|import('selector-observer').MutationRecordLike} record
     */
    _onChildListMutation({addedNodes, removedNodes, target}) {
        const selector = this._selector;
        const ELEMENT_NODE = Node.ELEMENT_NODE;

        for (const node of removedNodes) {
            const observers = this._elementAncestorMap.get(node);
            if (typeof observers === 'undefined') { continue; }
            for (const observer of observers) {
                this._removeObserver(observer);
            }
        }

        for (const node of addedNodes) {
            if (node.nodeType !== ELEMENT_NODE) { continue; }
            if (/** @type {Element} */ (node).matches(selector)) {
                this._createObserver(/** @type {Element} */ (node));
            }
            for (const childNode of /** @type {Element} */ (node).querySelectorAll(selector)) {
                this._createObserver(childNode);
            }
        }

        if (
            this._onChildrenUpdated !== null &&
            (addedNodes.length !== 0 || addedNodes.length !== 0)
        ) {
            for (let node = /** @type {?Node} */ (target); node !== null; node = node.parentNode) {
                const observer = this._elementMap.get(node);
                if (typeof observer !== 'undefined') {
                    this._onObserverChildrenUpdated(observer);
                }
            }
        }
    }

    /**
     * @param {MutationRecord|import('selector-observer').MutationRecordLike} record
     */
    _onAttributeMutation({target}) {
        const selector = this._selector;
        const observers = this._elementAncestorMap.get(/** @type {Element} */ (target));
        if (typeof observers !== 'undefined') {
            for (const observer of observers) {
                const element = observer.element;
                if (
                    !element.matches(selector) ||
                    this._shouldIgnoreElement(element) ||
                    this._isObserverStale(observer)
                ) {
                    this._removeObserver(observer);
                }
            }
        }

        if (/** @type {Element} */ (target).matches(selector)) {
            this._createObserver(/** @type {Element} */ (target));
        }
    }

    /**
     * @param {Element} element
     */
    _createObserver(element) {
        if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; }

        const data = this._onAdded(element);
        if (typeof data === 'undefined') { return; }
        const ancestors = this._getAncestors(element);
        const observer = {element, ancestors, data};

        this._elementMap.set(element, observer);

        for (const ancestor of ancestors) {
            let observers = this._elementAncestorMap.get(ancestor);
            if (typeof observers === 'undefined') {
                observers = new Set();
                this._elementAncestorMap.set(ancestor, observers);
            }
            observers.add(observer);
        }
    }

    /**
     * @param {import('selector-observer').Observer<T>} observer
     */
    _removeObserver(observer) {
        const {element, ancestors, data} = observer;

        this._elementMap.delete(element);

        for (const ancestor of ancestors) {
            const observers = this._elementAncestorMap.get(ancestor);
            if (typeof observers === 'undefined') { continue; }

            observers.delete(observer);
            if (observers.size === 0) {
                this._elementAncestorMap.delete(ancestor);
            }
        }

        if (this._onRemoved !== null) {
            this._onRemoved(element, data);
        }
    }

    /**
     * @param {import('selector-observer').Observer<T>} observer
     */
    _onObserverChildrenUpdated(observer) {
        if (this._onChildrenUpdated === null) { return; }
        this._onChildrenUpdated(observer.element, observer.data);
    }

    /**
     * @param {import('selector-observer').Observer<T>} observer
     * @returns {boolean}
     */
    _isObserverStale(observer) {
        return (this._isStale !== null && this._isStale(observer.element, observer.data));
    }

    /**
     * @param {Element} element
     * @returns {boolean}
     */
    _shouldIgnoreElement(element) {
        return (this._ignoreSelector !== null && element.matches(this._ignoreSelector));
    }

    /**
     * @param {Node} node
     * @returns {Node[]}
     */
    _getAncestors(node) {
        const root = this._observingElement;
        const results = [];
        let n = /** @type {?Node} */ (node);
        while (n !== null) {
            results.push(n);
            if (n === root) { break; }
            n = n.parentNode;
        }
        return results;
    }
}