import {useShallow} from "zustand/react/shallow";
import {
    ComponentDataType,
    findDataForNodeWithoutChildren,
    NodeData,
    NodesStore,
    TestableCompositeData,
    TestableCompositeDataWithChildren,
    TestableData,
    TestablePartial,
    useNodesStore
} from "./store";
import {__} from "../globals";
import {generate} from "shortid";
import {cloneDeep, isUndefined, merge, omit, pickBy} from "lodash";
import {ComponentRuntimeDataWithParentType} from "./components";
import {NestedTestableNodeData} from "./InMemoryTestableNode";
import mitt from "mitt";
import {useLazyTestables} from "./hooks";
import {ComponentType, getComponentMeta, getComponentMetaData} from "./ComponentsMeta";
import {exporterImporters} from "./export/CustomImportersAndExporters";
import {
    isNonContextTestableGroupType,
    normalizeTestableGroupMode,
    TestableGroupTypeId
} from "./testableGroupTypes";

export const useChildrenIds: (parentId: TestableCompositeData['id'] | TestableCompositeDataWithChildren) => (TestableCompositeData['id'] | TestableCompositeData)[] = parentID => {
    return useNodesStore(
        useShallow(store => {
            if (typeof parentID === 'object') {
                return parentID.children || []
            }

            return store.testableRelations.find(node => node.id === parentID)?.children
        })
    ) || []
}

export const useTestableTypeFromContext: (contextID: TestableCompositeData['id'] | TestableCompositeDataWithChildren) => Exclude<ComponentDataType, 'offer'> = (contextID) => {
    // get the first child and return its component type
    const children = useChildrenIds(contextID)
    const testables = useLazyTestables()

    const firstChild = children[0]

    if (!firstChild) {
        return 'filter'
    }

    let firstChildData: TestableData

    if (typeof firstChild === 'object') {
        firstChildData = (firstChild as unknown as TestableData)
    } else {
        firstChildData = testables.getNode<TestableData>(firstChild as string)!
    }

    return firstChildData?.testableType || 'filter'

}

export const useParentId: (id: TestableCompositeData['id']) => TestableCompositeData['id'] | undefined = ID => {
    return useNodesStore(
        useShallow(
            store => store.testableRelations.find(node => node.children.includes(ID))
        )
    )?.id
}

export const useNodeData: (id: TestableCompositeData['id'] | undefined) => NodeData | undefined = id => useNodesStore(useShallow(findDataForNodeWithoutChildren(id)))

export const testableTypeLabel: (type: TestableCompositeData['type']) => string = type => {
    switch (normalizeTestableGroupMode(type)) {
        case TestableGroupTypeId.AND:
            return '+'
        case TestableGroupTypeId.OR:
            return __('AND/OR')
        case TestableGroupTypeId.OR_EXCLUSIVE:
            return __('OR')
    }
}

export function getIdWithoutLastSuffix(id: string) {
    const suffixPattern = /->\w+$/;

    if (suffixPattern.test(id)) {
        if (/->children->\d+$/.test(id)) {
            return id.replace(/->children->\d+$/, '');
        }

        return id.replace(suffixPattern, '');
    }

    return id;
}

export function getIdWithoutAnySuffix(id: string) {
    const suffixPattern = /(->\w+)*$/;

    if (suffixPattern.test(id)) {
        if (/->children->\d+$/.test(id)) {
            return id.replace(/->children->\d+$/, '');
        }

        return id.replace(suffixPattern, '');
    }

    return id;
}

export function getIdSuffixed(id: string, suffixToHave: 'branch' | 'testable') {
    if (id.endsWith(`->${suffixToHave}`)) {
        return id;
    }

    const suffixPattern = /->\w+$/;

    if (suffixPattern.test(id)) {
        return id.replace(suffixPattern, `->${suffixToHave}`);
    }

    return `${id}->${suffixToHave}`;
}


type TestableCompositeWithChildren = {
    testableComposite: TestableCompositeData,
    children: {
        testableComposite: TestableCompositeData,
        children: (TestableData | TestableCompositeWithChildren)[]
    }[]
};

export enum TestableFormat {
    /**
     * IMPORTANT: DO NOT USE NUMBERS HERE BECAUSE THE OTHER OPTION MAY EXPECT A NUMBER (ID)
     */
    Flat = '__FLAT__',
    Nested = '__NESTED__',
}

interface PerformAddTestablesData {
    // id is optional
    testables: ((Partial<Pick<TestableData, 'id'>> & Omit<TestableData, 'id'>)[]) | NestedTestableNodeData[],
    contextOrTestableGroupIDOrFormat: string | undefined | TestableFormat,
    wrapInGroup: boolean,
    options: {
        keepOriginalModes: boolean
    }
}

export type TestablesState = Pick<NodesStore, 'testables' | 'testableRelations' | 'testablePartials'>;

export enum TestableEvent {
    RemovedRoot = 'testable.removedRoot',
}

type TestableEventTypes = {
    [TestableEvent.RemovedRoot]: {
        id: string
    }
}

export class Testables {
    private emitter = mitt<TestableEventTypes>();
    protected afterPerformActionsQueue: Function[] = []
    private isInsideATransaction: boolean = false

    constructor(
        private store: TestablesState
    ) {
    }

    get testables() {
        return this.store.testables
    }

    get testableRelations() {
        return this.store.testableRelations
    }

    get partials() {
        return this.store.testablePartials
    }

    getNode<TypeOfNode extends (NodeData | TestablePartial) = NodeData>(id: string): TypeOfNode | undefined {
        if (this.isPartial(id)) {
            return this.store.testablePartials.find(node => node.id === id) as TypeOfNode;
        }
        return this.store.testables.find(node => node.id === id) as TypeOfNode;
    }

    /**
     * Returns copies of the nodes
     */
    getNodeWithChildren(id: string) {
        const node = this.getNode(id)
        const nodeRelations = this.store.testableRelations.find(node => node.id === id);

        if (!node) {
            return;
        }

        if (!nodeRelations) {
            const isCompositeNode =
                typeof (node as TestableCompositeData).mode === 'string'
                && (
                    isNonContextTestableGroupType((node as TestableCompositeData).type)
                    || (node as TestableCompositeData).type === TestableGroupTypeId.CONTEXT
                )

            if (isCompositeNode) {
                return cloneDeep({
                    ...node,
                    children: []
                } as TestableCompositeDataWithChildren)
            }

            return cloneDeep(node)
        }

        if (nodeRelations) {
            // @ts-ignore
            return cloneDeep({
                ...node,
                children: nodeRelations.children.map(this.getNodeWithChildren.bind(this)).filter(Boolean) as TestableCompositeDataWithChildren['children']
            })
        }
    }

    getChildren(id: string): NodeData[] {
        const children = this.store.testableRelations.find(node => node.id === id)?.children || [];

        return children.map(this.getNode.bind(this)).filter(Boolean) as NodeData[];
    }

    getParent(id: string): NodeData | undefined {
        const parentId = this.store.testableRelations.find(node => node.children.includes(id))?.id!;

        if (parentId) {
            return this.getNode(parentId);
        }
    }

    hasParent(id: string): boolean {
        return !!this.getParent(id)
    }

    getWithParentType(testable: TestableData, parentType?: ComponentRuntimeDataWithParentType['parentType']): ComponentRuntimeDataWithParentType {
        return {
            ...testable,
            parentType: `${testable.testableType || parentType}s`,
        }
    }

    /**
     * Default groups are nodes that:
     * - are testable groups
     * - have 1 child
     * @param id
     */
    isDefaultGroup(id: string): any {
        return this.isTestableCompositeGroup(id) && this.getChildren(id).length === 1;
    }

    isTestableCompositeGroup(id: string): boolean {
        const node = this.getNode(id)

        // @ts-ignore
        return typeof node?.mode == 'string' && isNonContextTestableGroupType(node?.type);
    }

    isContext(id: string): boolean {
        const node = this.getNode(id)

        // @ts-ignore
        return typeof node?.mode == 'string' && node?.type === TestableGroupTypeId.CONTEXT;
    }

    isTestable(id: string): boolean {
        const node = this.getNode(id)

        // @ts-ignore
        return typeof node?.mode == 'undefined' && node?.options
    }

    /**
     * If id is a context, they will be added to that context
     * if it's a testable group, a new context will be created and added to that group
     *
     * if no id is passed, a new group will be created in the root, as a totally new tree!
     * @param testables
     * @param contextOrTestableGroupID
     */
    addTestables(testables: PerformAddTestablesData['testables'], contextOrTestableGroupIDOrFormat?: PerformAddTestablesData['contextOrTestableGroupIDOrFormat'], wrapInGroup: PerformAddTestablesData['wrapInGroup'] = true, options: PerformAddTestablesData['options'] = {keepOriginalModes: false}) {
        return this.perform<PerformAddTestablesData, TestableData['id'][]>(
            'addTestables', {data: {testables, contextOrTestableGroupIDOrFormat, wrapInGroup, options}}
        )
    }

    addTestablePartials(testablePartials: (TestablePartial | Omit<TestablePartial, 'id'>)[], testableId: string): TestablePartial['id'][] {
        // dont if the parent is not a testable
        const testable = this.getNode(testableId)
        if (!testable || !this.isTestable(testableId)) {
            return []
        }

        const testablePartialsWithId: TestablePartial[] = testablePartials.map(testablePartial => ({
            id: `testable-partial-${generate()}`,
            ...testablePartial,
        })).filter(testablePartial => {
            // only add the testable partial if it doesn't exist in the store
            return !this.getNode(testablePartial.id)
        })

        this.store.testablePartials.push(...testablePartialsWithId)
        // now let's add the relations
        this.addChildrenRelations(testablePartialsWithId.map(testablePartial => testablePartial.id), testableId)

        return testablePartialsWithId.map(testablePartial => testablePartial.id);
    }

    /**
     * If you're adding new testables, a context id is needed
     * By default, if you pass a context id, you have to pass all testables otherwise it will remove the testables that are not in the new array
     * you can set removeInexistentTestables to false to prevent that
     */
    updateTestables(testables: TestableData[], contextID?: string, removeInexistentTestables: boolean = true) {
        // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction
        this.transaction(() => {
            const canRemove = this.isContext(contextID!) && removeInexistentTestables;

            if (canRemove) {
                const updatedTestableIds = testables.map(testable => testable.id)
                // first remove the testables that are not in the new array
                const removedTestables = this.getChildren(contextID!).filter(testable => !updatedTestableIds.includes(testable.id))

                removedTestables.forEach(testable => {
                    this.removeIDS([testable.id])
                })
            }
            testables.forEach(testable => {
                const testableIndex = this.store.testables.findIndex(node => node.id === testable.id)

                if (testableIndex === -1) {
                    // it's a new testable
                    // @ts-ignore
                    if (this.isContext(contextID)) {
                        this.addTestables([testable], contextID)
                    }
                } else {
                    this.updateTestable(testable)
                }
            })

        })
    }

    updatePartial(testablePartial: TestablePartial) {
        // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction
        this.transaction(() => {
            const testableIndex = this.store.testablePartials.findIndex(node => node.id === testablePartial.id)

            if (testableIndex === -1) {
                // it's a new testable partial
                // @ts-ignore
                this.store.testablePartials.push(testablePartial)
                return
            }
            this.store.testablePartials[testableIndex] = testablePartial
        })
    }

    updateTestable(testable: TestableData) {
        // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction
        this.transaction(() => {
            const testableIndex = this.store.testables.findIndex(node => node.id === testable.id)

            if (testableIndex === -1) {
                // it's a new testable partial
                // @ts-ignore
                this.store.testables.push(testable)
            } else {
                this.store.testables[testableIndex] = testable
            }
        })
    }

    wrapAllContexts(testableGroupID: string) {
        const testableGroupWithChildrenIndex = this.store.testableRelations.findIndex(testable => testable.id === testableGroupID)!
        const testableGroupWithChildren = this.store.testableRelations[testableGroupWithChildrenIndex]

        testableGroupWithChildren.children = testableGroupWithChildren.children.map(contextID => {
            const parentGroupForThisContext = {
                id: `group-${generate()}`,
                type: TestableGroupTypeId.AND,
                mode: 'filter'
            } as TestableCompositeData

            const contextIndex = this.store.testables.findIndex(testable => testable.id === contextID)!
            this.store.testables.splice(contextIndex, 0, parentGroupForThisContext)
            this.store.testableRelations.splice(testableGroupWithChildrenIndex, 0, {
                id: parentGroupForThisContext.id,
                children: [contextID]
            })

            return parentGroupForThisContext.id;
        })
    }

    /**
     * Can be a testable composite (group or context) or a testable
     *
     * if it's a testable, it will return the parent context
     */
    getRelationsForNode(id: string): NodesStore['testableRelations'][number] | undefined {
        const testableCompositeRelations = this.store.testableRelations.find(node => node.id === id)

        if (testableCompositeRelations) {
            // the id is for a testable composite (group or context)
            return testableCompositeRelations
        }

        // the id is for a testable so let's find its parent context
        return this.store.testableRelations.find(node => node.children.includes(id))
    }

    createContext(testables: TestableData[], testableGroupID: string, mode: TestableCompositeData['mode'] = 'filter') {
        // lets add the context to the testables array
        const context = {
            id: `context-${generate()}`,
            type: TestableGroupTypeId.CONTEXT,
            mode
        } as TestableCompositeData
        // lets add the context to the testable group relations
        this.store.testables.push(context)
        // lets add the context to the relations array
        this.addChildrenRelations([], context.id)
        this.addChildrenRelations([context.id], testableGroupID)
        this.addTestables(testables, context.id)
    }

    createGroup(
        parentGroupID?: string,
        type: TestableCompositeData['type'] = TestableGroupTypeId.AND,
        mode: TestableCompositeData['mode'] = 'filter',
        children: TestableCompositeData['id'][] = []
    ) {
        const group = {
            id: `group-${generate()}`,
            type,
            mode
        } as TestableCompositeData

        this.store.testables.push(group)
        this.addChildrenRelations(children, group.id)

        if (parentGroupID) {
            this.addChildrenRelations([group.id], parentGroupID)
        }

        return group
    }

    wrapChildrenInNewGroup(testableGroupID: string, type?: TestableCompositeData['type']) {
        const targetGroup = this.getNode(testableGroupID) as TestableCompositeData | undefined
        const targetGroupRelations = this.getRelationsForNode(testableGroupID)

        if (!targetGroup || !targetGroupRelations) {
            throw new Error(`Could not wrap children for testable group "${testableGroupID}".`)
        }

        const wrappedGroup = this.createGroup(undefined, type || targetGroup.type, targetGroup.mode, [
            ...targetGroupRelations.children,
        ])

        targetGroupRelations.children = [wrappedGroup.id]

        return wrappedGroup
    }

    getNodeIds = (testables: NodeData[]) => {
        return testables.map(testable => testable.id);
    }

    addChildrenRelations(children: TestableCompositeData['id'][], parentID: TestableCompositeData['id']) {
        const parentRelations = this.getRelationsForNode(parentID)
        if (typeof parentRelations !== 'undefined') {
            parentRelations.children.push(...children)
        } else {
            // it doesn't exist, so we need to create it
            this.store.testableRelations.push({
                id: parentID,
                children
            })
        }
    }

    changeTestableGroupOptions(testableCompositeGroupId: string, options: Partial<Omit<TestableCompositeData, 'id'>>) {
        const testableCompositeNode = this.getNode(testableCompositeGroupId) as TestableCompositeData

        for (let option in omit(options, 'id')) {
            // @ts-ignore
            testableCompositeNode[option] = options[option]
        }
    }

    /**
     * Triggers:
     * - TestableEvent.RemovedRoot
     */
    remove(testableCompositeOrContextID: string) {
        this.perform<string>('remove', {data: testableCompositeOrContextID})
    }

    perform<Data, Return = any>(action: 'remove' | 'addTestables', {data}: { data: Data }): Return {
        const result = this[`perform${action.charAt(0).toUpperCase() + action.slice(1)}`](data)
        this.maybeCallActions();

        return result
    }

    /**
     * Will hold the cleanup actions until the transaction is finished.
     * When you run a mutable function, a "cleanup" action will be performed after that.
     *
     * For example, if you remove a node, an action will be scheduled to check if its parent has no children and if not,
     * it will be removed too because we don't want to have empty nodes.
     *
     * So if you remove a node inside a transaction, the cleanup action will not be executed until the transaction is finished.
     *
     * This allows us, for example, to remove a node and then add a new one to its parent.
     *
     * If you remove a node outside a transaction and that node was the only child of its parent's node, the parent would be removed, and you wouldn't be able to add another node to that parent
     *
     * eg:
     *
     * the parent of testable is context-8758 and it has no other children
     * this.remove('testable')
     * this.addTestables([newTestable], 'context-8758') // this would fail because context-8758 would have been removed by now
     *
     * so you'd have to perform those operations inside a transaction
     *
     * this.transaction(() => {
     *   this.remove('testable')
     *   // the parent isn't removed yet because we are inside a transaction
     *   this.addTestables([newTestable], 'context-8758')
     *   // end of transaction, cleanup actions are executed but parent is still there because we added a new testable to it
     * })
     */
    transaction(callable: Function) {
        this.isInsideATransaction = true
        callable()
        this.isInsideATransaction = false

        this.maybeCallActions()
    }

    private maybeCallActions = () => {
        if (this.isInsideATransaction) {
            return
        }
        for (const [index, action] of this.afterPerformActionsQueue.entries()) {
            action()
        }
        this.afterPerformActionsQueue = []
    }

    /**
     * Returns the ids of the added testables, useful if you passed testables to add without ids
     */
    performAddTestables({
                            testables,
                            contextOrTestableGroupIDOrFormat,
                            wrapInGroup,
                            options
                        }: PerformAddTestablesData): TestableData['id'][] {
        const {keepOriginalModes} = options

        if (!testables || testables.length < 1) {
            return []
        }

        //let's add ids if they don't have em
        testables.forEach(testable => {
            if (typeof testable.id === 'undefined') {
                testable.id = `testable-${generate()}`
            }
        })

        // because typescript wont stop yelling smh
        const testablesWithIDs: TestableData[] = testables as TestableData[]
        const testableType: TestableData['testableType'] = testablesWithIDs[0].testableType || 'filter'

        const firstTestableId = testablesWithIDs[0].id

        if (contextOrTestableGroupIDOrFormat === TestableFormat.Nested) {
            wrapInGroup = false
        }

        testablesWithIDs.forEach(testable => {
            const testableIndex = this.store.testables.findIndex(node => node.id === testable.id)
            const testableWithoutChildren = pickBy({
                ...testable,
                children: undefined
            } as TestableData, v => !isUndefined(v))
            const customImportedData = exporterImporters[(testableWithoutChildren as TestableData).type]?.importData?.(
                cloneDeep(testableWithoutChildren as TestableData),
                {testables: this}
            )
            const testableToStore = this.mergeWithDefaultCardData(
                (customImportedData || testableWithoutChildren) as TestableData
            )

            if (testableIndex === -1) {
                // @ts-ignore
                this.store.testables.push(testableToStore)
            } else {
                this.store.testables[testableIndex] = testableToStore
            }
        })

        if (!contextOrTestableGroupIDOrFormat && wrapInGroup) {
            const mode = this.getModeFromTestableType(testableType)
            // no context or testable group id passed, so let's create a new group
            const newGroup = {
                id: `group-${generate()}`,
                type: TestableGroupTypeId.AND,
                mode
            } as TestableCompositeData
            // we should also create a context, i bet this hasn't been tested has it
            // so lets create a new context
            const newContext = {
                id: `context-${generate()}`,
                type: TestableGroupTypeId.CONTEXT,
                mode
            }

            this.store.testables.push(newGroup, newContext)
            // lets add the context and group to the testable group relations
            this.store.testableRelations.push({
                id: newGroup.id,
                children: [newContext.id]
            })
            this.store.testableRelations.push({
                id: newContext.id,
                children: this.getNodeIds(testablesWithIDs)
            })
        } else if (this.isContext(contextOrTestableGroupIDOrFormat || '') && wrapInGroup) {
            this.addChildrenRelations(this.getNodeIds(testablesWithIDs), contextOrTestableGroupIDOrFormat!)
        } else if (wrapInGroup) {
            // this is a testable group
            // so let's create a new context
            this.createContext(testablesWithIDs, contextOrTestableGroupIDOrFormat!)
        }

        if (contextOrTestableGroupIDOrFormat === TestableFormat.Nested) {
            // let's check if children and if so, add them to the state and the relations
            testables.forEach(testable => {
                if ('mode' in testable) {
                    this.addChildrenRelations([], testable.id)
                }

                if (testable.children) {
                    // use performAddTestables to add the children
                    // if the children are another group, then add them using the nested format
                    // but if the chidlrwen are testables, then add them using the normal format and pass the parent id
                    testable.children.forEach(child => {
                        if ('children' in child) {
                            // This is another nestable group (context or composite), use nested format
                            const childIds = this.performAddTestables({
                                testables: [child],
                                contextOrTestableGroupIDOrFormat: TestableFormat.Nested,
                                wrapInGroup: false,
                                options
                            });
                            // Now establish the parent-child relationship
                            if (childIds.length > 0) {
                                this.addChildrenRelations([childIds[0]], testable.id);
                            }
                        } else {
                            // This is a plain testable, add it with the parent id
                            this.performAddTestables({
                                testables: [child],
                                contextOrTestableGroupIDOrFormat: testable.id,
                                wrapInGroup: true,
                                options
                            });
                        }
                    });
                }
            })
        }

        this.afterPerformActionsQueue.push(() => {
            if (!keepOriginalModes) {
                this.ensureCorrectModeForTestableAncestors(firstTestableId)
            }
        })

        return testablesWithIDs.map(testable => testable.id);
    }

    performRemove(testableCompositeOrContextID: string) {

        if (this.isTestable(testableCompositeOrContextID)) {
            this.performRemoveTestable(testableCompositeOrContextID);
        } else if (this.isContext(testableCompositeOrContextID) || this.isTestableCompositeGroup(testableCompositeOrContextID)) {
            this.performRemoveTestableComposite(testableCompositeOrContextID);
        }
        // first lets remove the child testables
        //const children = this.getChildren(testableCompositeOrContextID)


        return () => {
            // also remove the testable composite group if it has no children
        };
    }

    private performRemoveTestable(testableID: string) {
        const parentContext = this.getParent(testableID) as TestableCompositeData

        this.removeIDS([testableID])

        // maybe the parent has already been removed
        if (!parentContext) {
            return
        }

        // cleanup function
        this.afterPerformActionsQueue.push(() => {
            // removing if no children
            this.removeIfNoChildren(parentContext.id)
        })
    }

    /**
     * Could be a testable group or a context
     */
    private performRemoveTestableComposite(testableCompositeID: string) {
        const parentGroupId = this.getParent(testableCompositeID)?.id
        const isRoot = !parentGroupId
        const children = this.getChildren(testableCompositeID)

        this.performRemoveTestable(testableCompositeID)

        // for the children, remove them using the api in case they are testable composites so that it can be recursive
        children.forEach(child => {
            this.remove(child.id)
        })

        this.afterPerformActionsQueue.push(() => {
            if (isRoot) {
                this.emitter.emit(TestableEvent.RemovedRoot, {
                    id: testableCompositeID
                })
            }
        })
    }

    private removeIDFromTestables = (contextID: string) => {
        let storage: (NodeData[] | TestablePartial[])

        if (this.isPartial(contextID)) {
            storage = this.store.testablePartials
        } else {
            storage = this.store.testables
        }
        const index = storage.findIndex(node => node.id === contextID)

        if (index !== -1) {
            storage.splice(index, 1)
        }
    }

    private removeIDfromRelations = (id: string) => {
        // here remove it from the relations array if it exists
        const index = this.store.testableRelations.findIndex(node => node.id === id)

        if (index !== -1) {
            this.store.testableRelations.splice(index, 1)
        }
        // then loop through chidlren and remove it from children too if it exists
        this.store.testableRelations.forEach(node => {
            const childIndex = node.children.indexOf(id)
            if (childIndex !== -1) {
                node.children.splice(childIndex, 1)
            }
        })
    }

    private removeIfNoChildren = (id: string): Function | undefined => {
        // maybe it has already been removed
        if (!this.getNode(id)) {
            return;
        }

        if (this.isTestable(id)) {
            return
        }

        if (this.getChildren(id).length < 1) {
            this.remove(id)
            return
        }

        if (this.isTestableCompositeGroup(id) && !this.isContext(id)) {
            this.collapseRedundantSingleChildWrappers(id)
        }
    }

    private collapseRedundantSingleChildWrappers(groupId: string) {
        if (!this.getNode(groupId) || !this.isTestableCompositeGroup(groupId) || this.isContext(groupId)) {
            return
        }

        let group = this.getNode(groupId) as TestableCompositeData | undefined
        let groupRelations = this.getRelationsForNode(groupId)

        if (!group || !groupRelations) {
            return
        }

        let onlyChild = this.getChildren(groupId)[0]

        while (groupRelations.children.length === 1 && onlyChild && this.isTestableCompositeGroup(onlyChild.id) && !this.isContext(onlyChild.id)) {
            const childGroup = this.getNode(onlyChild.id) as TestableCompositeData | undefined
            const childGroupRelations = this.getRelationsForNode(onlyChild.id)

            if (!childGroup || !childGroupRelations) {
                break
            }

            group.type = childGroup.type
            group.mode = childGroup.mode
            groupRelations.children = [...childGroupRelations.children]
            this.removeIDS([onlyChild.id])

            group = this.getNode(groupId) as TestableCompositeData | undefined
            groupRelations = this.getRelationsForNode(groupId)

            if (!group || !groupRelations) {
                return
            }

            onlyChild = this.getChildren(groupId)[0]
        }

        onlyChild = this.getChildren(groupId)[0]

        if (group && onlyChild && this.isContext(onlyChild.id)) {
            group.type = TestableGroupTypeId.AND
            group.mode = (onlyChild as TestableCompositeData).mode
        }

        const parentGroup = this.getParent(groupId)

        if (parentGroup && this.isTestableCompositeGroup(parentGroup.id) && !this.isContext(parentGroup.id)) {
            this.collapseRedundantSingleChildWrappers(parentGroup.id)
        }
    }

    private removeIDS(nodeIds: string[]) {
        nodeIds.forEach(id => {
            // first remove it from the testables array
            this.removeIDFromTestables(id)
            // then remove it from the relations array
            this.removeIDfromRelations(id)
        })
    }

    private ensureCorrectModeForTestableAncestors(testableId: string) {
        // know what? i rather do the ancestry
        // check the whole ancestry and if js a single condition, then change mode for all groups to test
        // there's probably a more performant way to do this, but this is easier to understand so this will have to do for now
        if (this.thereIsAConditionInTheTree(testableId)) {
            this.changeModeOfAllTestableCompositeDescendants(this.getRootNode(testableId).id, 'test')
        } else {
            // there's not a single condition in the tree, so we can change all modes to filter UNLESS
            this.changeModeOfAllTestableCompositeDescendants(this.getRootNode(testableId).id, 'filter')
        }
    }

    /**
     * This is the farthest ancestor and it 100% is a TestableComposite because testables cannot have children
     */
    getRootNode(id: string): TestableCompositeData | undefined {
        const node = this.getNode(id) as TestableCompositeData

        if (!node) {
            return
        }

        const parent = this.getParent(node.id)

        if (parent) {
            return this.getRootNode(parent.id)
        }

        return node
    }

    private thereIsAConditionInTheTree(id: string): boolean {
        // get the root
        const root = this.getRootNode(id)

        return this.theresAConditionInDescendants(root.id)
    }

    private theresAConditionInDescendants(id): boolean {
        if (this.isTestable(id)) {
            return (this.getNode(id) as TestableData).testableType === 'condition'
        }

        return !!this.getChildren(id).find(child => {
            return this.theresAConditionInDescendants(child.id)
        })

    }

    private changeModeOfAllTestableCompositeDescendants(id: string, mode: TestableCompositeData['mode']) {
        if (this.isTestable(id)) {
            return
        }

        if (!this.modeIs(id, mode)) {
            this.changeTestableGroupOptions(id, {mode})
        }

        // then loop through children and change their mode too
        this.getChildren(id).forEach(child => {
            this.changeModeOfAllTestableCompositeDescendants(child.id, mode)
        })
    }

    private modeIs(id: string, mode: TestableCompositeData['mode']): boolean {
        const node = this.getNode(id) as TestableCompositeData

        return node?.mode === mode
    }

    private getModeFromTestableType(testableType: TestableData['testableType']): TestableCompositeData['mode'] {
        if (testableType === 'condition') {
            return 'test'
        }

        return 'filter'
    }

    public isPartial(id: string) {
        // we're going very simple, with the id prefix, this could be brittle so ots something to keep in mind
        return id?.startsWith?.('testable-partial-')
    }

    addSubscriber(event: TestableEvent, subscriber: (data: TestableEventTypes[typeof event]) => void) {
        this.emitter.on(event, subscriber)
    }

    getPartialRelations() {
        return this.testableRelations.filter(relation => relation.children.some(childId => this.isPartial(childId)))
    }

    mergeWithDefaultCardData(testableWithoutChildren: object) {
        if ('parentType' in testableWithoutChildren) {
            const runtimeData = testableWithoutChildren as { parentType: string, type: string }
            const componentMeta = getComponentMeta(runtimeData.parentType as ComponentType, runtimeData.type)

            if (!componentMeta) {
                return testableWithoutChildren
            }

            // its a filter or condition so lets get the default data
            const cardData = getComponentMetaData(componentMeta)

            testableWithoutChildren = merge({options: cloneDeep(cardData.defaultOptions)}, testableWithoutChildren)
        }

        return testableWithoutChildren
    }
}
