import { assert, unreachable } from "../../../shared/src/asserts.js";
import { binarySearch } from "../../../shared/src/binary-search.js";
import { emptyArray } from "../../../shared/src/sentinels.js";
import { constraintsAreCompatible } from "./constraint.js";
import { generateWithOverlay, isJoinMatch, rowEqualsForCompoundKey, } from "./join-utils.js";
import { throwOutput, } from "./operator.js";
import { first } from "./stream.js";
/**
 * An *inner* join which fetches nodes from its child input first and then
 * fetches their related nodes from its parent input.  Output nodes are the
 * nodes from parent input (in parent input order), which have at least one
 * related child.  These output nodes have a new relationship added to them,
 * which has the name `relationshipName`. The value of the relationship is a
 * stream of related nodes from the child input (in child input order).
 */
export class FlippedJoin {
    #parent;
    #child;
    #parentKey;
    #childKey;
    #relationshipName;
    #schema;
    #output = throwOutput;
    #inprogressChildChange;
    constructor({ parent, child, parentKey, childKey, relationshipName, hidden, system, }) {
        assert(parent !== child, 'Parent and child must be different operators');
        assert(parentKey.length === childKey.length, 'The parentKey and childKey keys must have same length');
        this.#parent = parent;
        this.#child = child;
        this.#parentKey = parentKey;
        this.#childKey = childKey;
        this.#relationshipName = relationshipName;
        const parentSchema = parent.getSchema();
        const childSchema = child.getSchema();
        this.#schema = {
            ...parentSchema,
            relationships: {
                ...parentSchema.relationships,
                [relationshipName]: {
                    ...childSchema,
                    isHidden: hidden,
                    system,
                },
            },
        };
        parent.setOutput({
            push: (change) => this.#pushParent(change),
        });
        child.setOutput({
            push: (change) => this.#pushChild(change),
        });
    }
    destroy() {
        this.#child.destroy();
        this.#parent.destroy();
    }
    setOutput(output) {
        this.#output = output;
    }
    getSchema() {
        return this.#schema;
    }
    // TODO: When parentKey is the parent's primary key (or more
    // generally when the parent cardinality is expected to be small) a different
    // algorithm should be used:  For each child node, fetch all parent nodes
    // eagerly and then sort using quicksort.
    *fetch(req) {
        // Translate constraints for the parent on parts of the join key to
        // constraints for the child.
        const childConstraint = {};
        let hasChildConstraint = false;
        if (req.constraint) {
            for (const [key, value] of Object.entries(req.constraint)) {
                const index = this.#parentKey.indexOf(key);
                if (index !== -1) {
                    hasChildConstraint = true;
                    childConstraint[this.#childKey[index]] = value;
                }
            }
        }
        const childNodes = [
            ...this.#child.fetch(hasChildConstraint ? { constraint: childConstraint } : {}),
        ];
        // FlippedJoin's split-push change overlay logic is largely
        // the same as Join's with the exception of remove.  For remove,
        // the change is undone here, and then re-applied to parents with order
        // less than or equal to change.position below.  This is necessary
        // because if the removed node was the last related child, the
        // related parents with position greater than change.position
        // (which should not yet have the node removed), would not even
        // be fetched here, and would be absent from the output all together.
        if (this.#inprogressChildChange?.change.type === 'remove') {
            const removedNode = this.#inprogressChildChange.change.node;
            const compare = this.#child.getSchema().compareRows;
            const insertPos = binarySearch(childNodes.length, i => compare(removedNode.row, childNodes[i].row));
            childNodes.splice(insertPos, 0, removedNode);
        }
        const parentIterators = [];
        let threw = false;
        try {
            for (const childNode of childNodes) {
                // TODO: consider adding the ability to pass a set of
                // ids to fetch, and have them applied to sqlite using IN.
                const constraintFromChild = {};
                for (let i = 0; i < this.#parentKey.length; i++) {
                    constraintFromChild[this.#parentKey[i]] =
                        childNode.row[this.#childKey[i]];
                }
                if (req.constraint &&
                    !constraintsAreCompatible(constraintFromChild, req.constraint)) {
                    parentIterators.push(emptyArray[Symbol.iterator]());
                }
                else {
                    const stream = this.#parent.fetch({
                        ...req,
                        constraint: {
                            ...req.constraint,
                            ...constraintFromChild,
                        },
                    });
                    const iterator = stream[Symbol.iterator]();
                    parentIterators.push(iterator);
                }
            }
            const nextParentNodes = [];
            for (let i = 0; i < parentIterators.length; i++) {
                const iter = parentIterators[i];
                const result = iter.next();
                nextParentNodes[i] = result.done ? null : result.value;
            }
            while (true) {
                let minParentNode = null;
                let minParentNodeChildIndexes = [];
                for (let i = 0; i < nextParentNodes.length; i++) {
                    const parentNode = nextParentNodes[i];
                    if (parentNode === null) {
                        continue;
                    }
                    if (minParentNode === null) {
                        minParentNode = parentNode;
                        minParentNodeChildIndexes.push(i);
                    }
                    else {
                        const compareResult = this.#schema.compareRows(parentNode.row, minParentNode.row) *
                            (req.reverse ? -1 : 1);
                        if (compareResult === 0) {
                            minParentNodeChildIndexes.push(i);
                        }
                        else if (compareResult < 0) {
                            minParentNode = parentNode;
                            minParentNodeChildIndexes = [i];
                        }
                    }
                }
                if (minParentNode === null) {
                    return;
                }
                const relatedChildNodes = [];
                for (const minParentNodeChildIndex of minParentNodeChildIndexes) {
                    relatedChildNodes.push(childNodes[minParentNodeChildIndex]);
                    const iter = parentIterators[minParentNodeChildIndex];
                    const result = iter.next();
                    nextParentNodes[minParentNodeChildIndex] = result.done
                        ? null
                        : result.value;
                }
                let overlaidRelatedChildNodes = relatedChildNodes;
                if (this.#inprogressChildChange &&
                    this.#inprogressChildChange.position &&
                    isJoinMatch(this.#inprogressChildChange.change.node.row, this.#childKey, minParentNode.row, this.#parentKey)) {
                    const hasInprogressChildChangeBeenPushedForMinParentNode = this.#parent
                        .getSchema()
                        .compareRows(minParentNode.row, this.#inprogressChildChange.position) <= 0;
                    if (this.#inprogressChildChange.change.type === 'remove') {
                        if (hasInprogressChildChangeBeenPushedForMinParentNode) {
                            // Remove form relatedChildNodes since the removed child
                            // was inserted into childNodes above.
                            overlaidRelatedChildNodes = relatedChildNodes.filter(n => n !== this.#inprogressChildChange?.change.node);
                        }
                    }
                    else if (!hasInprogressChildChangeBeenPushedForMinParentNode) {
                        overlaidRelatedChildNodes = [
                            ...generateWithOverlay(relatedChildNodes, this.#inprogressChildChange.change, this.#child.getSchema()),
                        ];
                    }
                }
                // yield node if after the overlay it still has relationship nodes
                if (overlaidRelatedChildNodes.length > 0) {
                    yield {
                        ...minParentNode,
                        relationships: {
                            ...minParentNode.relationships,
                            [this.#relationshipName]: () => overlaidRelatedChildNodes,
                        },
                    };
                }
            }
        }
        catch (e) {
            threw = true;
            for (const iter of parentIterators) {
                try {
                    iter.throw?.(e);
                }
                catch (_cleanupError) {
                    // error in the iter.throw cleanup,
                    // catch so other iterators are cleaned up
                }
            }
            throw e;
        }
        finally {
            if (!threw) {
                for (const iter of parentIterators) {
                    try {
                        iter.return?.();
                    }
                    catch (_cleanupError) {
                        // error in the iter.return cleanup,
                        // catch so other iterators are cleaned up
                    }
                }
            }
        }
    }
    *cleanup(_req) { }
    #pushChild(change) {
        const pushChildChange = (exists) => {
            this.#inprogressChildChange = {
                change,
                position: undefined,
            };
            try {
                const parentNodeStream = this.#parent.fetch({
                    constraint: Object.fromEntries(this.#parentKey.map((key, i) => [
                        key,
                        change.node.row[this.#childKey[i]],
                    ])),
                });
                for (const parentNode of parentNodeStream) {
                    this.#inprogressChildChange = {
                        change,
                        position: parentNode.row,
                    };
                    const childNodeStream = () => this.#child.fetch({
                        constraint: Object.fromEntries(this.#childKey.map((key, i) => [
                            key,
                            parentNode.row[this.#parentKey[i]],
                        ])),
                    });
                    if (!exists) {
                        for (const childNode of childNodeStream()) {
                            if (this.#child
                                .getSchema()
                                .compareRows(childNode.row, change.node.row) !== 0) {
                                exists = true;
                                break;
                            }
                        }
                    }
                    if (exists) {
                        this.#output.push({
                            type: 'child',
                            node: {
                                ...parentNode,
                                relationships: {
                                    ...parentNode.relationships,
                                    [this.#relationshipName]: childNodeStream,
                                },
                            },
                            child: {
                                relationshipName: this.#relationshipName,
                                change,
                            },
                        }, this);
                    }
                    else {
                        this.#output.push({
                            ...change,
                            node: {
                                ...parentNode,
                                relationships: {
                                    ...parentNode.relationships,
                                    [this.#relationshipName]: () => [change.node],
                                },
                            },
                        }, this);
                    }
                }
            }
            finally {
                this.#inprogressChildChange = undefined;
            }
        };
        switch (change.type) {
            case 'add':
            case 'remove':
                pushChildChange();
                break;
            case 'edit': {
                assert(rowEqualsForCompoundKey(change.oldNode.row, change.node.row, this.#childKey), `Child edit must not change relationship.`);
                pushChildChange(true);
                break;
            }
            case 'child':
                pushChildChange(true);
                break;
        }
    }
    #pushParent(change) {
        const childNodeStream = (node) => () => this.#child.fetch({
            constraint: Object.fromEntries(this.#childKey.map((key, i) => [key, node.row[this.#parentKey[i]]])),
        });
        const flip = (node) => ({
            ...node,
            relationships: {
                ...node.relationships,
                [this.#relationshipName]: childNodeStream(node),
            },
        });
        // If no related child don't push as this is an inner join.
        if (first(childNodeStream(change.node)()) === undefined) {
            return;
        }
        switch (change.type) {
            case 'add':
            case 'remove':
            case 'child': {
                this.#output.push({
                    ...change,
                    node: flip(change.node),
                }, this);
                break;
            }
            case 'edit': {
                assert(rowEqualsForCompoundKey(change.oldNode.row, change.node.row, this.#parentKey), `Parent edit must not change relationship.`);
                this.#output.push({
                    type: 'edit',
                    oldNode: flip(change.oldNode),
                    node: flip(change.node),
                }, this);
                break;
            }
            default:
                unreachable(change);
        }
    }
}
//# sourceMappingURL=flipped-join.js.map