/* oxlint-disable @typescript-eslint/no-explicit-any */
import { resolver } from '@rocicorp/resolver';
import { assert } from "../../../shared/src/asserts.js";
import { must } from "../../../shared/src/must.js";
import { SUBQ_PREFIX, } from "../../../zero-protocol/src/ast.js";
import { hashOfAST, hashOfNameAndArgs, } from "../../../zero-protocol/src/query-hash.js";
import { buildPipeline } from "../builder/builder.js";
import { NotImplementedError } from "../error.js";
import { ArrayView } from "../ivm/array-view.js";
import { defaultFormat } from "../ivm/default-format.js";
import { and, cmp, ExpressionBuilder, simplifyCondition, } from "./expression.js";
import { delegateSymbol, } from "./query.js";
import { DEFAULT_PRELOAD_TTL_MS, DEFAULT_TTL_MS } from "./ttl.js";
export function materialize(query, delegate, factoryOrOptions, maybeOptions) {
    if (typeof factoryOrOptions === 'function') {
        return query[delegateSymbol](delegate)
            .materialize(factoryOrOptions, maybeOptions?.ttl);
    }
    return query[delegateSymbol](delegate)
        .materialize(factoryOrOptions?.ttl);
}
const astSymbol = Symbol();
export function ast(query) {
    return query[astSymbol];
}
export function newQuery(delegate, schema, table) {
    return new QueryImpl(delegate, schema, table, { table }, defaultFormat, undefined);
}
export const newQuerySymbol = Symbol();
export class AbstractQuery {
    #schema;
    _delegate;
    #tableName;
    _ast;
    format;
    #hash = '';
    #system;
    #currentJunction;
    customQueryID;
    constructor(delegate, schema, tableName, ast, format, system, customQueryID, currentJunction) {
        this.#schema = schema;
        this._delegate = delegate;
        this.#tableName = tableName;
        this._ast = ast;
        this.format = format;
        this.#system = system;
        this.#currentJunction = currentJunction;
        this.customQueryID = customQueryID;
    }
    [delegateSymbol](delegate) {
        return this[newQuerySymbol](delegate, this.#schema, this.#tableName, this._ast, this.format, this.customQueryID, this.#currentJunction);
    }
    nameAndArgs(name, args) {
        return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, this._ast, this.format, {
            name,
            args,
        }, this.#currentJunction);
    }
    get [astSymbol]() {
        return this._ast;
    }
    get ast() {
        return this._completeAst();
    }
    hash() {
        if (!this.#hash) {
            this.#hash = hashOfAST(this._completeAst());
        }
        return this.#hash;
    }
    one = () => this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
        ...this._ast,
        limit: 1,
    }, {
        ...this.format,
        singular: true,
    }, this.customQueryID, this.#currentJunction);
    whereExists = (relationship, cbOrOptions, options) => {
        const cb = typeof cbOrOptions === 'function' ? cbOrOptions : undefined;
        const opts = typeof cbOrOptions === 'function' ? options : cbOrOptions;
        const flipped = opts?.flip ?? false;
        return this.where(({ exists }) => exists(relationship, cb, { flip: flipped }));
    };
    related = (relationship, cb) => {
        if (relationship.startsWith(SUBQ_PREFIX)) {
            throw new Error(`Relationship names may not start with "${SUBQ_PREFIX}". That is a reserved prefix.`);
        }
        cb = cb ?? (q => q);
        const related = this.#schema.relationships[this.#tableName][relationship];
        assert(related, 'Invalid relationship');
        if (isOneHop(related)) {
            const { destSchema, destField, sourceField, cardinality } = related[0];
            const q = this[newQuerySymbol](this._delegate, this.#schema, destSchema, {
                table: destSchema,
                alias: relationship,
            }, {
                relationships: {},
                singular: cardinality === 'one',
            }, this.customQueryID, undefined);
            // Intentionally not setting to `one` as it is a perf degradation
            // and the user should not be making the mistake of setting cardinality to
            // `one` when it is actually not.
            // if (cardinality === 'one') {
            //   q = q.one();
            // }
            const sq = cb(q);
            assert(isCompoundKey(sourceField), 'The source of a relationship must specify at last 1 field');
            assert(isCompoundKey(destField), 'The destination of a relationship must specify at last 1 field');
            assert(sourceField.length === destField.length, 'The source and destination of a relationship must have the same number of fields');
            return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
                ...this._ast,
                related: [
                    ...(this._ast.related ?? []),
                    {
                        system: this.#system,
                        correlation: {
                            parentField: sourceField,
                            childField: destField,
                        },
                        subquery: addPrimaryKeysToAst(this.#schema.tables[destSchema], sq._ast),
                    },
                ],
            }, {
                ...this.format,
                relationships: {
                    ...this.format.relationships,
                    [relationship]: sq.format,
                },
            }, this.customQueryID, this.#currentJunction);
        }
        if (isTwoHop(related)) {
            const [firstRelation, secondRelation] = related;
            const { destSchema } = secondRelation;
            const junctionSchema = firstRelation.destSchema;
            const sq = cb(this[newQuerySymbol](this._delegate, this.#schema, destSchema, {
                table: destSchema,
                alias: relationship,
            }, {
                relationships: {},
                singular: secondRelation.cardinality === 'one',
            }, this.customQueryID, relationship));
            assert(isCompoundKey(firstRelation.sourceField), 'Invalid relationship');
            assert(isCompoundKey(firstRelation.destField), 'Invalid relationship');
            assert(isCompoundKey(secondRelation.sourceField), 'Invalid relationship');
            assert(isCompoundKey(secondRelation.destField), 'Invalid relationship');
            return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
                ...this._ast,
                related: [
                    ...(this._ast.related ?? []),
                    {
                        system: this.#system,
                        correlation: {
                            parentField: firstRelation.sourceField,
                            childField: firstRelation.destField,
                        },
                        hidden: true,
                        subquery: {
                            table: junctionSchema,
                            alias: relationship,
                            orderBy: addPrimaryKeys(this.#schema.tables[junctionSchema], undefined),
                            related: [
                                {
                                    system: this.#system,
                                    correlation: {
                                        parentField: secondRelation.sourceField,
                                        childField: secondRelation.destField,
                                    },
                                    subquery: addPrimaryKeysToAst(this.#schema.tables[destSchema], sq._ast),
                                },
                            ],
                        },
                    },
                ],
            }, {
                ...this.format,
                relationships: {
                    ...this.format.relationships,
                    [relationship]: sq.format,
                },
            }, this.customQueryID, this.#currentJunction);
        }
        throw new Error(`Invalid relationship ${relationship}`);
    };
    where = (fieldOrExpressionFactory, opOrValue, value) => {
        let cond;
        if (typeof fieldOrExpressionFactory === 'function') {
            cond = fieldOrExpressionFactory(new ExpressionBuilder(this._exists));
        }
        else {
            assert(opOrValue !== undefined, 'Invalid condition');
            cond = cmp(fieldOrExpressionFactory, opOrValue, value);
        }
        const existingWhere = this._ast.where;
        if (existingWhere) {
            cond = and(existingWhere, cond);
        }
        const where = simplifyCondition(cond);
        return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
            ...this._ast,
            where,
        }, this.format, this.customQueryID, this.#currentJunction);
    };
    start = (row, opts) => this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
        ...this._ast,
        start: {
            row,
            exclusive: !opts?.inclusive,
        },
    }, this.format, this.customQueryID, this.#currentJunction);
    limit = (limit) => {
        if (limit < 0) {
            throw new Error('Limit must be non-negative');
        }
        if ((limit | 0) !== limit) {
            throw new Error('Limit must be an integer');
        }
        if (this.#currentJunction) {
            throw new NotImplementedError('Limit is not supported in junction relationships yet. Junction relationship being limited: ' +
                this.#currentJunction);
        }
        return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
            ...this._ast,
            limit,
        }, this.format, this.customQueryID, this.#currentJunction);
    };
    orderBy = (field, direction) => {
        if (this.#currentJunction) {
            throw new NotImplementedError('Order by is not supported in junction relationships yet. Junction relationship being ordered: ' +
                this.#currentJunction);
        }
        return this[newQuerySymbol](this._delegate, this.#schema, this.#tableName, {
            ...this._ast,
            orderBy: [...(this._ast.orderBy ?? []), [field, direction]],
        }, this.format, this.customQueryID, this.#currentJunction);
    };
    _exists = (relationship, cb, options) => {
        cb = cb ?? (q => q);
        const flip = options?.flip ?? false;
        const related = this.#schema.relationships[this.#tableName][relationship];
        assert(related, 'Invalid relationship');
        if (isOneHop(related)) {
            const { destSchema, sourceField, destField } = related[0];
            assert(isCompoundKey(sourceField), 'Invalid relationship');
            assert(isCompoundKey(destField), 'Invalid relationship');
            const sq = cb(this[newQuerySymbol](this._delegate, this.#schema, destSchema, {
                table: destSchema,
                alias: `${SUBQ_PREFIX}${relationship}`,
            }, defaultFormat, this.customQueryID, undefined));
            return {
                type: 'correlatedSubquery',
                related: {
                    system: this.#system,
                    correlation: {
                        parentField: sourceField,
                        childField: destField,
                    },
                    subquery: addPrimaryKeysToAst(this.#schema.tables[destSchema], sq._ast),
                },
                op: 'EXISTS',
                flip,
            };
        }
        if (isTwoHop(related)) {
            const [firstRelation, secondRelation] = related;
            assert(isCompoundKey(firstRelation.sourceField), 'Invalid relationship');
            assert(isCompoundKey(firstRelation.destField), 'Invalid relationship');
            assert(isCompoundKey(secondRelation.sourceField), 'Invalid relationship');
            assert(isCompoundKey(secondRelation.destField), 'Invalid relationship');
            const { destSchema } = secondRelation;
            const junctionSchema = firstRelation.destSchema;
            const queryToDest = cb(this[newQuerySymbol](this._delegate, this.#schema, destSchema, {
                table: destSchema,
                alias: `${SUBQ_PREFIX}zhidden_${relationship}`,
            }, defaultFormat, this.customQueryID, relationship));
            return {
                type: 'correlatedSubquery',
                related: {
                    system: this.#system,
                    correlation: {
                        parentField: firstRelation.sourceField,
                        childField: firstRelation.destField,
                    },
                    subquery: {
                        table: junctionSchema,
                        alias: `${SUBQ_PREFIX}${relationship}`,
                        orderBy: addPrimaryKeys(this.#schema.tables[junctionSchema], undefined),
                        where: {
                            type: 'correlatedSubquery',
                            related: {
                                system: this.#system,
                                correlation: {
                                    parentField: secondRelation.sourceField,
                                    childField: secondRelation.destField,
                                },
                                subquery: addPrimaryKeysToAst(this.#schema.tables[destSchema], queryToDest._ast),
                            },
                            op: 'EXISTS',
                            flip,
                        },
                    },
                },
                op: 'EXISTS',
                flip,
            };
        }
        throw new Error(`Invalid relationship ${relationship}`);
    };
    #completedAST;
    _completeAst() {
        if (!this.#completedAST) {
            const finalOrderBy = addPrimaryKeys(this.#schema.tables[this.#tableName], this._ast.orderBy);
            if (this._ast.start) {
                const { row } = this._ast.start;
                const narrowedRow = {};
                for (const [field] of finalOrderBy) {
                    narrowedRow[field] = row[field];
                }
                this.#completedAST = {
                    ...this._ast,
                    start: {
                        ...this._ast.start,
                        row: narrowedRow,
                    },
                    orderBy: finalOrderBy,
                };
            }
            else {
                this.#completedAST = {
                    ...this._ast,
                    orderBy: addPrimaryKeys(this.#schema.tables[this.#tableName], this._ast.orderBy),
                };
            }
        }
        return this.#completedAST;
    }
}
const completedAstSymbol = Symbol();
export function completedAST(q) {
    return q[completedAstSymbol];
}
export class QueryImpl extends AbstractQuery {
    #system;
    constructor(delegate, schema, tableName, ast = { table: tableName }, format = defaultFormat, system = 'client', customQueryID, currentJunction) {
        super(delegate, schema, tableName, ast, format, system, customQueryID, currentJunction);
        this.#system = system;
    }
    get [completedAstSymbol]() {
        return this._completeAst();
    }
    [newQuerySymbol](delegate, schema, tableName, ast, format, customQueryID, currentJunction) {
        return new QueryImpl(delegate, schema, tableName, ast, format, this.#system, customQueryID, currentJunction);
    }
    materialize(factoryOrTTL, ttl = DEFAULT_TTL_MS) {
        const delegate = must(this._delegate, 'materialize requires a query delegate to be set');
        let factory;
        if (typeof factoryOrTTL === 'function') {
            factory = factoryOrTTL;
        }
        else {
            ttl = factoryOrTTL ?? DEFAULT_TTL_MS;
        }
        const ast = this._completeAst();
        const queryID = this.customQueryID
            ? hashOfNameAndArgs(this.customQueryID.name, this.customQueryID.args)
            : this.hash();
        const queryCompleteResolver = resolver();
        let queryComplete = delegate.defaultQueryComplete;
        const updateTTL = (newTTL) => {
            this.customQueryID
                ? delegate.updateCustomQuery(this.customQueryID, newTTL)
                : delegate.updateServerQuery(ast, newTTL);
        };
        const gotCallback = (got, error) => {
            if (error) {
                queryCompleteResolver.reject(error);
                queryComplete = error;
                return;
            }
            if (got) {
                delegate.addMetric('query-materialization-end-to-end', performance.now() - t0, queryID, ast);
                queryComplete = true;
                queryCompleteResolver.resolve(true);
            }
        };
        let removeCommitObserver;
        const onDestroy = () => {
            input.destroy();
            removeCommitObserver?.();
            removeAddedQuery();
        };
        const t0 = performance.now();
        const removeAddedQuery = this.customQueryID
            ? delegate.addCustomQuery(ast, this.customQueryID, ttl, gotCallback)
            : delegate.addServerQuery(ast, ttl, gotCallback);
        const input = buildPipeline(ast, delegate, queryID);
        const view = delegate.batchViewUpdates(() => (factory ?? arrayViewFactory)(this, input, this.format, onDestroy, cb => {
            removeCommitObserver = delegate.onTransactionCommit(cb);
        }, queryComplete || queryCompleteResolver.promise, updateTTL));
        delegate.addMetric('query-materialization-client', performance.now() - t0, queryID);
        return view;
    }
    run(options) {
        const delegate = must(this._delegate, 'run requires a query delegate to be set');
        delegate.assertValidRunOptions(options);
        const v = this.materialize(options?.ttl);
        if (options?.type === 'complete') {
            return new Promise(resolve => {
                v.addListener((data, type) => {
                    if (type === 'complete') {
                        v.destroy();
                        resolve(data);
                    }
                    else if (type === 'error') {
                        v.destroy();
                        resolve(Promise.reject(data));
                    }
                });
            });
        }
        options?.type;
        const ret = v.data;
        v.destroy();
        return Promise.resolve(ret);
    }
    preload(options) {
        const delegate = must(this._delegate, 'preload requires a query delegate to be set');
        const ttl = options?.ttl ?? DEFAULT_PRELOAD_TTL_MS;
        const ast = this._completeAst();
        const { resolve, promise: complete } = resolver();
        if (this.customQueryID) {
            const cleanup = delegate.addCustomQuery(ast, this.customQueryID, ttl, got => {
                if (got) {
                    resolve();
                }
            });
            return {
                cleanup,
                complete,
            };
        }
        const cleanup = delegate.addServerQuery(ast, ttl, got => {
            if (got) {
                resolve();
            }
        });
        return {
            cleanup,
            complete,
        };
    }
}
function addPrimaryKeys(schema, orderBy) {
    orderBy = orderBy ?? [];
    const { primaryKey } = schema;
    const primaryKeysToAdd = new Set(primaryKey);
    for (const [field] of orderBy) {
        primaryKeysToAdd.delete(field);
    }
    if (primaryKeysToAdd.size === 0) {
        return orderBy;
    }
    return [
        ...orderBy,
        ...[...primaryKeysToAdd].map(key => [key, 'asc']),
    ];
}
function addPrimaryKeysToAst(schema, ast) {
    return {
        ...ast,
        orderBy: addPrimaryKeys(schema, ast.orderBy),
    };
}
function arrayViewFactory(_query, input, format, onDestroy, onTransactionCommit, queryComplete, updateTTL) {
    const v = new ArrayView(input, format, queryComplete, updateTTL);
    v.onDestroy = onDestroy;
    onTransactionCommit(() => {
        v.flush();
    });
    return v;
}
function isCompoundKey(field) {
    return Array.isArray(field) && field.length >= 1;
}
function isOneHop(r) {
    return r.length === 1;
}
function isTwoHop(r) {
    return r.length === 2;
}
//# sourceMappingURL=query-impl.js.map