import {
  ALIAS_REPLACEMENT,
  ALIAS_REPLACEMENT_RE,
  ArrayType,
  JsonType,
  inspect,
  isRaw,
  LockMode,
  OptimisticLockError,
  QueryOperator,
  QueryOrderNumeric,
  raw,
  Raw,
  QueryHelper,
  ReferenceKind,
  Utils,
  ValidationError,
} from '@mikro-orm/core';
import { EMBEDDABLE_ARRAY_OPS, JoinType, QueryType } from './enums.js';
/**
 * @internal
 */
export class QueryBuilderHelper {
  #platform;
  #metadata;
  #entityName;
  #alias;
  #aliasMap;
  #subQueries;
  #driver;
  #tptAliasMap;
  /** Monotonically increasing counter for unique JSON array iteration aliases within a single query. */
  #jsonAliasCounter = 0;
  constructor(entityName, alias, aliasMap, subQueries, driver, tptAliasMap = {}) {
    this.#entityName = entityName;
    this.#alias = alias;
    this.#aliasMap = aliasMap;
    this.#subQueries = subQueries;
    this.#driver = driver;
    this.#tptAliasMap = tptAliasMap;
    this.#platform = this.#driver.getPlatform();
    this.#metadata = this.#driver.getMetadata();
  }
  /**
   * For TPT inheritance, finds the correct alias for a property based on which entity owns it.
   * Returns the main alias if not a TPT property or if the property belongs to the main entity.
   */
  getTPTAliasForProperty(propName, defaultAlias) {
    const meta = this.#aliasMap[defaultAlias]?.meta ?? this.#metadata.get(this.#entityName);
    if (meta?.inheritanceType !== 'tpt' || !meta.tptParent) {
      return defaultAlias;
    }
    // Check if property is in the main entity's ownProps
    if (meta.ownProps?.some(p => p.name === propName || p.fieldNames?.includes(propName))) {
      return defaultAlias;
    }
    // Walk up the TPT hierarchy to find which parent owns this property
    let parentMeta = meta.tptParent;
    while (parentMeta) {
      const parentAlias = this.#tptAliasMap[parentMeta.className];
      if (parentAlias && parentMeta.ownProps?.some(p => p.name === propName || p.fieldNames?.includes(propName))) {
        return parentAlias;
      }
      parentMeta = parentMeta.tptParent;
    }
    // Property not found in hierarchy, return default alias
    return defaultAlias;
  }
  mapper(field, type = QueryType.SELECT, value, alias, schema) {
    if (isRaw(field)) {
      return raw(field.sql, field.params);
    }
    if (Raw.isKnownFragmentSymbol(field)) {
      return Raw.getKnownFragment(field);
    }
    /* v8 ignore next */
    if (typeof field !== 'string') {
      return field;
    }
    const isTableNameAliasRequired = this.isTableNameAliasRequired(type);
    const fields = Utils.splitPrimaryKeys(field);
    if (fields.length > 1) {
      const parts = [];
      for (const p of fields) {
        const [a, f] = this.splitField(p);
        const prop = this.getProperty(f, a);
        const fkIdx2 = prop?.fieldNames.findIndex(name => name === f) ?? -1;
        if (fkIdx2 !== -1) {
          parts.push(
            this.mapper(
              a !== this.#alias ? `${a}.${prop.fieldNames[fkIdx2]}` : prop.fieldNames[fkIdx2],
              type,
              value,
              alias,
            ),
          );
        } else if (prop) {
          parts.push(...prop.fieldNames.map(f => this.mapper(a !== this.#alias ? `${a}.${f}` : f, type, value, alias)));
        } else {
          parts.push(this.mapper(a !== this.#alias ? `${a}.${f}` : f, type, value, alias));
        }
      }
      // flatten the value if we see we are expanding nested composite key
      // hackish, but cleaner solution would require quite a lot of refactoring
      if (fields.length !== parts.length && Array.isArray(value)) {
        value.forEach(row => {
          if (Array.isArray(row)) {
            const tmp = Utils.flatten(row);
            row.length = 0;
            row.push(...tmp);
          }
        });
      }
      return raw('(' + parts.map(part => this.#platform.quoteIdentifier(part)).join(', ') + ')');
    }
    const [a, f] = this.splitField(field);
    const prop = this.getProperty(f, a);
    // For TPT inheritance, resolve the correct alias for this property
    // Only apply TPT resolution when `a` is an actual table alias (in aliasMap),
    // not when it's an embedded property name like 'profile1.identity.links'
    const isTableAlias = !!this.#aliasMap[a];
    const baseAlias = isTableAlias ? a : this.#alias;
    const resolvedAlias = isTableAlias ? this.getTPTAliasForProperty(prop?.name ?? f, a) : this.#alias;
    const aliasPrefix = isTableNameAliasRequired ? resolvedAlias + '.' : '';
    const fkIdx2 = prop?.fieldNames.findIndex(name => name === f) ?? -1;
    const fkIdx = fkIdx2 === -1 ? 0 : fkIdx2;
    if (a === prop?.embedded?.[0]) {
      return aliasPrefix + prop.fieldNames[fkIdx];
    }
    const noPrefix = prop?.persist === false;
    if (prop?.fieldNameRaw) {
      return raw(this.prefix(field, isTableNameAliasRequired));
    }
    if (prop?.formula) {
      const alias2 = this.#platform.quoteIdentifier(a).toString();
      const aliasName = alias === undefined ? prop.fieldNames[0] : alias;
      const as = aliasName === null ? '' : ` as ${this.#platform.quoteIdentifier(aliasName)}`;
      const meta = this.#aliasMap[a]?.meta ?? this.#metadata.get(this.#entityName);
      const table = this.createFormulaTable(alias2, meta, schema);
      const columns = meta.createColumnMappingObject(p => this.getTPTAliasForProperty(p.name, a), alias2);
      let value = this.#driver.evaluateFormula(prop.formula, columns, table);
      if (!this.isTableNameAliasRequired(type)) {
        value = value.replaceAll(alias2 + '.', '');
      }
      return raw(`${value}${as}`);
    }
    if (prop?.hasConvertToJSValueSQL && type !== QueryType.UPSERT) {
      let valueSQL;
      if (prop.fieldNames.length > 1 && fkIdx !== -1) {
        const fk = prop.targetMeta.getPrimaryProps()[fkIdx];
        const prefixed = this.prefix(field, isTableNameAliasRequired, true, fkIdx);
        valueSQL = fk.customType.convertToJSValueSQL(prefixed, this.#platform);
      } else {
        const prefixed = this.prefix(field, isTableNameAliasRequired, true);
        valueSQL = prop.customType.convertToJSValueSQL(prefixed, this.#platform);
      }
      if (alias === null) {
        return raw(valueSQL);
      }
      return raw(`${valueSQL} as ${this.#platform.quoteIdentifier(alias ?? prop.fieldNames[fkIdx])}`);
    }
    let ret = this.prefix(field, false, false, fkIdx);
    if (alias) {
      ret += ' as ' + alias;
    }
    if (!isTableNameAliasRequired || this.isPrefixed(ret) || noPrefix) {
      return ret;
    }
    return resolvedAlias + '.' + ret;
  }
  processData(data, convertCustomTypes, multi = false) {
    if (Array.isArray(data)) {
      return data.map(d => this.processData(d, convertCustomTypes, true));
    }
    const meta = this.#metadata.find(this.#entityName);
    data = this.#driver.mapDataToFieldNames(data, true, meta?.properties, convertCustomTypes);
    if (!Utils.hasObjectKeys(data) && meta && multi) {
      /* v8 ignore next */
      data[meta.getPrimaryProps()[0].fieldNames[0]] = this.#platform.usesDefaultKeyword() ? raw('default') : undefined;
    }
    return data;
  }
  joinOneToReference(prop, ownerAlias, alias, type, cond = {}, schema) {
    const prop2 = prop.targetMeta.properties[prop.mappedBy || prop.inversedBy];
    const table = this.getTableName(prop.targetMeta.class);
    const joinColumns = prop.owner ? prop.referencedColumnNames : prop2.joinColumns;
    const inverseJoinColumns = prop.referencedColumnNames;
    const primaryKeys = prop.owner ? prop.joinColumns : prop2.referencedColumnNames;
    schema ??= prop.targetMeta?.schema === '*' ? '*' : this.#driver.getSchemaName(prop.targetMeta);
    cond = Utils.merge(cond, prop.where);
    // For inverse side of polymorphic relations, add discriminator condition
    if (!prop.owner && prop2.polymorphic && prop2.discriminatorColumn && prop2.discriminatorMap) {
      const ownerMeta = this.#aliasMap[ownerAlias]?.meta ?? this.#metadata.get(this.#entityName);
      const discriminatorValue = QueryHelper.findDiscriminatorValue(prop2.discriminatorMap, ownerMeta.class);
      if (discriminatorValue) {
        cond[`${alias}.${prop2.discriminatorColumn}`] = discriminatorValue;
      }
    }
    return {
      prop,
      type,
      cond,
      ownerAlias,
      alias,
      table,
      schema,
      joinColumns,
      inverseJoinColumns,
      primaryKeys,
    };
  }
  joinManyToOneReference(prop, ownerAlias, alias, type, cond = {}, schema) {
    return {
      prop,
      type,
      cond,
      ownerAlias,
      alias,
      table: this.getTableName(prop.targetMeta.class),
      schema: prop.targetMeta?.schema === '*' ? '*' : this.#driver.getSchemaName(prop.targetMeta, { schema }),
      joinColumns: prop.referencedColumnNames,
      // For polymorphic relations, fieldNames includes the discriminator column which is not
      // part of the join condition - use joinColumns (the FK columns only) instead
      primaryKeys: prop.polymorphic ? prop.joinColumns : prop.fieldNames,
    };
  }
  joinManyToManyReference(prop, ownerAlias, alias, pivotAlias, type, cond, path, schema) {
    const pivotMeta = this.#metadata.find(prop.pivotEntity);
    const ret = {
      [`${ownerAlias}.${prop.name}#${pivotAlias}`]: {
        prop,
        type,
        ownerAlias,
        alias: pivotAlias,
        inverseAlias: alias,
        joinColumns: prop.joinColumns,
        inverseJoinColumns: prop.inverseJoinColumns,
        primaryKeys: prop.referencedColumnNames,
        cond: {},
        table: pivotMeta.tableName,
        schema: prop.targetMeta?.schema === '*' ? '*' : this.#driver.getSchemaName(pivotMeta, { schema }),
        path: path.endsWith('[pivot]') ? path : `${path}[pivot]`,
      },
    };
    if (type === JoinType.pivotJoin) {
      return ret;
    }
    const prop2 = pivotMeta.relations[prop.owner ? 1 : 0];
    ret[`${pivotAlias}.${prop2.name}#${alias}`] = this.joinManyToOneReference(
      prop2,
      pivotAlias,
      alias,
      type,
      cond,
      schema,
    );
    ret[`${pivotAlias}.${prop2.name}#${alias}`].path = path;
    const tmp = prop2.referencedTableName.split('.');
    ret[`${pivotAlias}.${prop2.name}#${alias}`].schema ??= tmp.length > 1 ? tmp[0] : undefined;
    return ret;
  }
  processJoins(qb, joins, schema, schemaOverride) {
    Object.values(joins).forEach(join => {
      if ([JoinType.nestedInnerJoin, JoinType.nestedLeftJoin].includes(join.type)) {
        return;
      }
      const { sql, params } = this.createJoinExpression(join, joins, schema, schemaOverride);
      qb.join(sql, params);
    });
  }
  createJoinExpression(join, joins, schema, schemaOverride) {
    let table = join.table;
    const method =
      {
        [JoinType.nestedInnerJoin]: 'inner join',
        [JoinType.nestedLeftJoin]: 'left join',
        [JoinType.pivotJoin]: 'left join',
      }[join.type] ?? join.type;
    const conditions = [];
    const params = [];
    schema = join.schema === '*' ? schema : (join.schema ?? schemaOverride);
    if (schema && schema !== this.#platform.getDefaultSchemaName()) {
      table = `${schema}.${table}`;
    }
    if (join.prop.name !== '__subquery__') {
      join.primaryKeys.forEach((primaryKey, idx) => {
        const right = `${join.alias}.${join.joinColumns[idx]}`;
        if (join.prop.formula) {
          const quotedAlias = this.#platform.quoteIdentifier(join.ownerAlias).toString();
          const ownerMeta = this.#aliasMap[join.ownerAlias]?.meta ?? this.#metadata.get(this.#entityName);
          const table = this.createFormulaTable(quotedAlias, ownerMeta, schema);
          const columns = ownerMeta.createColumnMappingObject(
            p => this.getTPTAliasForProperty(p.name, join.ownerAlias),
            quotedAlias,
          );
          const left = this.#driver.evaluateFormula(join.prop.formula, columns, table);
          conditions.push(`${left} = ${this.#platform.quoteIdentifier(right)}`);
          return;
        }
        const left =
          join.prop.object && join.prop.fieldNameRaw
            ? join.prop.fieldNameRaw.replaceAll(ALIAS_REPLACEMENT, join.ownerAlias)
            : this.#platform.quoteIdentifier(`${join.ownerAlias}.${primaryKey}`);
        conditions.push(`${left} = ${this.#platform.quoteIdentifier(right)}`);
      });
    }
    if (
      join.prop.targetMeta?.root.inheritanceType === 'sti' &&
      join.prop.targetMeta?.discriminatorValue &&
      !join.path?.endsWith('[pivot]')
    ) {
      const typeProperty = join.prop.targetMeta.root.discriminatorColumn;
      const alias = join.inverseAlias ?? join.alias;
      join.cond[`${alias}.${typeProperty}`] = join.prop.targetMeta.discriminatorValue;
    }
    // For polymorphic relations, add discriminator condition to filter by target entity type
    if (join.prop.polymorphic && join.prop.discriminatorColumn && join.prop.discriminatorMap) {
      const discriminatorValue = QueryHelper.findDiscriminatorValue(
        join.prop.discriminatorMap,
        join.prop.targetMeta.class,
      );
      if (discriminatorValue) {
        const discriminatorCol = this.#platform.quoteIdentifier(`${join.ownerAlias}.${join.prop.discriminatorColumn}`);
        conditions.push(`${discriminatorCol} = ?`);
        params.push(discriminatorValue);
      }
    }
    let sql = method + ' ';
    if (join.nested) {
      const asKeyword = this.#platform.usesAsKeyword() ? ' as ' : ' ';
      sql += `(${this.#platform.quoteIdentifier(table)}${asKeyword}${this.#platform.quoteIdentifier(join.alias)}`;
      for (const nested of join.nested) {
        const { sql: nestedSql, params: nestedParams } = this.createJoinExpression(
          nested,
          joins,
          schema,
          schemaOverride,
        );
        sql += ' ' + nestedSql;
        params.push(...nestedParams);
      }
      sql += `)`;
    } else if (join.subquery) {
      const asKeyword = this.#platform.usesAsKeyword() ? ' as ' : ' ';
      sql += `(${join.subquery})${asKeyword}${this.#platform.quoteIdentifier(join.alias)}`;
    } else {
      sql +=
        this.#platform.quoteIdentifier(table) +
        (this.#platform.usesAsKeyword() ? ' as ' : ' ') +
        this.#platform.quoteIdentifier(join.alias);
    }
    const oldAlias = this.#alias;
    this.#alias = join.alias;
    const subquery = this._appendQueryCondition(QueryType.SELECT, join.cond);
    this.#alias = oldAlias;
    if (subquery.sql) {
      conditions.push(subquery.sql);
      subquery.params.forEach(p => params.push(p));
    }
    if (conditions.length > 0) {
      sql += ` on ${conditions.join(' and ')}`;
    }
    return { sql, params };
  }
  mapJoinColumns(type, join) {
    if (join.prop && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(join.prop.kind)) {
      return join.prop.fieldNames.map((_fieldName, idx) => {
        const columns = join.prop.owner ? join.joinColumns : join.inverseJoinColumns;
        return this.mapper(`${join.alias}.${columns[idx]}`, type, undefined, `${join.alias}__${columns[idx]}`);
      });
    }
    return [
      ...join.joinColumns.map(col => this.mapper(`${join.alias}.${col}`, type, undefined, `fk__${col}`)),
      ...join.inverseJoinColumns.map(col => this.mapper(`${join.alias}.${col}`, type, undefined, `fk__${col}`)),
    ];
  }
  isOneToOneInverse(field, meta) {
    meta ??= this.#metadata.find(this.#entityName);
    const prop = meta.properties[field.replace(/:ref$/, '')];
    return prop?.kind === ReferenceKind.ONE_TO_ONE && !prop.owner;
  }
  getTableName(entityName) {
    const meta = this.#metadata.find(entityName);
    return meta?.tableName ?? Utils.className(entityName);
  }
  /**
   * Checks whether the RE can be rewritten to simple LIKE query
   */
  isSimpleRegExp(re) {
    if (!(re instanceof RegExp)) {
      return false;
    }
    if (re.flags.includes('i')) {
      return false;
    }
    // when including the opening bracket/paren we consider it complex
    return !/[{[(]/.exec(re.source);
  }
  getRegExpParam(re) {
    const value = re.source
      .replace(/\.\*/g, '%') // .* -> %
      .replace(/\./g, '_') // .  -> _
      .replace(/\\_/g, '.') // \. -> .
      .replace(/^\^/g, '') // remove ^ from start
      .replace(/\$$/g, ''); // remove $ from end
    if (re.source.startsWith('^') && re.source.endsWith('$')) {
      return value;
    }
    if (re.source.startsWith('^')) {
      return value + '%';
    }
    if (re.source.endsWith('$')) {
      return '%' + value;
    }
    return `%${value}%`;
  }
  appendOnConflictClause(type, onConflict, qb) {
    onConflict.forEach(item => {
      const { fields, ignore } = item;
      const sub = qb.onConflict({ fields, ignore });
      Utils.runIfNotEmpty(() => {
        let mergeParam = item.merge;
        if (Utils.isObject(item.merge)) {
          mergeParam = {};
          Utils.keys(item.merge).forEach(key => {
            const k = this.mapper(key, type);
            mergeParam[k] = item.merge[key];
          });
        }
        if (Array.isArray(item.merge)) {
          mergeParam = item.merge.map(key => this.mapper(key, type));
        }
        sub.merge = mergeParam ?? [];
        if (item.where) {
          sub.where = this._appendQueryCondition(type, item.where);
        }
      }, 'merge' in item);
    });
  }
  appendQueryCondition(type, cond, qb, operator, method = 'where') {
    const { sql, params } = this._appendQueryCondition(type, cond, operator);
    qb[method](sql, params);
  }
  _appendQueryCondition(type, cond, operator) {
    const parts = [];
    const params = [];
    for (const k of Utils.getObjectQueryKeys(cond)) {
      if (k === '$and' || k === '$or') {
        if (operator) {
          this.append(() => this.appendGroupCondition(type, k, cond[k]), parts, params, operator);
          continue;
        }
        this.append(() => this.appendGroupCondition(type, k, cond[k]), parts, params);
        continue;
      }
      if (k === '$not') {
        const res = this._appendQueryCondition(type, cond[k]);
        parts.push(`not (${res.sql})`);
        res.params.forEach(p => params.push(p));
        continue;
      }
      this.append(() => this.appendQuerySubCondition(type, cond, k), parts, params);
    }
    return { sql: parts.join(' and '), params };
  }
  append(cb, parts, params, operator) {
    const res = cb();
    if (['', '()'].includes(res.sql)) {
      return;
    }
    parts.push(operator === '$or' ? `(${res.sql})` : res.sql);
    res.params.forEach(p => params.push(p));
  }
  appendQuerySubCondition(type, cond, key) {
    const parts = [];
    const params = [];
    if (this.isSimpleRegExp(cond[key])) {
      parts.push(`${this.#platform.quoteIdentifier(this.mapper(key, type))} like ?`);
      params.push(this.getRegExpParam(cond[key]));
      return { sql: parts.join(' and '), params };
    }
    if (Utils.isPlainObject(cond[key]) && !Raw.isKnownFragmentSymbol(key)) {
      const [a, f] = this.splitField(key);
      const prop = this.getProperty(f, a);
      if (prop?.kind === ReferenceKind.EMBEDDED && prop.array) {
        const keys = Object.keys(cond[key]);
        const hasOnlyArrayOps = keys.every(k => EMBEDDABLE_ARRAY_OPS.includes(k));
        if (!hasOnlyArrayOps) {
          return this.processEmbeddedArrayCondition(cond[key], prop, a);
        }
      }
      // $elemMatch on JSON properties — iterate array elements via EXISTS subquery.
      // When combined with other operators (e.g. $contains), processObjectSubCondition
      // splits them first (size > 1), so $elemMatch arrives here alone.
      if (prop && cond[key].$elemMatch != null && Utils.getObjectKeysSize(cond[key]) === 1) {
        if (!(prop.customType instanceof JsonType)) {
          throw new ValidationError(
            `$elemMatch can only be used on JSON array properties, but '${this.#entityName}.${prop.name}' has type '${prop.type}'`,
          );
        }
        return this.processJsonElemMatch(cond[key].$elemMatch, prop, a);
      }
    }
    if (Utils.isPlainObject(cond[key]) || cond[key] instanceof RegExp) {
      return this.processObjectSubCondition(cond, key, type);
    }
    const op = cond[key] === null ? 'is' : '=';
    if (Raw.isKnownFragmentSymbol(key)) {
      const raw = Raw.getKnownFragment(key);
      const sql = raw.sql.replaceAll(ALIAS_REPLACEMENT, this.#alias);
      const value = Utils.asArray(cond[key]);
      params.push(...raw.params);
      if (value.length > 0) {
        const k = key;
        const val = this.getValueReplacement([k], value[0], params, k);
        parts.push(`${sql} ${op} ${val}`);
        return { sql: parts.join(' and '), params };
      }
      parts.push(sql);
      return { sql: parts.join(' and '), params };
    }
    const fields = Utils.splitPrimaryKeys(key);
    if (this.#subQueries[key]) {
      const val = this.getValueReplacement(fields, cond[key], params, key);
      parts.push(`(${this.#subQueries[key]}) ${op} ${val}`);
      return { sql: parts.join(' and '), params };
    }
    const val = this.getValueReplacement(fields, cond[key], params, key);
    parts.push(`${this.#platform.quoteIdentifier(this.mapper(key, type, cond[key], null))} ${op} ${val}`);
    return { sql: parts.join(' and '), params };
  }
  processObjectSubCondition(cond, key, type) {
    const parts = [];
    const params = [];
    let value = cond[key];
    const size = Utils.getObjectKeysSize(value);
    if (Utils.isPlainObject(value) && size === 0) {
      return { sql: '', params };
    }
    // grouped condition for one field, e.g. `{ age: { $gte: 10, $lt: 50 } }`
    if (size > 1) {
      const subCondition = Object.entries(value).map(([subKey, subValue]) => {
        return { [key]: { [subKey]: subValue } };
      });
      for (const sub of subCondition) {
        this.append(() => this._appendQueryCondition(type, sub, '$and'), parts, params);
      }
      return { sql: parts.join(' and '), params };
    }
    if (value instanceof RegExp) {
      value = this.#platform.getRegExpValue(value);
    }
    // operators
    const op = Object.keys(QueryOperator).find(op => op in value);
    /* v8 ignore next */
    if (!op) {
      throw ValidationError.invalidQueryCondition(cond);
    }
    const replacement = this.getOperatorReplacement(op, value);
    const rawField = Raw.isKnownFragmentSymbol(key);
    const fields = rawField ? [key] : Utils.splitPrimaryKeys(key);
    if (fields.length > 1 && Array.isArray(value[op])) {
      const singleTuple = !value[op].every(v => Array.isArray(v));
      if (!this.#platform.allowsComparingTuples()) {
        const mapped = fields.map(f => this.mapper(f, type));
        if (op === '$in') {
          const conds = value[op].map(() => {
            return `(${mapped.map(field => `${this.#platform.quoteIdentifier(field)} = ?`).join(' and ')})`;
          });
          parts.push(`(${conds.join(' or ')})`);
          params.push(...Utils.flatten(value[op]));
          return { sql: parts.join(' and '), params };
        }
        parts.push(...mapped.map(field => `${this.#platform.quoteIdentifier(field)} = ?`));
        params.push(...Utils.flatten(value[op]));
        return { sql: parts.join(' and '), params };
      }
      if (singleTuple) {
        const tmp =
          value[op].length === 1 && Utils.isPlainObject(value[op][0]) ? fields.map(f => value[op][0][f]) : value[op];
        const sql = `(${fields.map(() => '?').join(', ')})`;
        value[op] = raw(sql, tmp);
      }
    }
    if (this.#subQueries[key]) {
      const val = this.getValueReplacement(fields, value[op], params, op);
      parts.push(`(${this.#subQueries[key]}) ${replacement} ${val}`);
      return { sql: parts.join(' and '), params };
    }
    const [a, f] = rawField ? [] : this.splitField(key);
    const prop = f && this.getProperty(f, a);
    if (prop && [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
      return { sql: '', params };
    }
    if (op === '$fulltext') {
      /* v8 ignore next */
      if (!prop) {
        throw new Error(`Cannot use $fulltext operator on ${String(key)}, property not found`);
      }
      const { sql, params: params2 } = raw(this.#platform.getFullTextWhereClause(prop), {
        column: this.mapper(key, type, undefined, null),
        query: value[op],
      });
      parts.push(sql);
      params.push(...params2);
    } else if (['$in', '$nin'].includes(op) && Array.isArray(value[op]) && value[op].length === 0) {
      parts.push(`1 = ${op === '$in' ? 0 : 1}`);
    } else if (op === '$re') {
      const mappedKey = this.mapper(key, type, value[op], null);
      const processed = this.#platform.mapRegExpCondition(mappedKey, value);
      parts.push(processed.sql);
      params.push(...processed.params);
    } else if (value[op] instanceof Raw || typeof value[op]?.toRaw === 'function') {
      const query = value[op] instanceof Raw ? value[op] : value[op].toRaw();
      const mappedKey = this.mapper(key, type, query, null);
      let sql = query.sql;
      if (['$in', '$nin'].includes(op)) {
        sql = `(${sql})`;
      }
      parts.push(`${this.#platform.quoteIdentifier(mappedKey)} ${replacement} ${sql}`);
      params.push(...query.params);
    } else {
      const mappedKey = this.mapper(key, type, value[op], null);
      const val = this.getValueReplacement(fields, value[op], params, op, prop);
      parts.push(`${this.#platform.quoteIdentifier(mappedKey)} ${replacement} ${val}`);
    }
    return { sql: parts.join(' and '), params };
  }
  getValueReplacement(fields, value, params, key, prop) {
    if (Array.isArray(value)) {
      if (fields.length > 1) {
        const tmp = [];
        for (const field of value) {
          tmp.push(`(${field.map(() => '?').join(', ')})`);
          params.push(...field);
        }
        return `(${tmp.join(', ')})`;
      }
      if (prop?.customType instanceof ArrayType) {
        const item = prop.customType.convertToDatabaseValue(value, this.#platform, {
          fromQuery: true,
          key,
          mode: 'query',
        });
        params.push(item);
      } else {
        value.forEach(p => params.push(p));
      }
      return `(${value.map(() => '?').join(', ')})`;
    }
    if (value === null) {
      return 'null';
    }
    params.push(value);
    return '?';
  }
  getOperatorReplacement(op, value) {
    let replacement = QueryOperator[op];
    if (op === '$exists') {
      replacement = value[op] ? 'is not' : 'is';
      value[op] = null;
    }
    if (value[op] === null && ['$eq', '$ne'].includes(op)) {
      replacement = op === '$eq' ? 'is' : 'is not';
    }
    if (op === '$re') {
      replacement = this.#platform.getRegExpOperator(value[op], value.$flags);
    }
    if (replacement.includes('?')) {
      replacement = replacement.replaceAll('?', '\\?');
    }
    return replacement;
  }
  validateQueryOrder(orderBy) {
    const strKeys = [];
    const rawKeys = [];
    for (const key of Utils.getObjectQueryKeys(orderBy)) {
      const raw = Raw.getKnownFragment(key);
      if (raw) {
        rawKeys.push(raw);
      } else {
        strKeys.push(key);
      }
    }
    if (strKeys.length > 0 && rawKeys.length > 0) {
      const example = [
        ...strKeys.map(key => ({ [key]: orderBy[key] })),
        ...rawKeys.map(rawKey => ({ [`raw('${rawKey.sql}')`]: orderBy[rawKey] })),
      ];
      throw new Error(
        [
          `Invalid "orderBy": You are mixing field-based keys and raw SQL fragments inside a single object.`,
          `This is not allowed because object key order cannot reliably preserve evaluation order.`,
          `To fix this, split them into separate objects inside an array:\n`,
          `orderBy: ${inspect(example, { depth: 5 }).replace(/"raw\('(.*)'\)"/g, `[raw('$1')]`)}`,
        ].join('\n'),
      );
    }
  }
  getQueryOrder(type, orderBy, populate, collation) {
    if (Array.isArray(orderBy)) {
      return orderBy.flatMap(o => this.getQueryOrder(type, o, populate, collation));
    }
    return this.getQueryOrderFromObject(type, orderBy, populate, collation);
  }
  getQueryOrderFromObject(type, orderBy, populate, collation) {
    const ret = [];
    for (const key of Utils.getObjectQueryKeys(orderBy)) {
      const direction = orderBy[key];
      const order = typeof direction === 'number' ? QueryOrderNumeric[direction] : direction;
      if (Raw.isKnownFragmentSymbol(key)) {
        const raw = Raw.getKnownFragment(key);
        ret.push(
          ...this.#platform.getOrderByExpression(this.#platform.formatQuery(raw.sql, raw.params), order, collation),
        );
        continue;
      }
      for (const f of Utils.splitPrimaryKeys(key)) {
        // eslint-disable-next-line prefer-const
        let [alias, field] = this.splitField(f, true);
        alias = populate[alias] || alias;
        const prop = this.getProperty(field, alias);
        const noPrefix = (prop?.persist === false && !prop.formula && !prop.embedded) || Raw.isKnownFragment(f);
        const column = this.mapper(noPrefix ? field : `${alias}.${field}`, type, undefined, null);
        /* v8 ignore next */
        const rawColumn =
          typeof column === 'string'
            ? column
                .split('.')
                .map(e => this.#platform.quoteIdentifier(e))
                .join('.')
            : column;
        const customOrder = prop?.customOrder;
        let colPart = customOrder ? this.#platform.generateCustomOrder(rawColumn, customOrder) : rawColumn;
        if (isRaw(colPart)) {
          colPart = this.#platform.formatQuery(colPart.sql, colPart.params);
        }
        if (Array.isArray(order)) {
          order.forEach(part => ret.push(...this.getQueryOrderFromObject(type, part, populate, collation)));
        } else {
          ret.push(...this.#platform.getOrderByExpression(colPart, order, collation));
        }
      }
    }
    return ret;
  }
  splitField(field, greedyAlias = false) {
    const parts = field.split('.');
    const ref = parts[parts.length - 1].split(':')[1];
    if (ref) {
      parts[parts.length - 1] = parts[parts.length - 1].substring(0, parts[parts.length - 1].indexOf(':'));
    }
    if (parts.length === 1) {
      return [this.#alias, parts[0], ref];
    }
    if (greedyAlias) {
      const fromField = parts.pop();
      const fromAlias = parts.join('.');
      return [fromAlias, fromField, ref];
    }
    const fromAlias = parts.shift();
    const fromField = parts.join('.');
    return [fromAlias, fromField, ref];
  }
  getLockSQL(qb, lockMode, lockTables = [], joinsMap) {
    const meta = this.#metadata.find(this.#entityName);
    if (lockMode === LockMode.OPTIMISTIC && meta && !meta.versionProperty) {
      throw OptimisticLockError.lockFailed(Utils.className(this.#entityName));
    }
    if (lockMode !== LockMode.OPTIMISTIC && lockTables.length === 0 && joinsMap) {
      const joins = Object.values(joinsMap);
      const innerJoins = joins.filter(join =>
        [JoinType.innerJoin, JoinType.innerJoinLateral, JoinType.nestedInnerJoin].includes(join.type),
      );
      if (joins.length > innerJoins.length) {
        lockTables.push(this.#alias, ...innerJoins.map(join => join.alias));
      }
    }
    qb.lockMode(lockMode, lockTables);
  }
  updateVersionProperty(qb, data) {
    const meta = this.#metadata.find(this.#entityName);
    if (!meta?.versionProperty || meta.versionProperty in data) {
      return;
    }
    const versionProperty = meta.properties[meta.versionProperty];
    let sql = this.#platform.quoteIdentifier(versionProperty.fieldNames[0]) + ' + 1';
    if (versionProperty.runtimeType === 'Date') {
      sql = this.#platform.getCurrentTimestampSQL(versionProperty.length);
    }
    qb.update({ [versionProperty.fieldNames[0]]: raw(sql) });
  }
  prefix(field, always = false, quote = false, idx) {
    let ret;
    if (!this.isPrefixed(field)) {
      // For TPT inheritance, resolve the correct alias for this property
      const tptAlias = this.getTPTAliasForProperty(field, this.#alias);
      const alias = always ? (quote ? tptAlias : this.#platform.quoteIdentifier(tptAlias)) + '.' : '';
      const fieldName = this.fieldName(field, tptAlias, always, idx);
      if (fieldName instanceof Raw) {
        return fieldName.sql;
      }
      ret = alias + fieldName;
    } else {
      const [a, ...rest] = field.split('.');
      const f = rest.join('.');
      // For TPT inheritance, resolve the correct alias for this property
      // Only apply TPT resolution when `a` is an actual table alias (in aliasMap),
      // not when it's an embedded property name like 'profile1.identity.links'
      const isTableAlias = !!this.#aliasMap[a];
      const resolvedAlias = isTableAlias ? this.getTPTAliasForProperty(f, a) : a;
      const fieldName = this.fieldName(f, resolvedAlias, always, idx);
      if (fieldName instanceof Raw) {
        return fieldName.sql;
      }
      ret = resolvedAlias + '.' + fieldName;
    }
    if (quote) {
      return this.#platform.quoteIdentifier(ret);
    }
    return ret;
  }
  appendGroupCondition(type, operator, subCondition) {
    const parts = [];
    const params = [];
    // single sub-condition can be ignored to reduce nesting of parens
    if (subCondition.length === 1 || operator === '$and') {
      for (const sub of subCondition) {
        this.append(() => this._appendQueryCondition(type, sub), parts, params);
      }
      return { sql: parts.join(' and '), params };
    }
    for (const sub of subCondition) {
      // skip nesting parens if the value is simple = scalar or object without operators or with only single key, being the operator
      const keys = Utils.getObjectQueryKeys(sub);
      const val = sub[keys[0]];
      const simple =
        !Utils.isPlainObject(val) ||
        Utils.getObjectKeysSize(val) === 1 ||
        Object.keys(val).every(k => !Utils.isOperator(k));
      if (keys.length === 1 && simple) {
        this.append(() => this._appendQueryCondition(type, sub, operator), parts, params);
        continue;
      }
      this.append(() => this._appendQueryCondition(type, sub), parts, params, operator);
    }
    return { sql: `(${parts.join(' or ')})`, params };
  }
  isPrefixed(field) {
    return !!/[\w`"[\]]+\./.exec(field);
  }
  fieldName(field, alias, always, idx = 0) {
    const prop = this.getProperty(field, alias);
    if (!prop) {
      return field;
    }
    if (prop.fieldNameRaw) {
      if (!always) {
        return raw(
          prop.fieldNameRaw
            .replace(new RegExp(ALIAS_REPLACEMENT_RE + '\\.?', 'g'), '')
            .replace(this.#platform.quoteIdentifier('') + '.', ''),
        );
      }
      if (alias) {
        return raw(prop.fieldNameRaw.replace(new RegExp(ALIAS_REPLACEMENT_RE, 'g'), alias));
      }
      /* v8 ignore next */
      return raw(prop.fieldNameRaw);
    }
    /* v8 ignore next */
    return prop.fieldNames?.[idx] ?? field;
  }
  getProperty(field, alias) {
    const entityName = this.#aliasMap[alias]?.entityName || this.#entityName;
    const meta = this.#metadata.find(entityName);
    // raw table name (e.g. CTE) — no metadata available
    if (!meta) {
      return undefined;
    }
    // check if `alias` is not matching an embedded property name instead of alias, e.g. `address.city`
    if (alias) {
      const prop = meta.properties[alias];
      if (prop?.kind === ReferenceKind.EMBEDDED) {
        const parts = field.split('.');
        const nest = p => (parts.length > 0 ? nest(p.embeddedProps[parts.shift()]) : p);
        return nest(prop);
      }
    }
    if (meta.properties[field]) {
      return meta.properties[field];
    }
    return meta.relations.find(prop => prop.fieldNames?.some(name => field === name));
  }
  isTableNameAliasRequired(type) {
    return [QueryType.SELECT, QueryType.COUNT].includes(type);
  }
  processEmbeddedArrayCondition(cond, prop, alias) {
    const column = this.#platform.quoteIdentifier(`${alias}.${prop.fieldNames[0]}`);
    const resolveProperty = key => {
      const { embProp, jsonPropName } = this.resolveEmbeddedProp(prop, key);
      return { name: jsonPropName, type: embProp.runtimeType ?? 'string' };
    };
    const invalidObjectError = key => ValidationError.invalidEmbeddableQuery(this.#entityName, key, prop.type);
    const parts = [];
    const allParams = [];
    // Top-level $not generates NOT EXISTS (no element matches the inner condition).
    const { $not, ...rest } = cond;
    if (Utils.hasObjectKeys(rest)) {
      const result = this.buildJsonArrayExists(rest, column, false, resolveProperty, invalidObjectError);
      if (result) {
        parts.push(result.sql);
        allParams.push(...result.params);
      }
    }
    if ($not != null) {
      if (!Utils.isPlainObject($not)) {
        throw new ValidationError(`Invalid query: $not in embedded array queries expects an object value`);
      }
      const result = this.buildJsonArrayExists($not, column, true, resolveProperty, invalidObjectError);
      if (result) {
        parts.push(result.sql);
        allParams.push(...result.params);
      }
    }
    if (parts.length === 0) {
      return { sql: '1 = 1', params: [] };
    }
    return { sql: parts.join(' and '), params: allParams };
  }
  buildJsonArrayExists(cond, column, negate, resolveProperty, invalidObjectError) {
    const jeAlias = `__je${this.#jsonAliasCounter++}`;
    const referencedProps = new Map();
    const { sql: whereSql, params } = this.buildArrayElementWhere(
      cond,
      jeAlias,
      referencedProps,
      resolveProperty,
      invalidObjectError,
    );
    if (!whereSql) {
      return null;
    }
    const from = this.#platform.getJsonArrayFromSQL(column, jeAlias, [...referencedProps.values()]);
    const exists = this.#platform.getJsonArrayExistsSQL(from, whereSql);
    return { sql: negate ? `not ${exists}` : exists, params };
  }
  resolveEmbeddedProp(prop, key) {
    const embProp = prop.embeddedProps[key] ?? Object.values(prop.embeddedProps).find(p => p.name === key);
    if (!embProp) {
      throw ValidationError.invalidEmbeddableQuery(this.#entityName, key, prop.type);
    }
    const prefix = `${prop.fieldNames[0]}~`;
    const raw = embProp.fieldNames[0];
    const jsonPropName = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
    return { embProp, jsonPropName };
  }
  buildEmbeddedArrayOperatorCondition(lhs, value, params) {
    const supported = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$not', '$like', '$exists']);
    const parts = [];
    // Clone to avoid getOperatorReplacement mutating the original (it sets value[op] = null for $exists).
    value = { ...value };
    for (const op of Object.keys(value)) {
      if (!supported.has(op)) {
        throw new ValidationError(`Operator ${op} is not supported in embedded array queries`);
      }
      const replacement = this.getOperatorReplacement(op, value);
      const val = value[op];
      if (['$in', '$nin'].includes(op)) {
        if (!Array.isArray(val)) {
          throw new ValidationError(`Invalid query: ${op} operator expects an array value`);
        } else if (val.length === 0) {
          parts.push(`1 = ${op === '$in' ? 0 : 1}`);
        } else {
          val.forEach(v => params.push(v));
          parts.push(`${lhs} ${replacement} (${val.map(() => '?').join(', ')})`);
        }
      } else if (op === '$exists') {
        parts.push(`${lhs} ${replacement} null`);
      } else if (val === null) {
        parts.push(`${lhs} ${replacement} null`);
      } else {
        parts.push(`${lhs} ${replacement} ?`);
        params.push(val);
      }
    }
    return parts.join(' and ');
  }
  processJsonElemMatch(cond, prop, alias) {
    const column = this.#platform.quoteIdentifier(`${alias}.${prop.fieldNames[0]}`);
    const result = this.buildJsonArrayExists(
      cond,
      column,
      false,
      (key, value) => {
        this.#platform.validateJsonPropertyName(key);
        return { name: key, type: this.inferJsonValueType(value) };
      },
      () => ValidationError.invalidQueryCondition(cond),
    );
    return result ?? { sql: '1 = 1', params: [] };
  }
  /**
   * Shared logic for building WHERE conditions inside JSON array EXISTS subqueries.
   * Used by both embedded array queries (metadata-driven) and $elemMatch (type-inferred).
   */
  buildArrayElementWhere(cond, jeAlias, referencedProps, resolveProperty, invalidObjectError) {
    const parts = [];
    const params = [];
    for (const k of Object.keys(cond)) {
      if (k === '$and' || k === '$or') {
        const items = cond[k];
        if (items.length === 0) {
          continue;
        }
        const subParts = [];
        for (const item of items) {
          const sub = this.buildArrayElementWhere(item, jeAlias, referencedProps, resolveProperty, invalidObjectError);
          if (sub.sql) {
            subParts.push(sub.sql);
            params.push(...sub.params);
          }
        }
        if (subParts.length > 0) {
          const joiner = k === '$or' ? ' or ' : ' and ';
          parts.push(`(${subParts.join(joiner)})`);
        }
        continue;
      }
      // Within $or/$and scope, $not provides element-level negation:
      // "this element does not match the condition".
      if (k === '$not') {
        const sub = this.buildArrayElementWhere(cond[k], jeAlias, referencedProps, resolveProperty, invalidObjectError);
        if (sub.sql) {
          parts.push(`not (${sub.sql})`);
          params.push(...sub.params);
        }
        continue;
      }
      const value = cond[k];
      const { name, type } = resolveProperty(k, value);
      referencedProps.set(k, { name, type });
      const lhs = this.#platform.getJsonArrayElementPropertySQL(jeAlias, name, type);
      if (Utils.isPlainObject(value)) {
        const valueKeys = Object.keys(value);
        if (valueKeys.some(vk => !Utils.isOperator(vk))) {
          throw invalidObjectError(k);
        }
        const sub = this.buildEmbeddedArrayOperatorCondition(lhs, value, params);
        parts.push(sub);
      } else if (value === null) {
        parts.push(`${lhs} is null`);
      } else {
        parts.push(`${lhs} = ?`);
        params.push(value);
      }
    }
    return { sql: parts.join(' and '), params };
  }
  inferJsonValueType(value) {
    if (typeof value === 'number') {
      return 'number';
    }
    if (typeof value === 'boolean') {
      return 'boolean';
    }
    if (typeof value === 'bigint') {
      return 'bigint';
    }
    if (Utils.isPlainObject(value)) {
      for (const v of Object.values(value)) {
        if (typeof v === 'number') {
          return 'number';
        }
        if (typeof v === 'boolean') {
          return 'boolean';
        }
        if (typeof v === 'bigint') {
          return 'bigint';
        }
        if (Array.isArray(v) && v.length > 0) {
          if (typeof v[0] === 'number') {
            return 'number';
          }
          if (typeof v[0] === 'boolean') {
            return 'boolean';
          }
        }
      }
    }
    return 'string';
  }
  processOnConflictCondition(cond, schema) {
    const meta = this.#metadata.get(this.#entityName);
    const tableName = meta.tableName;
    for (const key of Object.keys(cond)) {
      const mapped = this.mapper(key, QueryType.UPSERT);
      Utils.renameKey(cond, key, tableName + '.' + mapped);
    }
    return cond;
  }
  createFormulaTable(alias, meta, schema) {
    const effectiveSchema = schema ?? (meta.schema !== '*' ? meta.schema : undefined);
    const qualifiedName = effectiveSchema ? `${effectiveSchema}.${meta.tableName}` : meta.tableName;
    return {
      alias,
      name: meta.tableName,
      schema: effectiveSchema,
      qualifiedName,
      toString: () => alias,
    };
  }
}
