import { assert, unreachable } from "../../../shared/src/asserts.js";
import { generateWithOverlay, isJoinMatch, rowEqualsForCompoundKey, } from "./join-utils.js";
import { throwOutput, } from "./operator.js";
import { take } from "./stream.js";
/**
 * The Join operator joins the output from two upstream inputs. Zero's join
 * is a little different from SQL's join in that we output hierarchical data,
 * not a flat table. This makes it a lot more useful for UI programming and
 * avoids duplicating tons of data like left join would.
 *
 * The Nodes output from Join have a new relationship added to them, which has
 * the name #relationshipName. The value of the relationship is a stream of
 * child nodes which are the corresponding values from the child source.
 */
export class Join {
    #parent;
    #child;
    #storage;
    #parentKey;
    #childKey;
    #relationshipName;
    #schema;
    #output = throwOutput;
    #inprogressChildChange;
    constructor({ parent, child, storage, 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.#storage = storage;
        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.#parent.destroy();
        this.#child.destroy();
    }
    setOutput(output) {
        this.#output = output;
    }
    getSchema() {
        return this.#schema;
    }
    *fetch(req) {
        for (const parentNode of this.#parent.fetch(req)) {
            yield this.#processParentNode(parentNode.row, parentNode.relationships, 'fetch');
        }
    }
    *cleanup(req) {
        for (const parentNode of this.#parent.cleanup(req)) {
            yield this.#processParentNode(parentNode.row, parentNode.relationships, 'cleanup');
        }
    }
    #pushParent(change) {
        switch (change.type) {
            case 'add':
                this.#output.push({
                    type: 'add',
                    node: this.#processParentNode(change.node.row, change.node.relationships, 'fetch'),
                }, this);
                break;
            case 'remove':
                this.#output.push({
                    type: 'remove',
                    node: this.#processParentNode(change.node.row, change.node.relationships, 'cleanup'),
                }, this);
                break;
            case 'child':
                this.#output.push({
                    type: 'child',
                    node: this.#processParentNode(change.node.row, change.node.relationships, 'fetch'),
                    child: change.child,
                }, this);
                break;
            case 'edit': {
                // Assert the edit could not change the relationship.
                assert(rowEqualsForCompoundKey(change.oldNode.row, change.node.row, this.#parentKey), `Parent edit must not change relationship.`);
                this.#output.push({
                    type: 'edit',
                    oldNode: this.#processParentNode(change.oldNode.row, change.oldNode.relationships, 'cleanup'),
                    node: this.#processParentNode(change.node.row, change.node.relationships, 'fetch'),
                }, this);
                break;
            }
            default:
                unreachable(change);
        }
    }
    #pushChild(change) {
        const pushChildChange = (childRow, change) => {
            this.#inprogressChildChange = {
                change,
                position: undefined,
            };
            try {
                const parentNodes = this.#parent.fetch({
                    constraint: Object.fromEntries(this.#parentKey.map((key, i) => [key, childRow[this.#childKey[i]]])),
                });
                for (const parentNode of parentNodes) {
                    this.#inprogressChildChange.position = parentNode.row;
                    const childChange = {
                        type: 'child',
                        node: this.#processParentNode(parentNode.row, parentNode.relationships, 'fetch'),
                        child: {
                            relationshipName: this.#relationshipName,
                            change,
                        },
                    };
                    this.#output.push(childChange, this);
                }
            }
            finally {
                this.#inprogressChildChange = undefined;
            }
        };
        switch (change.type) {
            case 'add':
            case 'remove':
                pushChildChange(change.node.row, change);
                break;
            case 'child':
                pushChildChange(change.node.row, change);
                break;
            case 'edit': {
                const childRow = change.node.row;
                const oldChildRow = change.oldNode.row;
                // Assert the edit could not change the relationship.
                assert(rowEqualsForCompoundKey(oldChildRow, childRow, this.#childKey), 'Child edit must not change relationship.');
                pushChildChange(childRow, change);
                break;
            }
            default:
                unreachable(change);
        }
    }
    #processParentNode(parentNodeRow, parentNodeRelations, mode) {
        let method = mode;
        let storageUpdated = false;
        const childStream = () => {
            if (!storageUpdated) {
                if (mode === 'cleanup') {
                    this.#storage.del(makeStorageKey(this.#parentKey, this.#parent.getSchema().primaryKey, parentNodeRow));
                    const empty = [
                        ...take(this.#storage.scan({
                            prefix: makeStorageKeyPrefix(parentNodeRow, this.#parentKey),
                        }), 1),
                    ].length === 0;
                    method = empty ? 'cleanup' : 'fetch';
                }
                storageUpdated = true;
                // Defer the work to update storage until the child stream
                // is actually accessed
                if (mode === 'fetch') {
                    this.#storage.set(makeStorageKey(this.#parentKey, this.#parent.getSchema().primaryKey, parentNodeRow), true);
                }
            }
            const stream = this.#child[method]({
                constraint: Object.fromEntries(this.#childKey.map((key, i) => [
                    key,
                    parentNodeRow[this.#parentKey[i]],
                ])),
            });
            if (this.#inprogressChildChange &&
                isJoinMatch(parentNodeRow, this.#parentKey, this.#inprogressChildChange.change.node.row, this.#childKey) &&
                this.#inprogressChildChange.position &&
                this.#schema.compareRows(parentNodeRow, this.#inprogressChildChange.position) > 0) {
                return generateWithOverlay(stream, this.#inprogressChildChange.change, this.#child.getSchema());
            }
            return stream;
        };
        return {
            row: parentNodeRow,
            relationships: {
                ...parentNodeRelations,
                [this.#relationshipName]: childStream,
            },
        };
    }
}
/** Exported for testing. */
export function makeStorageKeyForValues(values) {
    const json = JSON.stringify(['pKeySet', ...values]);
    return json.substring(1, json.length - 1) + ',';
}
/** Exported for testing. */
export function makeStorageKeyPrefix(row, key) {
    return makeStorageKeyForValues(key.map(k => row[k]));
}
/** Exported for testing.
 * This storage key tracks the primary keys seen for each unique
 * value joined on. This is used to know when to cleanup a child's state.
 */
export function makeStorageKey(key, primaryKey, row) {
    const values = key.map(k => row[k]);
    for (const key of primaryKey) {
        values.push(row[key]);
    }
    return makeStorageKeyForValues(values);
}
//# sourceMappingURL=join.js.map