import { assert } from "../../assert.js";
import Signal from "../../events/signal/Signal.js";
import { objectsEqual } from "../../function/objectsEqual.js";
import { invokeObjectEquals } from "../../model/object/invokeObjectEquals.js";
import { array_index_by_equality } from "../array/array_index_by_equality.js";
import { array_set_diff } from "../array/array_set_diff.js";


/**
 *
 * List structure with event signals for observing changes.
 * @template T
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
class List {
    /**
     * @readonly
     */
    on = {
        /**
         * @readonly
         * @type {Signal<T,number>}
         */
        added: new Signal(),
        /**
         * @readonly
         * @type {Signal<T,number>}
         */
        removed: new Signal(),

        /**
         * Captures both {@link #on.added} and {@link #on.removed} signals.
         * Useful shortcut if you don't care about actual modifications, just the fact that something has changed.
         * @readonly
         * @type {Signal<>}
         */
        get changed() {
            // lazily initialization, don't want to create the signal and capture events if we don't need it

            const signal = new Signal();

            // pipe changes to an aggregate signal
            const h = () => signal.send0();

            this.added.add(h);
            this.removed.add(h);

            // rewrite the property to return the aggregate signal
            Object.defineProperty(this, 'changed', {
                value: signal,
                writable: false,
                enumerable: true,
                configurable: false
            });

            return signal;
        }
    };


    /**
     * @readonly
     * @type {T[]}
     */
    data = []

    /**
     * @param {T[]} [array]
     */
    constructor(array) {

        if (array !== undefined) {
            assert.isArray(array, 'array');

            /**
             * @private
             * @readonly
             * @type {T[]}
             */
            this.data = array.slice();
        }

        /**
         * Number of elements in the list
         * @type {number}
         */
        this.length = this.data.length;

    }

    /**
     * Retrieve element at a given position in the list
     * @param {number} index
     * @returns {T|undefined}
     */
    get(index) {
        assert.isNumber(index, 'index');
        assert.isNonNegativeInteger(index, 'index');

        return this.data[index];
    }

    /**
     * Set element at a given position inside the list
     * @param {number} index
     * @param {T} value
     */
    set(index, value) {
        assert.isNumber(index, 'index');
        assert.isNonNegativeInteger(index, 'index');

        const oldValue = this.data[index];

        if (oldValue !== undefined) {
            this.on.removed.send2(oldValue, index)
        } else {
            if (index >= this.length) {

                this.length = index + 1;

                if (index > this.length) {
                    console.error(`Overflow, attempted to set element at ${index} past the list length(=${this.length})`);
                }

            }
        }

        this.data[index] = value;

        this.on.added.send2(value, index);
    }

    /**
     *
     * @param {T} el
     * @returns {this}
     */
    add(el) {
        this.data.push(el);
        const oldLength = this.length;

        this.length = oldLength + 1;

        this.on.added.send2(el, oldLength);
        return this;
    }

    /**
     * Only add element if it doesn't already exist in the list
     * Useful when you wish to ensure uniqueness of elements inside the list
     * Note that this operation is rather slow as it triggers a linear scan
     * If the list gets large - consider using a {@link Set} class instead
     * @param {T} el
     * @return {boolean} true if element was added, false if element already existed in the list
     */
    addUnique(el) {
        if (this.contains(el)) {
            return false;
        }

        this.add(el);

        return true;
    }

    /**
     * Insert element at a specific position into the list
     * This operation will result in a larger list
     * @param {number} index
     * @param {T} el
     * @returns {List}
     * @throws {Error} when trying to insert past list end
     */
    insert(index, el) {

        if (index > this.length) {
            console.error(`Overflow, attempted to insert element at ${index} past the list length(=${this.length})`);
            this.length = index;

            this.data[index] = el;
        } else {
            this.data.splice(index, 0, el);
        }

        this.length++;

        this.on.added.send2(el, index);

        return this;
    }

    /**
     * Reduces the list to a subsection but removing everything before startIndex and everything after endIndex
     * @param {int} startIndex
     * @param {int} endIndex up to this index, not including it
     * @returns {number}
     */
    crop(startIndex, endIndex) {

        const data = this.data;

        const tail = data.splice(endIndex, this.length - endIndex);

        const head = data.splice(0, startIndex);

        this.length = endIndex - startIndex;


        const headLength = head.length;

        const tailLength = tail.length;

        const onRemoved = this.on.removed;

        if (onRemoved.hasHandlers()) {
            let i;

            for (i = 0; i < headLength; i++) {
                onRemoved.send2(head[i], i);
            }


            for (i = 0; i < tailLength; i++) {
                onRemoved.send2(tail[i], endIndex + i);
            }
        }

        //return number of dropped elements
        return headLength + tailLength;
    }

    /**
     * Replace the data, replacements is performed surgically, meaning that diff is computed and add/remove operations are performed on the set
     * This method is tailored to work well with visualisation as only elements that's missing from the new set is removed, and only elements that are new to are added
     * Conversely, relevant events are dispatched that can observe. This results in fewer changes required to the visualisation
     * @param {T[]} new_data
     */
    patch(new_data) {

        const data = this.data;

        const diff = array_set_diff(data, new_data, objectsEqual);

        //resolve diff
        const removals = diff.uniqueA;

        const removalCount = removals.length;

        for (let i = 0; i < removalCount; i++) {
            const item = removals[i];

            this.removeOneOf(item);
        }

        const additions = diff.uniqueB;

        //sort additions by their position in the input
        additions.sort((a, b) => {
            const ai = new_data.indexOf(a);
            const bi = new_data.indexOf(b);

            return bi - ai;
        });

        const additionsCount = additions.length;

        for (let i = 0; i < additionsCount; i++) {
            const item = additions[i];

            //find where the item is in the input
            const inputIndex = new_data.indexOf(item);

            let p = 0;
            for (let i = inputIndex - 1; i >= 0; i--) {
                const prev = new_data[i];

                //look for previous item in the output
                const j = this.indexOf(prev);

                if (j !== -1) {
                    p = j + 1;
                    break;
                }
            }

            this.insert(p, item);
        }

    }

    /**
     *
     * @param {Array.<T>} elements
     */
    addAll(elements) {
        const addedElementsCount = elements.length;

        const added = this.on.added;

        if (added.hasHandlers()) {
            //only signal if there are listeners attached
            for (let i = 0; i < addedElementsCount; i++) {
                const element = elements[i];
                this.data.push(element);
                added.send2(element, this.length++);
            }
        } else {
            //no observers, we can add elements faster
            Array.prototype.push.apply(this.data, elements);
            this.length += addedElementsCount;
        }
    }

    /**
     *
     * @param {Array.<T>} elements
     * @see addUnique
     * @see addAll
     */
    addAllUnique(elements) {
        const length = elements.length;

        for (let i = 0; i < length; i++) {
            this.addUnique(elements[i]);
        }
    }

    /**
     *
     * @param {number} index
     * @param {number} removeCount
     * @returns {T[]}
     */
    removeMany(index, removeCount) {
        assert.isNonNegativeInteger(index, "index");
        assert.ok(index < this.length || index < 0, `index(=${index}) out of range (${this.length})`);

        const removed_elements = this.data.splice(index, removeCount);

        const removedCount = removed_elements.length;

        this.length -= removedCount;

        assert.equal(this.length, this.data.length, `length(=${this.length}) is inconsistent with underlying data array length(=${this.data.length})`)

        const onRemoved = this.on.removed;

        if (onRemoved.hasHandlers()) {
            for (let i = 0; i < removedCount; i++) {
                const element = removed_elements[i];

                onRemoved.send2(element, index + i);
            }
        }

        return removed_elements;
    }

    /**
     *
     * @param {number} index
     * @returns {T}
     */
    remove(index) {
        assert.equal(typeof index, 'number', `index must a number, instead was '${typeof index}'`);
        assert.ok(index < this.length || index < 0, `index(=${index}) out of range (${this.length})`);

        const els = this.data.splice(index, 1);

        this.length--;

        assert.equal(this.length, this.data.length, `length(=${this.length}) is inconsistent with underlying data array length(=${this.data.length})`)

        const element = els[0];

        this.on.removed.send2(element, index);

        return element;
    }

    /**
     *
     * @param {T[]} elements
     * @returns {boolean} True is all specified elements were found and removed, False if some elements were not present in the list
     */
    removeAll(elements) {
        let i, il;
        let j, jl;

        il = elements.length;
        jl = this.length;

        let missCount = 0;

        const data = this.data;

        main_loop: for (i = 0; i < il; i++) {

            const expected = elements[i];

            for (j = jl - 1; j >= 0; j--) {

                const actual = data[j];

                if (objectsEqual(actual, expected)) {

                    this.remove(j);

                    jl--;


                    continue main_loop;

                }

            }


            missCount++;
        }

        //some elements were not deleted
        return missCount === 0;
    }

    /**
     *
     * @param {T} value
     * @return {boolean}
     */
    removeOneOf(value) {
        if (typeof value === "object" && typeof value.equals === "function") {
            return this.removeOneIf(conditionEqualsViaMethod, value);
        } else {
            return this.removeOneIf(conditionEqualsStrict, value);
        }
    }

    /**
     *
     * @param {function(a:T, b:T):number} [compare_function]
     * @returns {this}
     */
    sort(compare_function) {
        Array.prototype.sort.call(this.data, compare_function);
        return this;
    }

    /**
     * Copy of this list
     * The copy is shallow
     * @returns {List.<T>}
     */
    clone() {
        return new List(this.data);
    }

    /**
     * Returns a shallow copy array with elements in range from start to end (end not included)
     * Same as {@link Array.prototype.slice}
     * @param {number} [start]
     * @param {number} [end]
     * @return {T[]}
     */
    slice(start, end) {
        return this.data.slice(start, end);
    }

    /**
     *
     * @param {function(element:T):boolean} condition
     * @returns {boolean}
     */
    some(condition) {
        const l = this.length;
        const data = this.data;
        for (let i = 0; i < l; i++) {
            if (condition(data[i])) {
                return true;
            }
        }
        return false;
    }

    /**
     *
     * @param {function(T):boolean} condition must return boolean value
     * @param {*} [thisArg]
     * @see removeOneIf
     */
    removeIf(condition, thisArg) {
        assert.isFunction(condition, 'condition');

        let l = this.length;
        const data = this.data;
        for (let i = 0; i < l; i++) {
            const element = data[i];

            if (condition.call(thisArg, element)) {
                this.remove(i);
                i--;
                l--;
            }
        }
    }

    /**
     *
     * @param {function(T):boolean} condition
     * @param {*} [thisArg]
     * @return {boolean}
     * @see removeIf
     */
    removeOneIf(condition, thisArg) {
        const l = this.length;
        const data = this.data;

        for (let i = 0; i < l; i++) {
            const element = data[i];

            if (condition.call(thisArg, element)) {
                this.remove(i);
                return true;
            }
        }

        return false;
    }

    /**
     * INVARIANT: List length must not change during the traversal
     * @param {function(el:T, index:number):?} f
     * @param {*} [thisArg]
     */
    forEach(f, thisArg) {
        const l = this.length;
        const data = this.data;

        for (let i = 0; i < l; i++) {

            f.call(thisArg, data[i], i);

        }
    }

    /**
     * @param {function(*,T):*} f
     * @param {*} initial
     * @returns {*}
     */
    reduce(f, initial) {
        let t = initial;
        this.forEach(function (v) {
            t = f(t, v);
        });
        return t;
    }

    /**
     *
     * @param {function(T):boolean} f
     * @returns {Array.<T>}
     */
    filter(f) {
        return this.data.filter(f);
    }

    /**
     *
     * @param {function(el:T):boolean} matcher
     * @returns {T|undefined}
     */
    find(matcher) {
        const data = this.data;
        let i = 0;
        const l = this.length;
        for (; i < l; i++) {
            const el = data[i];
            if (matcher(el)) {
                return el;
            }
        }

        return undefined;
    }

    /**
     *
     * @param {function(T):boolean} matcher
     * @returns {number} Index of the first match or -1
     */
    findIndex(matcher) {
        const data = this.data;

        let i = 0;

        const l = this.length;

        for (; i < l; i++) {
            const el = data[i];
            if (matcher(el)) {
                return i;
            }
        }

        return -1;
    }

    /**
     *
     * @param {function(el:T):boolean} matcher
     * @param {function(el:T, index:number):*} callback
     * @returns {boolean}
     */
    visitFirstMatch(matcher, callback) {
        const index = this.findIndex(matcher);

        if (index === -1) {
            return false;
        }

        const el = this.data[index];

        if (matcher(el)) {
            callback(el, index);
            return true;
        } else {
            return false;
        }

    }

    /**
     *
     * @param {T} v
     * @returns {boolean}
     */
    contains(v) {
        return this.data.indexOf(v) !== -1;
    }

    /**
     * Does the list contain at least one of the given options?
     * @param {T[]} options
     * @returns {boolean}
     */
    containsAny(options) {
        const n = options.length;

        for (let i = 0; i < n; i++) {
            const option = options[i];
            if (this.contains(option)) {
                return true;
            }
        }

        return false;
    }

    /**
     * List has no elements
     * @returns {boolean}
     */
    isEmpty() {
        return this.length <= 0;
    }

    /**
     *
     * @param {T} el
     * @returns {number}
     */
    indexOf(el) {
        return this.data.indexOf(el);
    }

    /**
     * @template R
     * @param {function(T):R} callback
     * @param {*} [thisArg]
     * @returns {R[]}
     */
    map(callback, thisArg) {
        const result = [];
        const data = this.data;
        const l = this.length;

        for (let i = 0; i < l; i++) {

            const datum = data[i];
            if (datum !== undefined) {
                result[i] = callback.call(thisArg, datum, i);
            }
        }

        return result;
    }

    /**
     * @deprecated use `#reset` directly in combination with `this.on.removed` signal
     * @param {function(element:T,index:number)} callback
     * @param {*} [thisArg]
     */
    resetViaCallback(callback, thisArg) {
        throw new Error('deprecated');
    }

    /**
     * Clears the list and removes all elements
     */
    reset() {
        const length = this.length;
        if (length > 0) {

            const removed = this.on.removed;

            if (removed.hasHandlers()) {

                const oldElements = this.data;

                //only signal if there are listeners attached
                for (let i = length - 1; i >= 0; i--) {
                    const element = oldElements[i];
                    // decrement data length gradually to allow handlers access to the rest of the elements
                    this.data.length = i;
                    this.length = i;
                    removed.send2(element, i);
                }

            } else {
                this.data = [];
                this.length = 0;
            }

        }
    }

    /**
     * Note that elements must have .clone method for this to work
     * @param {List<T>} other
     * @param {function} [removeCallback]
     * @param {*} [thisArg] Used on removeCallback
     */
    deepCopy(other, removeCallback, thisArg) {
        assert.notEqual(other, undefined, 'other is undefined');
        assert.notEqual(other, null, 'other is null');

        const newData = [];

        const otherItems = other.asArray();
        const nOtherItems = otherItems.length;

        const thisItems = this.data;
        let nThisItems = this.length;

        //walk existing elements and see what can be kept
        for (let i = 0; i < nThisItems; i++) {
            const a = thisItems[i];

            const index = array_index_by_equality(otherItems, a, invokeObjectEquals);

            if (index !== -1) {
                newData[index] = a;
            } else if (typeof removeCallback === "function") {
                removeCallback.call(thisArg, a);
            }
        }

        //fill in the blanks
        for (let i = 0; i < nOtherItems; i++) {
            if (newData[i] === undefined) {
                const otherItem = otherItems[i];
                newData[i] = otherItem.clone();
            }
        }

        this.reset();
        this.addAll(newData);
    }

    /**
     *
     * @param {List<T>|T[]} other
     */
    copy(other) {
        if (this === other) {
            // no point
            return;
        }


        this.reset();
        if (other.length > 0) {
            if (other instanceof List) {
                this.addAll(other.data);
            } else {
                this.addAll(other);
            }
        }
    }

    /**
     * NOTE: do not modify resulting array
     * @returns {T[]}
     */
    asArray() {
        return this.data;
    }

    toJSON() {
        return JSON.parse(JSON.stringify(this.data));
    }

    /**
     * @template J
     * @param {J[]} json
     * @param {function} constructor
     */
    fromJSON(json, constructor) {
        this.reset();

        assert.isArray(json, 'json');

        if (typeof constructor === "function") {
            this.addAll(json.map(function (elJSON) {
                const el = new constructor();
                el.fromJSON(elJSON);
                return el;
            }));
        } else {
            this.addAll(json);
        }
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     */
    toBinaryBuffer(buffer) {
        const n = this.length;

        buffer.writeUint32(n);

        for (let i = 0; i < n; i++) {
            const item = this.data[i];

            if (typeof item.toBinaryBuffer !== "function") {
                throw new Error('item.toBinaryBuffer is not a function');
            }

            item.toBinaryBuffer(buffer);
        }
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param constructor
     */
    fromBinaryBuffer(buffer, constructor) {
        this.fromBinaryBufferViaFactory(buffer, function (buffer) {

            const el = new constructor();

            if (typeof el.fromBinaryBuffer !== "function") {
                throw new Error('item.fromBinaryBuffer is not a function');
            }

            el.fromBinaryBuffer(buffer);

            return el;
        });
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param {function(buffer:BinaryBuffer)} factory
     */
    fromBinaryBufferViaFactory(buffer, factory) {
        this.reset();

        this.addFromBinaryBufferViaFactory(buffer, factory);
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param {function(buffer:BinaryBuffer)} factory
     */
    addFromBinaryBufferViaFactory(buffer, factory) {
        const length = buffer.readUint32();

        for (let i = 0; i < length; i++) {
            const el = factory(buffer);

            this.add(el);
        }
    }

    /**
     * @template J
     * @param {J[]} data
     * @param {function(J):T} factory
     */
    addFromJSONViaFactory(data, factory) {
        const n = data.length;
        for (let i = 0; i < n; i++) {
            const datum = data[i];

            const el = factory(datum);

            this.add(el);
        }
    }

    /**
     * NOTE: Elements must have `hash` method for this to work
     *
     * @returns {number}
     */
    hash() {
        const length = this.length;
        let hash = length;

        for (let i = 0; i < length; i++) {
            const datum = this.data[i];

            const singleValue = datum.hash();

            hash = ((hash << 5) - hash) + singleValue;
            hash |= 0; // Convert to 32bit integer
        }

        return hash;
    }

    /**
     * First element in the list
     * @returns {T|undefined}
     */
    first() {
        return this.get(0);
    }

    /**
     * Last element in the list
     * @return {T|undefined}
     */
    last() {
        return this.get(this.length - 1);
    }

    /**
     * Perform element-wise equality comparison with another list
     * @param {List} other
     * @returns {boolean}
     */
    equals(other) {
        assert.defined(other, 'other');

        const length = this.length;

        if (length !== other.length) {
            return false;
        }

        let i;

        for (i = 0; i < length; i++) {
            const a = this.get(i);

            const b = other.get(i);

            if (a === b) {
                continue;
            }

            if (typeof a === "object" && typeof b === "object" && typeof a.equals === "function" && a.equals(b)) {
                //test via "equals" method
                continue;
            }

            //elements not equal
            return false;
        }

        return true;
    }

    /**
     *
     * @param {List<T>} other
     * @returns {number}
     */
    compare(other) {
        const l = this.length;

        const other_length = other.length;

        if (l !== other_length) {
            return l - other_length;
        }

        for (let i = 0; i < l; i++) {
            const a = this.get(i);

            const b = other.get(i);

            const delta = a.compare(b);

            if (delta !== 0) {
                return delta;
            }

        }

        // same
        return 0;
    }
}

function conditionEqualsViaMethod(v) {
    return this === v || this.equals(v);
}

function conditionEqualsStrict(v) {
    return this === v;
}


export default List;
