import { QueryHelper } from '../utils/QueryHelper.js';
import { Utils } from '../utils/Utils.js';
import { ValidationError } from '../errors.js';
import { LoadStrategy, PopulatePath, ReferenceKind } from '../enums.js';
import { Reference } from './Reference.js';
import { helper } from './wrap.js';
import { expandDotPaths } from './utils.js';
import { Raw } from '../utils/RawQueryFragment.js';
/** Responsible for batch-loading entity relations using either select-in or joined loading strategies. */
export class EntityLoader {
  #metadata;
  #driver;
  #em;
  constructor(em) {
    this.#em = em;
    this.#metadata = this.#em.getMetadata();
    this.#driver = this.#em.getDriver();
  }
  /**
   * Loads specified relations in batch.
   * This will execute one query for each relation, that will populate it on all the specified entities.
   */
  async populate(entityName, entities, populate, options) {
    if (entities.length === 0 || Utils.isEmpty(populate)) {
      return this.setSerializationContext(entities, populate, options);
    }
    const meta = this.#metadata.find(entityName);
    if (entities.some(e => !e.__helper)) {
      const entity = entities.find(e => !Utils.isEntity(e));
      throw ValidationError.notDiscoveredEntity(entity, meta, 'populate');
    }
    const references = entities.filter(e => !helper(e).isInitialized());
    const visited = (options.visited ??= new Set());
    options.where ??= {};
    options.orderBy ??= {};
    options.lookup ??= true;
    options.validate ??= true;
    options.refresh ??= false;
    options.convertCustomTypes ??= true;
    if (references.length > 0) {
      await this.populateScalar(meta, references, { ...options, populateWhere: undefined });
    }
    populate = this.normalizePopulate(entityName, populate, options.strategy, options.lookup, options.exclude);
    const invalid = populate.find(({ field }) => !this.#em.canPopulate(entityName, field));
    /* v8 ignore next */
    if (options.validate && invalid) {
      throw ValidationError.invalidPropertyName(entityName, invalid.field);
    }
    this.setSerializationContext(entities, populate, options);
    for (const entity of entities) {
      visited.add(entity);
    }
    for (const pop of populate) {
      await this.populateField(entityName, entities, pop, options);
    }
    for (const entity of entities) {
      visited.delete(entity);
    }
  }
  /** Normalizes populate hints into a structured array of PopulateOptions, expanding dot paths and eager relations. */
  normalizePopulate(entityName, populate, strategy, lookup = true, exclude) {
    const meta = this.#metadata.find(entityName);
    let normalized = Utils.asArray(populate).map(field => {
      // oxfmt-ignore
      return typeof field === 'boolean' || field.field === PopulatePath.ALL ? { all: !!field, field: meta.primaryKeys[0] } : field;
    });
    if (normalized.some(p => p.all)) {
      normalized = this.lookupAllRelationships(entityName);
    }
    // convert nested `field` with dot syntax to PopulateOptions with `children` array
    expandDotPaths(meta, normalized, true);
    if (lookup && populate !== false) {
      normalized = this.lookupEagerLoadedRelationships(entityName, normalized, strategy, '', [], exclude);
      // convert nested `field` with dot syntax produced by eager relations
      expandDotPaths(meta, normalized, true);
    }
    // merge same fields
    return this.mergeNestedPopulate(normalized);
  }
  setSerializationContext(entities, populate, options) {
    for (const entity of entities) {
      helper(entity).setSerializationContext({
        populate,
        fields: options.fields,
        exclude: options.exclude,
      });
    }
  }
  /**
   * Merge multiple populates for the same entity with different children. Also skips `*` fields, those can come from
   * partial loading hints (`fields`) that are used to infer the `populate` hint if missing.
   */
  mergeNestedPopulate(populate) {
    const tmp = populate.reduce((ret, item) => {
      /* v8 ignore next */
      if (item.field === PopulatePath.ALL) {
        return ret;
      }
      if (!ret[item.field]) {
        ret[item.field] = item;
        return ret;
      }
      if (!ret[item.field].children && item.children) {
        ret[item.field].children = item.children;
      } else if (ret[item.field].children && item.children) {
        ret[item.field].children.push(...item.children);
      }
      return ret;
    }, {});
    return Object.values(tmp).map(item => {
      if (item.children) {
        item.children = this.mergeNestedPopulate(item.children);
      }
      return item;
    });
  }
  /**
   * preload everything in one call (this will update already existing references in IM)
   */
  async populateMany(entityName, entities, populate, options) {
    const [field, ref] = populate.field.split(':', 2);
    const meta = this.#metadata.find(entityName);
    const prop = meta.properties[field];
    if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner && !this.#driver.getPlatform().usesPivotTable()) {
      const filtered = entities.filter(e => !e[prop.name]?.isInitialized());
      if (filtered.length > 0) {
        await this.populateScalar(meta, filtered, { ...options, fields: [prop.name] });
      }
    }
    if (prop.kind === ReferenceKind.SCALAR && prop.lazy) {
      const filtered = entities.filter(
        e => options.refresh || (prop.ref ? !e[prop.name]?.isInitialized() : e[prop.name] === undefined),
      );
      if (options.ignoreLazyScalarProperties || filtered.length === 0) {
        return entities;
      }
      await this.populateScalar(meta, filtered, { ...options, fields: [prop.name] });
      return entities;
    }
    if (prop.kind === ReferenceKind.EMBEDDED) {
      return [];
    }
    const filtered = this.filterCollections(entities, field, options, ref);
    const innerOrderBy = Utils.asArray(options.orderBy)
      .filter(
        orderBy =>
          (Array.isArray(orderBy[prop.name]) && orderBy[prop.name].length > 0) || Utils.isObject(orderBy[prop.name]),
      )
      .flatMap(orderBy => orderBy[prop.name]);
    const where = await this.extractChildCondition(options, prop);
    if (prop.kind === ReferenceKind.MANY_TO_MANY && this.#driver.getPlatform().usesPivotTable()) {
      const pivotOrderBy = QueryHelper.mergeOrderBy(innerOrderBy, prop.orderBy, prop.targetMeta?.orderBy);
      const res = await this.findChildrenFromPivotTable(filtered, prop, options, pivotOrderBy, populate, !!ref);
      return Utils.flatten(res);
    }
    if (prop.polymorphic && prop.polymorphTargets) {
      return this.populatePolymorphic(entities, prop, options, !!ref);
    }
    const { items, partial } = await this.findChildren(
      options.filtered ?? entities,
      prop,
      populate,
      {
        ...options,
        where,
        orderBy: innerOrderBy,
      },
      !!(ref || prop.mapToPk),
    );
    const customOrder = innerOrderBy.length > 0 || !!prop.orderBy || !!prop.targetMeta?.orderBy;
    this.initializeCollections(filtered, prop, field, items, customOrder, partial);
    return items;
  }
  async populateScalar(meta, filtered, options) {
    const pk = Utils.getPrimaryKeyHash(meta.primaryKeys);
    const ids = Utils.unique(filtered.map(e => Utils.getPrimaryKeyValues(e, meta, true)));
    const where = this.mergePrimaryCondition(ids, pk, options, meta, this.#metadata, this.#driver.getPlatform());
    const { filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType, logging, fields } = options;
    await this.#em.find(meta.class, where, {
      filters,
      convertCustomTypes,
      lockMode,
      strategy,
      populateWhere,
      connectionType,
      logging,
      fields: fields,
      populate: [],
    });
  }
  async populatePolymorphic(entities, prop, options, ref) {
    const ownerMeta = this.#metadata.get(entities[0].constructor);
    // Separate entities: those with loaded refs vs those needing FK load
    const toPopulate = [];
    const needsFkLoad = [];
    for (const entity of entities) {
      const refValue = entity[prop.name];
      if (refValue && helper(refValue).hasPrimaryKey()) {
        if (
          (ref && !options.refresh) || // :ref hint - already have reference
          (!ref && helper(refValue).__initialized && !options.refresh) // already loaded
        ) {
          continue;
        }
        toPopulate.push(entity);
      } else if (refValue == null && !helper(entity).__loadedProperties.has(prop.name)) {
        // FK columns weren't loaded (partial loading) — need to re-fetch them.
        // If the property IS in __loadedProperties, the FK was loaded and is genuinely null.
        needsFkLoad.push(entity);
      }
    }
    // Load FK columns using populateScalar pattern
    if (needsFkLoad.length > 0) {
      await this.populateScalar(ownerMeta, needsFkLoad, {
        ...options,
        fields: [...ownerMeta.primaryKeys, prop.name],
      });
      // After loading FKs, add to toPopulate if not using :ref hint
      if (!ref) {
        for (const entity of needsFkLoad) {
          const refValue = entity[prop.name];
          if (refValue && helper(refValue).hasPrimaryKey()) {
            toPopulate.push(entity);
          }
        }
      }
    }
    if (toPopulate.length === 0) {
      return [];
    }
    // Group references by target class for batch loading
    const groups = new Map();
    for (const entity of toPopulate) {
      const refValue = Reference.unwrapReference(entity[prop.name]);
      const discriminator = QueryHelper.findDiscriminatorValue(prop.discriminatorMap, helper(refValue).__meta.class);
      const group = groups.get(discriminator) ?? [];
      group.push(refValue);
      groups.set(discriminator, group);
    }
    // Load each group concurrently - identity map handles merging with existing references
    const allItems = [];
    await Promise.all(
      [...groups].map(async ([discriminator, children]) => {
        const targetMeta = this.#metadata.find(prop.discriminatorMap[discriminator]);
        await this.populateScalar(targetMeta, children, options);
        allItems.push(...children);
      }),
    );
    return allItems;
  }
  initializeCollections(filtered, prop, field, children, customOrder, partial) {
    if (prop.kind === ReferenceKind.ONE_TO_MANY) {
      this.initializeOneToMany(filtered, children, prop, field, partial);
    }
    if (prop.kind === ReferenceKind.MANY_TO_MANY && !this.#driver.getPlatform().usesPivotTable()) {
      this.initializeManyToMany(filtered, children, prop, field, customOrder, partial);
    }
  }
  initializeOneToMany(filtered, children, prop, field, partial) {
    const mapToPk = prop.targetMeta.properties[prop.mappedBy].mapToPk;
    const map = {};
    for (const entity of filtered) {
      const key = helper(entity).getSerializedPrimaryKey();
      map[key] = [];
    }
    for (const child of children) {
      const pk = child.__helper.__data[prop.mappedBy] ?? child[prop.mappedBy];
      if (pk) {
        const key = helper(mapToPk ? this.#em.getReference(prop.targetMeta.class, pk) : pk).getSerializedPrimaryKey();
        map[key]?.push(child);
      }
    }
    for (const entity of filtered) {
      const key = helper(entity).getSerializedPrimaryKey();
      entity[field].hydrate(map[key], undefined, partial);
    }
  }
  initializeManyToMany(filtered, children, prop, field, customOrder, partial) {
    if (prop.mappedBy) {
      for (const entity of filtered) {
        const items = children.filter(child => child[prop.mappedBy].contains(entity, false));
        entity[field].hydrate(items, true, partial);
      }
    } else {
      // owning side of M:N without pivot table needs to be reordered
      for (const entity of filtered) {
        const order = !customOrder ? [...entity[prop.name].getItems(false)] : []; // copy order of references
        const items = children.filter(child => entity[prop.name].contains(child, false));
        if (!customOrder) {
          items.sort((a, b) => order.indexOf(a) - order.indexOf(b));
        }
        entity[field].hydrate(items, true, partial);
      }
    }
  }
  async findChildren(entities, prop, populate, options, ref) {
    const children = Utils.unique(this.getChildReferences(entities, prop, options, ref));
    const meta = prop.targetMeta;
    // When targetKey is set, use it for FK lookup instead of the PK
    let fk = prop.targetKey ?? Utils.getPrimaryKeyHash(meta.primaryKeys);
    let schema = options.schema;
    const partial = !Utils.isEmpty(prop.where) || !Utils.isEmpty(options.where);
    let polymorphicOwnerProp;
    if (prop.kind === ReferenceKind.ONE_TO_MANY || (prop.kind === ReferenceKind.MANY_TO_MANY && !prop.owner)) {
      const ownerProp = meta.properties[prop.mappedBy];
      if (ownerProp.polymorphic && ownerProp.fieldNames.length >= 2) {
        const idColumns = ownerProp.fieldNames.slice(1);
        fk = idColumns.length === 1 ? idColumns[0] : idColumns;
        polymorphicOwnerProp = ownerProp;
      } else {
        fk = ownerProp.name;
      }
    }
    if (prop.kind === ReferenceKind.ONE_TO_ONE && !prop.owner && !ref) {
      children.length = 0;
      fk = meta.properties[prop.mappedBy].name;
      children.push(...this.filterByReferences(entities, prop.name, options.refresh));
    }
    if (children.length === 0) {
      return { items: [], partial };
    }
    if (!schema && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
      schema = children.find(e => e.__helper.__schema)?.__helper.__schema;
    }
    const ids = Utils.unique(children.map(e => (prop.targetKey ? e[prop.targetKey] : e.__helper.getPrimaryKey())));
    let where;
    if (polymorphicOwnerProp && Array.isArray(fk)) {
      const conditions = ids.map(id => {
        const pkValues = Object.values(id);
        return Object.fromEntries(fk.map((col, idx) => [col, pkValues[idx]]));
      });
      where = conditions.length === 1 ? conditions[0] : { $or: conditions };
    } else {
      where = this.mergePrimaryCondition(ids, fk, options, meta, this.#metadata, this.#driver.getPlatform());
    }
    if (polymorphicOwnerProp) {
      const parentMeta = this.#metadata.find(entities[0].constructor);
      const discriminatorValue =
        QueryHelper.findDiscriminatorValue(polymorphicOwnerProp.discriminatorMap, parentMeta.class) ??
        parentMeta.tableName;
      const discriminatorColumn = polymorphicOwnerProp.fieldNames[0];
      where = { $and: [where, { [discriminatorColumn]: discriminatorValue }] };
    }
    const fields = this.buildFields(options.fields, prop, ref);
    /* eslint-disable prefer-const */
    let {
      refresh,
      filters,
      convertCustomTypes,
      lockMode,
      strategy,
      populateWhere = 'infer',
      connectionType,
      logging,
    } = options;
    /* eslint-enable prefer-const */
    if (typeof populateWhere === 'object') {
      populateWhere = await this.extractChildCondition({ where: populateWhere }, prop);
    }
    if (!Utils.isEmpty(prop.where) || Raw.hasObjectFragments(prop.where)) {
      where = { $and: [where, prop.where] };
    }
    const orderBy = QueryHelper.mergeOrderBy(options.orderBy, prop.orderBy);
    const items = await this.#em.find(meta.class, where, {
      filters,
      convertCustomTypes,
      lockMode,
      populateWhere,
      logging,
      orderBy,
      populate: populate.children ?? populate.all ?? [],
      exclude: Array.isArray(options.exclude)
        ? Utils.extractChildElements(options.exclude, prop.name)
        : options.exclude,
      strategy,
      fields,
      schema,
      connectionType,
      // @ts-ignore not a public option, will be propagated to the populate call
      refresh: refresh && !children.every(item => options.visited.has(item)),
      // @ts-ignore not a public option, will be propagated to the populate call
      visited: options.visited,
    });
    // For targetKey relations, wire up loaded entities to parent references
    // This is needed because the references were created under alternate key,
    // but loaded entities are stored under PK, so they don't automatically merge
    if (prop.targetKey && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
      const itemsByKey = new Map();
      for (const item of items) {
        itemsByKey.set('' + item[prop.targetKey], item);
      }
      for (const entity of entities) {
        const ref = entity[prop.name];
        /* v8 ignore next */
        if (!ref) {
          continue;
        }
        // oxfmt-ignore
        const keyValue = '' + (Reference.isReference(ref) ? ref.unwrap()[prop.targetKey] : ref[prop.targetKey]);
        const loadedItem = itemsByKey.get(keyValue);
        if (loadedItem) {
          entity[prop.name] = Reference.isReference(ref) ? Reference.create(loadedItem) : loadedItem;
        }
      }
    }
    if ([ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) && items.length !== children.length) {
      const nullVal = this.#em.config.get('forceUndefined') ? undefined : null;
      const itemsMap = new Set();
      const childrenMap = new Set();
      // Use targetKey value if set, otherwise use serialized PK
      const getKey = e => (prop.targetKey ? '' + e[prop.targetKey] : helper(e).getSerializedPrimaryKey());
      for (const item of items) {
        /* v8 ignore next */
        itemsMap.add(getKey(item));
      }
      for (const child of children) {
        childrenMap.add(getKey(child));
      }
      for (const entity of entities) {
        const ref = entity[prop.name] ?? {};
        const key = helper(ref) ? getKey(ref) : undefined;
        if (key && childrenMap.has(key) && !itemsMap.has(key)) {
          entity[prop.name] = nullVal;
          helper(entity).__originalEntityData[prop.name] = null;
        }
      }
    }
    for (const item of items) {
      if (ref && !helper(item).__onLoadFired) {
        helper(item).__initialized = false;
        this.#em.getUnitOfWork().unmarkAsLoaded(item);
      }
    }
    return { items, partial };
  }
  mergePrimaryCondition(ids, pk, options, meta, metadata, platform) {
    const cond1 = QueryHelper.processWhere({
      where: { [pk]: { $in: ids } },
      entityName: meta.class,
      metadata,
      platform,
      convertCustomTypes: !options.convertCustomTypes,
    });
    const where = { ...options.where };
    Utils.dropUndefinedProperties(where);
    return where[pk] ? { $and: [cond1, where] } : { ...cond1, ...where };
  }
  async populateField(entityName, entities, populate, options) {
    const field = populate.field.split(':')[0];
    const prop = this.#metadata.find(entityName).properties[field];
    if (prop.kind === ReferenceKind.SCALAR && !prop.lazy) {
      return;
    }
    options = { ...options, filters: QueryHelper.mergePropertyFilters(prop.filters, options.filters) };
    const populated = await this.populateMany(entityName, entities, populate, options);
    if (!populate.children && !populate.all) {
      return;
    }
    const children = [];
    for (const entity of entities) {
      const ref = entity[field];
      if (Utils.isEntity(ref)) {
        children.push(ref);
      } else if (Reference.isReference(ref)) {
        children.push(ref.unwrap());
      } else if (Utils.isCollection(ref)) {
        children.push(...ref.getItems());
      } else if (ref && prop.kind === ReferenceKind.EMBEDDED) {
        children.push(...Utils.asArray(ref));
      }
    }
    if (populated.length === 0 && !populate.children) {
      return;
    }
    const fields = this.buildFields(options.fields, prop);
    const innerOrderBy = Utils.asArray(options.orderBy)
      .filter(orderBy => Utils.isObject(orderBy[prop.name]))
      .map(orderBy => orderBy[prop.name]);
    const { refresh, filters, ignoreLazyScalarProperties, populateWhere, connectionType, logging, schema } = options;
    // oxfmt-ignore
    const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
    const visited = options.visited;
    for (const entity of entities) {
      visited.delete(entity);
    }
    const unique = Utils.unique(children);
    const filtered = unique.filter(e => !visited.has(e));
    for (const entity of entities) {
      visited.add(entity);
    }
    if (!prop.targetMeta) {
      return;
    }
    const populateChildren = async (targetMeta, items) => {
      await this.populate(targetMeta.class, items, populate.children ?? populate.all, {
        where: await this.extractChildCondition(options, prop, false),
        orderBy: innerOrderBy,
        fields,
        exclude,
        validate: false,
        lookup: false,
        filters,
        ignoreLazyScalarProperties,
        populateWhere,
        connectionType,
        logging,
        schema,
        // @ts-ignore not a public option, will be propagated to the populate call
        refresh: refresh && !filtered.every(item => options.visited.has(item)),
        // @ts-ignore not a public option, will be propagated to the populate call
        visited: options.visited,
        // @ts-ignore not a public option
        filtered,
      });
    };
    if (prop.polymorphic && prop.polymorphTargets) {
      await Promise.all(
        prop.polymorphTargets.map(async targetMeta => {
          const targetChildren = unique.filter(child => helper(child).__meta.className === targetMeta.className);
          if (targetChildren.length > 0) {
            await populateChildren(targetMeta, targetChildren);
          }
        }),
      );
    } else {
      await populateChildren(prop.targetMeta, unique);
    }
  }
  /** @internal */
  async findChildrenFromPivotTable(filtered, prop, options, orderBy, populate, pivotJoin) {
    const ids = filtered.map(e => e.__helper.__primaryKeys);
    const refresh = options.refresh;
    let where = await this.extractChildCondition(options, prop, true);
    const fields = this.buildFields(options.fields, prop);
    // oxfmt-ignore
    const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
    const populateFilter = options.populateFilter?.[prop.name];
    const options2 = { ...options, fields, exclude, populateFilter };
    ['limit', 'offset', 'first', 'last', 'before', 'after', 'overfetch'].forEach(prop => delete options2[prop]);
    options2.populate = populate?.children ?? [];
    if (!Utils.isEmpty(prop.where)) {
      where = { $and: [where, prop.where] };
    }
    const map = await this.#driver.loadFromPivotTable(
      prop,
      ids,
      where,
      orderBy,
      this.#em.getTransactionContext(),
      options2,
      pivotJoin,
    );
    const children = [];
    for (let i = 0; i < filtered.length; i++) {
      const entity = filtered[i];
      const items = map[Utils.getPrimaryKeyHash(ids[i])].map(item => {
        if (pivotJoin) {
          return this.#em.getReference(prop.targetMeta.class, item, {
            convertCustomTypes: true,
            schema: options.schema ?? this.#em.config.get('schema'),
          });
        }
        const entity = this.#em.getEntityFactory().create(prop.targetMeta.class, item, {
          refresh,
          merge: true,
          convertCustomTypes: true,
          schema: options.schema ?? this.#em.config.get('schema'),
        });
        return this.#em.getUnitOfWork().register(entity, item, { refresh, loaded: true });
      });
      entity[prop.name].hydrate(items, true);
      children.push(items);
    }
    return children;
  }
  async extractChildCondition(options, prop, filters = false) {
    const where = options.where;
    const subCond = Utils.isPlainObject(where[prop.name]) ? where[prop.name] : {};
    const meta2 = prop.targetMeta;
    const pk = Utils.getPrimaryKeyHash(meta2.primaryKeys);
    ['$and', '$or'].forEach(op => {
      if (where[op]) {
        const child = where[op]
          .map(cond => cond[prop.name])
          .filter(
            sub =>
              sub != null &&
              !(Utils.isPlainObject(sub) && Utils.getObjectQueryKeys(sub).every(key => Utils.isOperator(key, false))),
          )
          .map(cond => {
            if (Utils.isPrimaryKey(cond)) {
              return { [pk]: cond };
            }
            return cond;
          });
        if (child.length > 0) {
          subCond[op] = child;
        }
      }
    });
    const operators = Object.keys(subCond).filter(key => Utils.isOperator(key, false));
    if (operators.length > 0) {
      operators.forEach(op => {
        subCond[pk] ??= {};
        subCond[pk][op] = subCond[op];
        delete subCond[op];
      });
    }
    if (filters) {
      return this.#em.applyFilters(meta2.class, subCond, options.filters, 'read', options);
    }
    return subCond;
  }
  buildFields(fields = [], prop, ref) {
    if (ref) {
      fields = prop.targetMeta.primaryKeys.map(targetPkName => `${prop.name}.${targetPkName}`);
    }
    const ret = fields.reduce((ret, f) => {
      if (Utils.isPlainObject(f)) {
        Utils.keys(f)
          .filter(ff => ff === prop.name)
          .forEach(ff => ret.push(...f[ff]));
      } else if (f.toString().includes('.')) {
        const parts = f.toString().split('.');
        const propName = parts.shift();
        const childPropName = parts.join('.');
        /* v8 ignore next */
        if (propName === prop.name) {
          ret.push(childPropName);
        }
      }
      return ret;
    }, []);
    // we need to automatically select the FKs too, e.g. for 1:m relations to be able to wire them with the items
    if (prop.kind === ReferenceKind.ONE_TO_MANY || prop.kind === ReferenceKind.MANY_TO_MANY) {
      const owner = prop.targetMeta.properties[prop.mappedBy];
      // when the owning FK is lazy, we need to explicitly select it even without user-provided fields,
      // otherwise the driver will exclude it and we won't be able to map children to their parent collections
      if (owner && !ret.includes(owner.name) && (ret.length > 0 || owner.lazy)) {
        ret.push(owner.name);
      }
    }
    if (ret.length === 0) {
      return undefined;
    }
    return ret;
  }
  getChildReferences(entities, prop, options, ref) {
    const filtered = this.filterCollections(entities, prop.name, options, ref);
    if (prop.kind === ReferenceKind.ONE_TO_MANY) {
      return filtered.map(e => e[prop.name].owner);
    }
    if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner) {
      return filtered.reduce((a, b) => {
        a.push(...b[prop.name].getItems());
        return a;
      }, []);
    }
    if (prop.kind === ReferenceKind.MANY_TO_MANY) {
      // inverse side
      return filtered;
    }
    // MANY_TO_ONE or ONE_TO_ONE
    return this.filterReferences(entities, prop.name, options, ref);
  }
  filterCollections(entities, field, options, ref) {
    if (options.refresh) {
      return entities.filter(e => e[field]);
    }
    return entities.filter(e => Utils.isCollection(e[field]) && !e[field].isInitialized(!ref));
  }
  isPropertyLoaded(entity, field) {
    if (!entity || field === '*') {
      return true;
    }
    const wrapped = helper(entity);
    if (!field.includes('.')) {
      return wrapped.__loadedProperties.has(field);
    }
    const [f, ...r] = field.split('.');
    /* v8 ignore next */
    if (!wrapped.__loadedProperties.has(f) || !wrapped.__meta.properties[f]?.targetMeta) {
      return false;
    }
    if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(wrapped.__meta.properties[f].kind)) {
      return entity[f].getItems(false).every(item => this.isPropertyLoaded(item, r.join('.')));
    }
    return this.isPropertyLoaded(entity[f], r.join('.'));
  }
  filterReferences(entities, field, options, ref) {
    if (ref) {
      return [];
    }
    const children = entities.filter(e => Utils.isEntity(e[field], true));
    if (options.refresh) {
      return children.map(e => Reference.unwrapReference(e[field]));
    }
    if (options.fields) {
      return children
        .map(e => Reference.unwrapReference(e[field]))
        .filter(target => {
          const wrapped = helper(target);
          const childFields = options.fields
            .filter(f => f.startsWith(`${field}.`))
            .map(f => f.substring(field.length + 1));
          return !wrapped.__initialized || !childFields.every(cf => this.isPropertyLoaded(target, cf));
        });
    }
    return children.filter(e => !e[field].__helper.__initialized).map(e => Reference.unwrapReference(e[field]));
  }
  filterByReferences(entities, field, refresh) {
    /* v8 ignore next */
    if (refresh) {
      return entities;
    }
    return entities.filter(e => e[field] !== null && !e[field]?.__helper?.__initialized);
  }
  lookupAllRelationships(entityName) {
    const ret = [];
    const meta = this.#metadata.find(entityName);
    meta.relations.forEach(prop => {
      ret.push({
        field: this.getRelationName(meta, prop),
        // force select-in strategy when populating all relations as otherwise we could cause infinite loops when self-referencing
        strategy: LoadStrategy.SELECT_IN,
        // no need to look up populate children recursively as we just pass `all: true` here
        all: true,
      });
    });
    return ret;
  }
  getRelationName(meta, prop) {
    if (!prop.embedded) {
      return prop.name;
    }
    return `${this.getRelationName(meta, meta.properties[prop.embedded[0]])}.${prop.embedded[1]}`;
  }
  lookupEagerLoadedRelationships(entityName, populate, strategy, prefix = '', visited = [], exclude) {
    const meta = this.#metadata.find(entityName);
    if (!meta && !prefix) {
      return populate;
    }
    if (!meta || visited.includes(meta)) {
      return [];
    }
    visited.push(meta);
    const ret = prefix === '' ? [...populate] : [];
    meta.relations
      .filter(prop => {
        const field = this.getRelationName(meta, prop);
        const prefixed = prefix ? `${prefix}.${field}` : field;
        const isExcluded = exclude?.includes(prefixed);
        const eager = prop.eager && !populate.some(p => p.field === `${prop.name}:ref`);
        const populated = populate.some(p => p.field === prop.name);
        const disabled = populate.some(p => p.field === prop.name && p.all === false);
        return !disabled && !isExcluded && (eager || populated);
      })
      .forEach(prop => {
        const field = this.getRelationName(meta, prop);
        const prefixed = prefix ? `${prefix}.${field}` : field;
        const nestedPopulate = populate
          .filter(p => p.field === prop.name)
          .flatMap(p => p.children)
          .filter(Boolean);
        const nested = this.lookupEagerLoadedRelationships(
          prop.targetMeta.class,
          nestedPopulate,
          strategy,
          prefixed,
          visited.slice(),
          exclude,
        );
        if (nested.length > 0) {
          ret.push(...nested);
        } else {
          const selfReferencing =
            [meta.tableName, ...visited.map(m => m.tableName)].includes(prop.targetMeta.tableName) && prop.eager;
          ret.push({
            field: prefixed,
            // enforce select-in strategy for self-referencing relations
            strategy: selfReferencing ? LoadStrategy.SELECT_IN : (strategy ?? prop.strategy),
          });
        }
      });
    return ret;
  }
}
