import { assert } from "../core/assert.js";
import Signal from "../core/events/signal/Signal.js";
import { SignalBinding } from "../core/events/signal/SignalBinding.js";

import AABB2 from "../core/geom/2d/aabb/AABB2.js";

import { m3_cm_compose_transform } from "../core/geom/mat3/m3_cm_compose_transform.js";
import Vector1 from "../core/geom/Vector1.js";
import Vector2 from "../core/geom/Vector2.js";
import { epsilonEquals } from "../core/math/epsilonEquals.js";
import { FLT_EPSILON_32 } from "../core/math/FLT_EPSILON_32.js";
import { setElementVisibility } from "./setElementVisibility.js";
import { writeCssTransformMatrix } from "./writeCssTransformMatrix.js";


/**
 *
 * @enum {number}
 */
export const ViewFlags = {
    Linked: 1,
    Destroyed: 2,
    Visible: 4
};

/**
 * @readonly
 * @type {ViewFlags|number}
 */
const INITIAL_FLAGS = ViewFlags.Visible;

/**
 * Building block for UI elements.
 * Implements a DOM model, built around HTML elements (svg and XML is allowed as well)
 * Base View class.
 * @class
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
class View {

    /**
     * Signal bindings, these will be linked and unlinked along with the view
     * @private
     * @readonly
     * @type {SignalBinding[]}
     */
    bindings = [];

    /**
     *
     * @type {ViewFlags|number}
     */
    flags = INITIAL_FLAGS;

    /**
     * @readonly
     * @type {Vector2}
     */
    position = new Vector2(0, 0);

    /**
     * @readonly
     * @type {Vector1}
     */
    rotation = new Vector1(0);

    /**
     * @readonly
     * @type {Vector2}
     */
    scale = new Vector2(1, 1);

    /**
     * @readonly
     * @type {Vector2}
     */
    size = new Vector2(0, 0);

    /**
     * Origin from which rotation and scaling is applied
     * @readonly
     * @type {Vector2}
     */
    transformOrigin = new Vector2(0.5, 0.5);

    /**
     * @readonly
     */
    on = {
        /**
         * @readonly
         */
        linked: new Signal(),
        /**
         * @readonly
         */
        unlinked: new Signal()
    };

    /**
     *
     * @type {View[]}
     * @readonly
     */
    children = [];

    /**
     *
     * @type {View|null}
     */
    parent = null;


    #transform_written = new Float32Array(9);
    #transform_current = new Float32Array(9);

    /**
     *
     * @type {Element|NodeDescription|null}
     */
    el = null;

    constructor() {

        this.position.onChanged.add(this.__updateTransform, this);
        this.scale.onChanged.add(this.__updateTransform, this);
        this.rotation.onChanged.add(this.__updateTransform, this);
        this.size.onChanged.add(this.__setDimensions, this);

        this.transformOrigin.onChanged.add(this.__setTransformOrigin, this);
    }

    /**
     * @returns {boolean}
     */
    get isLinked() {
        return this.getFlag(ViewFlags.Linked);
    }

    /**
     *
     * @return {boolean}
     */
    get isDestroyed() {
        return this.getFlag(ViewFlags.Destroyed);
    }

    /**
     *
     * @param {boolean} v
     */
    set visible(v) {
        if (v === this.getFlag(ViewFlags.Visible)) {
            //do nothing
            return;
        }

        this.writeFlag(ViewFlags.Visible, v);

        setElementVisibility(this.el, v);
    }

    /**
     *
     * @return {boolean}
     */
    get visible() {
        return this.getFlag(ViewFlags.Visible);
    }

    /**
     *
     * @param {number|ViewFlags} flag
     * @returns {void}
     */
    setFlag(flag) {
        this.flags |= flag;
    }

    /**
     *
     * @param {number|ViewFlags} flag
     * @returns {void}
     */
    clearFlag(flag) {
        this.flags &= ~flag;
    }

    /**
     *
     * @param {number|ViewFlags} flag
     * @param {boolean} value
     */
    writeFlag(flag, value) {
        if (value) {
            this.setFlag(flag);
        } else {
            this.clearFlag(flag);
        }
    }

    /**
     *
     * @param {number|ViewFlags} flag
     * @returns {boolean}
     */
    getFlag(flag) {
        return (this.flags & flag) === flag;
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @private
     */
    __setTransformOrigin(x, y) {
        const style = this.el.style;

        style.transformOrigin = `${x * 100}% ${y * 100}%`;
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @private
     */
    __setDimensions(x, y) {
        const style = this.el.style;

        style.width = x + "px";
        style.height = y + "px";
    }

    /**
     *
     * @private
     */
    __updateTransform() {
        const position = this.position;
        const scale = this.scale;
        const rotation = this.rotation.getValue();

        m3_cm_compose_transform(this.#transform_current, position.x, position.y, scale.x, scale.y, 0, 0, rotation);

        this.#tryWriteTransform();
    }

    #tryWriteTransform() {

        const current = this.#transform_current;
        const written = this.#transform_written;

        for (let i = 0; i < 9; i++) {
            const a = current[i];
            const b = written[i];

            if (epsilonEquals(a, b, FLT_EPSILON_32)) {
                // common path
                continue;
            }

            this.#writeTransform();
            return true;

        }

        return false;
    }

    #writeTransform() {
        writeCssTransformMatrix(this.#transform_current, this.el);

        this.#transform_written.set(this.#transform_current);
    }

    /**
     * intended as initialization point when view becomes linked to the visible tree
     */
    link() {
        if (this.isLinked) {
            //do nothing
            return;
        }

        assert.notOk(this.isDestroyed, 'view is destroyed and may not be re-used');

        this.setFlag(ViewFlags.Linked);

        const signalBindings = this.bindings;
        const bindingCount = signalBindings.length;

        let i;
        for (i = 0; i < bindingCount; i++) {
            const binding = signalBindings[i];

            binding.link();
        }

        //link all children also
        const children = this.children;
        const childCount = children.length;

        for (i = 0; i < childCount; i++) {
            const child = children[i];

            child.link();
        }

        this.on.linked.send0();
    }

    /**
     * Finalization point, release all used resources and cleanup listeners
     */
    unlink() {
        if (!this.isLinked) {
            //do nothing
            return;
        }

        this.clearFlag(ViewFlags.Linked);

        const signalBindings = this.bindings;
        const bindingCount = signalBindings.length;

        let i;
        for (i = 0; i < bindingCount; i++) {
            const binding = signalBindings[i];

            binding.unlink();
        }

        //unlink all children also
        const children = this.children;
        const childCount = children.length;

        for (i = 0; i < childCount; i++) {
            const child = children[i];

            child.unlink();
        }

        this.on.unlinked.send0();
    }

    /**
     *
     * @param {View[]} children
     */
    addChildren(children) {
        for (let i = 0; i < children.length; i++) {
            this.addChild(children[i]);
        }
    }

    /**
     *
     * @param {View} child
     * @returns {View} this
     */
    addChild(child) {
        assert.defined(child, 'child');
        assert.notNull(child, 'child');
        assert.isInstanceOf(child, View, 'child');
        assert.equal(child.isLinked, false, 'child is already linked somewhere');
        assert.notEqual(child, this, 'cannot add self as a child');
        assert.notEqual(this.parent, child, 'cannot add existing parent as a child')

        child.parent = this;

        if (this.isLinked && !child.isLinked) {
            child.link();
        }

        this.children.push(child);
        this.el.appendChild(child.el);


        return this;
    }

    /**
     *
     * @param {Vector2} size
     * @param {number} targetX normalized horizontal position for setting child's position. 0 represents left-most , 1 right-most
     * @param {number} targetY normalized vertical position for setting child's position. 0 represents top-most , 1 bottom-most
     * @param {number} alignmentX
     * @param {number} alignmentY
     * @param {Vector2} result
     */
    computePlacement(size, targetX, targetY, alignmentX, alignmentY, result) {

        const p = new Vector2(targetX, targetY);

        p.multiply(this.size);
        p.add(this.position);

        p._sub(size.x * alignmentX, size.y * alignmentY);

        result.copy(p);
    }

    /**
     *
     * @param {View} child
     * @param {number} targetX normalized horizontal position for setting child's position. 0 represents left-most , 1 right-most
     * @param {number} targetY normalized vertical position for setting child's position. 0 represents top-most , 1 bottom-most
     * @param {number} alignmentX
     * @param {number} alignmentY
     */
    addChildAt(child, targetX, targetY, alignmentX, alignmentY) {
        assert.equal(typeof targetX, "number", `targetX must be of type "number", instead was "${typeof targetX}"`);
        assert.equal(typeof targetY, "number", `targetY must be of type "number", instead was "${typeof targetY}"`);
        assert.equal(typeof alignmentX, "number", `alignmentX must be of type "number", instead was "${typeof alignmentX}"`);
        assert.equal(typeof alignmentY, "number", `alignmentY must be of type "number", instead was "${typeof alignmentY}"`);

        this.computePlacement(child.size, targetX, targetY, alignmentX, alignmentY, child.position);

        this.addChild(child);
        //need to resize the container to ensure future calls work as expected
        this.resizeToFitChildren();
    }

    /**
     *
     * @param {View} child
     * @returns {boolean}
     */
    removeChild(child) {
        const children = this.children;

        const i = children.indexOf(child);

        if (i === -1) {
            //console.warn('Child not found. this:', this, 'child:', child);
            return false;
        }

        children.splice(i, 1);
        this.el.removeChild(child.el);
        child.unlink();

        child.parent = null;

        return true;
    }

    /**
     *
     * @param {View} child
     * @returns {boolean}
     */
    hasChild(child) {
        return this.children.indexOf(child) !== -1;
    }

    removeAllChildren() {
        const children = this.children;
        const numChildren = children.length;
        for (let i = 0; i < numChildren; i++) {
            const child = children.pop();
            this.el.removeChild(child.el);
            child.unlink();

            child.parent = null;
        }
    }

    /**
     *
     * @param {AABB2} aabb
     * @param {number} offsetX
     * @param {number} offsetY
     */
    expandToFit(aabb, offsetX, offsetY) {

        const oX = this.position.x + offsetX;
        const oY = this.position.y + offsetY;

        aabb._expandToFit(oX, oY, oX + this.size.x, oY + this.size.y);

        const children = this.children;

        let i = 0;
        const l = children.length;
        for (; i < l; i++) {
            const child = children[i];
            child.expandToFit(aabb, oX, oY);
        }

    }

    /**
     *
     * @param {AABB2} [result]
     * @returns {AABB2}
     */
    computeBoundingBox(result) {
        if (result === undefined) {
            result = new AABB2();
            result.setNegativelyInfiniteBounds();
        }

        this.expandToFit(result, 0, 0);
        return result;
    }

    resizeToFitChildren() {

        const box = new AABB2(0, 0, this.size.x, this.size.y);
        const children = this.children;

        let i = 0;
        const l = children.length;
        for (; i < l; i++) {
            const child = children[i];
            child.expandToFit(box, 0, 0);
        }

        this.size.set(box.x1, box.y1);
    }

    /**
     *
     * @param {Vector2} input
     * @param {Vector2} result
     * @returns {Vector2} result, same as parameter
     */
    positionLocalToGlobal(input, result) {
        result.copy(input);

        let v = this;
        while (v !== null) {
            result.add(v.position);

            v = v.parent;
        }

        return result;
    }

    /**
     *
     * @param {Vector2} input
     * @param {Vector2} result
     * @returns {Vector2} result, same as parameter
     */
    positionGlobalToLocal(input, result) {
        result.copy(input);

        let v = this;
        while (v !== null) {
            result.sub(v.position);

            v = v.parent;
        }

        return result;
    }

    /**
     *
     * @param {Vector2} result
     */
    computeGlobalScale(result) {
        let v = this;

        let x = 1;
        let y = 1;

        while (v !== null) {
            x *= v.scale.x;
            y *= v.scale.y;

            v = v.parent;
        }

        result.set(x, y);
    }

    destroy() {
        assert.ok(this instanceof View, 'this is not a View');
        assert.notOk(this.isLinked, 'view is linked, linked view may not be destroyed');

        const children = this.children;
        const childrenCount = children.length;

        for (let i = 0; i < childrenCount; i++) {
            const child = children[i];

            //cascade destruction
            child.destroy();
        }

        this.setFlag(ViewFlags.Destroyed);
    }

    /**
     * Will create signal binding that is automatically linked/unlinked along with the view
     * Useful for observing state changes when the view is live
     * @param {Signal} signal
     * @param {function} handler
     * @param {*} [context]
     * @returns {View} returns self, for call chaining
     */
    bindSignal(signal, handler, context) {
        const binding = new SignalBinding(signal, handler, context);
        this.bindings.push(binding);

        if (this.isLinked) {
            binding.link();
        }

        return this;
    }

    /**
     *
     * @param {Signal} signal
     * @param {function} handler
     * @param {*} [context]
     * @returns {boolean} true if binding existed and was removed, false otherwise
     */
    unbindSignal(signal, handler, context) {
        const bindings = this.bindings;
        const numBindings = bindings.length;

        for (let i = 0; i < numBindings; i++) {
            const signalBinding = bindings[i];

            if (
                signalBinding.signal === signal
                && signalBinding.handler === handler
                && (context === undefined || signalBinding.context === context)
            ) {
                bindings.splice(i, 1);

                signalBinding.unlink();

                return true;
            }
        }

        // no match found
        return false;
    }

    /**
     * Add CSS class to View's dom element
     *
     * NOTE: Idempotent
     *
     * @param {string} name
     */
    addClass(name) {
        assert.isString(name, 'name');

        this.el.classList.add(name);
    }

    /**
     * Add multiple CSS calsses to the View's dom element
     * @param {string[]} names
     */
    addClasses(names) {
        const classList = this.el.classList;

        const n = names.length;
        for (let i = 0; i < n; i++) {
            const name = names[i];

            assert.isString(name, 'name');

            classList.add(name);
        }
    }

    /**
     * Remove CSS class from View's dom element
     *
     * NOTE: Idempotent
     *
     * @param {string} name
     */
    removeClass(name) {
        this.el.classList.remove(name);
    }

    /**
     * Remove classes that match the given regular expression
     * @param {RegExp} rx
     * @returns {string[]} removed classes
     */
    removeClassesByPattern(rx) {
        const classList = this.el.classList;

        const classesToRemove = [];

        const initial_class_count = classList.length;

        for (let i = 0; i < initial_class_count; i++) {
            const className = classList[i];

            if (className.search(rx) !== -1) {
                classesToRemove.push(className);
            }
        }


        const n = classesToRemove.length;
        for (let i = 0; i < n; i++) {
            const className = classesToRemove[i];

            classList.remove(className);
        }

        return classesToRemove;
    }

    /**
     * Toggle CSS class of the View's dom element ON or OFF
     *
     * NOTE: Idempotent
     *
     * @param {string} name
     * @param {boolean} flag if true, will add class, if false will remove it
     * @returns {View}
     */
    setClass(name, flag) {
        const classList = this.el.classList;
        classList.toggle(name, flag);
        return this;
    }

    /**
     *
     * @param {Object} hash
     */
    css(hash) {
        for (let propertyName in hash) {
            if (hash.hasOwnProperty(propertyName)) {
                this.el.style[propertyName] = hash[propertyName];
            }
        }
    }

    /**
     *
     * @param {Object} hash
     */
    attr(hash) {
        for (let propertyName in hash) {
            if (hash.hasOwnProperty(propertyName)) {
                this.el.setAttribute(propertyName, hash[propertyName]);
            }
        }
    }

    /**
     *
     * @param {Vector2} size
     * @param {Vector2} [padding]
     */
    followSize({ size, padding = Vector2.zero }) {

        assert.defined(size, 'size');
        assert.equal(size.isVector2, true, 'size.isVector2 !== true');

        assert.defined(padding, 'padding');
        assert.equal(padding.isVector2, true, 'padding.isVector2 !== true');

        const copy = () => {
            this.size.set(size.x - padding.x * 2, size.y - padding.y * 2);
        };

        const link = () => {
            copy();

            size.onChanged.add(copy);
        }

        const unlink = () => {
            size.onChanged.remove(copy);
        };

        this.on.linked.add(link);
        this.on.unlinked.add(unlink);

        copy();
    }
}

/**
 * @readonly
 * @type {boolean}
 */
View.prototype.isView = true;


export default View;
