import { diff } from 'deep-diff';
import isEqual from 'fast-deep-equal';
import { cloneDeep, get, unset, isEmpty, set } from 'lodash';

type StateObject = { id: string } & Record<string, any>

export class VirtualState {
    constructor(
        private previousState: StateObject[] = [],
    ) {
        // Ensure previousState is always an array
        this.previousState = Array.isArray(previousState) ? previousState : [];
    }

    compare(nextState: StateObject[], ignoreProps?: string[]) {
        // Ensure nextState is always an array
        const validNextState = Array.isArray(nextState) ? nextState : [];
        return diffArraysById(this.previousState, validNextState, ignoreProps);
    }

    updateState(nextState: StateObject[]) {
        // Ensure nextState is always an array
        this.previousState = Array.isArray(nextState) ? nextState : [];
    }

    getState() {
        return this.previousState;
    }
}

interface MyObject {
    id: string | number;
    name: string;
    value?: number;
}

export type Changes<T extends { id: string | number }> = {
    added: T[],
    removed: T[],
    modified: { old: T, new: T }[],
    unchanged: T[]
};

const getValidStateObjects = item =>
    item !== null &&
    item !== undefined &&
    typeof item === 'object' &&
    'id' in item

/**
 * Compares two objects while ignoring specified properties
 * Requires lodash's cloneDeep and unset functions
 */
function areObjectsEqualExcludingProps<T>(obj1: T, obj2: T, ignoreProps?: string[]): boolean {
    if (!ignoreProps || ignoreProps.length === 0) {
        return isEqual(obj1, obj2);
    }

    // Clone objects to avoid modifying originals
    const clone1 = cloneDeep(obj1);
    const clone2 = cloneDeep(obj2);

    // Unset the ignored properties and clean up empty parent objects
    for (const prop of ignoreProps) {
        const pathParts = prop.split('.');

        // Unset the specific property first
        unset(clone1, prop);
        unset(clone2, prop);

        // If this is a nested property, check parent paths and remove them if empty
        if (pathParts.length > 1) {
            for (let i = pathParts.length - 2; i >= 0; i--) {
                const parentPath = pathParts.slice(0, i + 1).join('.');
                const parent1 = get(clone1, parentPath);
                const parent2 = get(clone2, parentPath);

                // Remove empty parent objects
                if (parent1 && isEmpty(parent1)) {
                    unset(clone1, parentPath);
                }
                if (parent2 && isEmpty(parent2)) {
                    unset(clone2, parentPath);
                }
            }
        }
    }

    // Compare with standard equality
    return isEqual(clone1, clone2);
}

function diffArraysById<T extends { id: string | number }>(
    array1: T[],
    array2: T[],
    ignoreProps?: string[]
): Changes<T> {
    // Filter out null, undefined, non-objects, and objects without id
    const filteredArray1 = array1.filter(getValidStateObjects) as T[];

    const filteredArray2 = array2.filter(getValidStateObjects) as T[];

    const map1 = new Map(filteredArray1.map(item => [item.id, item]));
    const map2 = new Map(filteredArray2.map(item => [item.id, item]));

    const added: T[] = [];
    const removed: T[] = [];
    const modified: { old: T, new: T }[] = [];
    const unchanged: T[] = [];

    // Check for removed and potentially modified/unchanged
    for (const [id, item1] of map1.entries()) {
        const item2 = map2.get(id);
        if (item2) {
            // Exists in both, check for modification using our helper function
            if (!areObjectsEqualExcludingProps(item1, item2, ignoreProps)) {
                // Create a new version of item2, initially a clone
                const modifiedItem2 = cloneDeep(item2);

                // Remove ignored properties from the 'new' representation
                if (ignoreProps && ignoreProps.length > 0) {
                    for (const prop of ignoreProps) {
                        // Check if this is a property that was added in the new object
                        // and not present in the old one - in that case we need to remove it
                        if (!get(item1, prop) && get(modifiedItem2, prop) !== undefined) {
                            unset(modifiedItem2, prop);
                        } else if (get(item1, prop) !== undefined) {
                            // If property exists in old object, copy its value to new object
                            // to make it appear as if it didn't change
                            set(modifiedItem2, prop, get(item1, prop));
                        }

                        // Clean up potentially empty parent objects created by unsetting nested props
                        const pathParts = prop.split('.');
                        if (pathParts.length > 1) {
                            for (let i = pathParts.length - 2; i >= 0; i--) {
                                const parentPath = pathParts.slice(0, i + 1).join('.');
                                const parent = get(modifiedItem2, parentPath);
                                if (parent && isEmpty(parent)) {
                                    unset(modifiedItem2, parentPath);
                                }
                            }
                        }
                    }
                }

                modified.push({ old: item1, new: modifiedItem2 });
            } else {
                unchanged.push(item1); // Use original object
            }
            // Mark as processed to avoid re-checking when iterating map2
            map2.delete(id);
        } else {
            // Only in map1 -> removed
            removed.push(item1);
        }
    }

    // Any remaining items in map2 are new additions
    for (const item2 of map2.values()) {
        added.push(item2);
    }

    return { added, removed, modified, unchanged };
}
/*
// --- Example Usage ---
const array1: MyObject[] = [
    { id: 1, name: 'Apple', value: 10 },
    { id: 2, name: 'Banana', value: 20 },
    { id: 3, name: 'Cherry' },
];

const array2: MyObject[] = [
    { id: 1, name: 'Apple', value: 15 }, // Modified
    { id: 3, name: 'Cherry' },           // Unchanged
    { id: 4, name: 'Date' },             // Added
];


const changes = diffArraysById(array1, array2);
console.log("Changes:", changes);*/
/*
Changes: {
  added: [ { id: 4, name: 'Date' } ],
  removed: [ { id: 2, name: 'Banana', value: 20 } ],
  modified: [
    {
      old: { id: 1, name: 'Apple', value: 10 },
      new: { id: 1, name: 'Apple', value: 15 }
    }
  ],
  unchanged: [ { id: 3, name: 'Cherry' } ]
}
*/

// Installs:
// npm install fast-deep-equal
// yarn add fast-deep-equal

