import { Collection } from '../entity/Collection.js';
import { EntityHelper } from '../entity/EntityHelper.js';
import { helper } from '../entity/wrap.js';
import { Reference } from '../entity/Reference.js';
import { EntityIdentifier } from '../entity/EntityIdentifier.js';
import { ChangeSet, ChangeSetType } from './ChangeSet.js';
import { ChangeSetComputer } from './ChangeSetComputer.js';
import { ChangeSetPersister } from './ChangeSetPersister.js';
import { CommitOrderCalculator } from './CommitOrderCalculator.js';
import { Utils } from '../utils/Utils.js';
import { Cascade, DeferMode, EventType, LockMode, ReferenceKind } from '../enums.js';
import { OptimisticLockError, ValidationError } from '../errors.js';
import { TransactionEventBroadcaster } from '../events/TransactionEventBroadcaster.js';
import { IdentityMap } from './IdentityMap.js';
import { createAsyncContext } from '../utils/AsyncContext.js';
// to deal with validation for flush inside flush hooks and `Promise.all`
const insideFlush = createAsyncContext();
/** Implements the Unit of Work pattern: tracks entity changes, computes change sets, and flushes them to the database. */
export class UnitOfWork {
  /** map of references to managed entities */
  #identityMap;
  #persistStack = new Set();
  #removeStack = new Set();
  #orphanRemoveStack = new Set();
  #changeSets = new Map();
  #collectionUpdates = new Set();
  #extraUpdates = new Set();
  #metadata;
  #platform;
  #eventManager;
  #comparator;
  #changeSetComputer;
  #changeSetPersister;
  #queuedActions = new Set();
  #loadedEntities = new Set();
  #flushQueue = [];
  #working = false;
  #em;
  constructor(em) {
    this.#em = em;
    this.#metadata = this.#em.getMetadata();
    this.#platform = this.#em.getPlatform();
    this.#identityMap = new IdentityMap(this.#platform.getDefaultSchemaName());
    this.#eventManager = this.#em.getEventManager();
    this.#comparator = this.#em.getComparator();
    this.#changeSetComputer = new ChangeSetComputer(this.#em, this.#collectionUpdates);
    this.#changeSetPersister = new ChangeSetPersister(this.#em);
  }
  /** Merges an entity into the identity map, taking a snapshot of its current state. */
  merge(entity, visited) {
    const wrapped = helper(entity);
    wrapped.__em = this.#em;
    if (!wrapped.hasPrimaryKey()) {
      return;
    }
    // skip new entities that could be linked from already persisted entity
    // that is being re-fetched (but allow calling `merge(e)` explicitly for those)
    if (!wrapped.__managed && visited) {
      return;
    }
    this.#identityMap.store(entity);
    // if visited is available, we are cascading, and need to be careful when resetting the entity data
    // as there can be some entity with already changed state that is not yet flushed
    if (wrapped.__initialized && (!visited || !wrapped.__originalEntityData)) {
      wrapped.__originalEntityData = this.#comparator.prepareEntity(entity);
    }
    this.cascade(entity, Cascade.MERGE, visited ?? new Set());
  }
  /**
   * Entity data can wary in its shape, e.g. we might get a deep relation graph with joined strategy, but for diffing,
   * we need to normalize the shape, so relation values are only raw FKs. This method handles that.
   * @internal
   */
  normalizeEntityData(meta, data) {
    const forceUndefined = this.#em.config.get('forceUndefined');
    for (const key of Utils.keys(data)) {
      const prop = meta.properties[key];
      if (!prop) {
        continue;
      }
      if (
        [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
        Utils.isPlainObject(data[prop.name])
      ) {
        // Skip polymorphic relations - they use PolymorphicRef wrapper
        if (!prop.polymorphic) {
          data[prop.name] = Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
        }
      } else if (prop.kind === ReferenceKind.EMBEDDED && !prop.object && Utils.isPlainObject(data[prop.name])) {
        for (const p of prop.targetMeta.props) {
          /* v8 ignore next */
          const prefix = prop.prefix === false ? '' : prop.prefix === true ? prop.name + '_' : prop.prefix;
          data[prefix + p.name] = data[prop.name][p.name];
        }
        data[prop.name] = Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
      }
      if (prop.hydrate === false && prop.customType?.ensureComparable(meta, prop)) {
        const converted = prop.customType.convertToJSValue(data[key], this.#platform, {
          key,
          mode: 'hydration',
          force: true,
        });
        data[key] = prop.customType.convertToDatabaseValue(converted, this.#platform, { key, mode: 'hydration' });
      }
      if (forceUndefined) {
        if (data[key] === null) {
          data[key] = undefined;
        }
      }
    }
  }
  /**
   * @internal
   */
  register(entity, data, options) {
    this.#identityMap.store(entity);
    EntityHelper.ensurePropagation(entity);
    if (options?.newEntity) {
      return entity;
    }
    const forceUndefined = this.#em.config.get('forceUndefined');
    const wrapped = helper(entity);
    if (options?.loaded && wrapped.__initialized && !wrapped.__onLoadFired) {
      this.#loadedEntities.add(entity);
    }
    wrapped.__em ??= this.#em;
    wrapped.__managed = true;
    if (data && (options?.refresh || !wrapped.__originalEntityData)) {
      this.normalizeEntityData(wrapped.__meta, data);
      for (const key of Utils.keys(data)) {
        const prop = wrapped.__meta.properties[key];
        if (prop) {
          wrapped.__loadedProperties.add(key);
        }
      }
      wrapped.__originalEntityData = data;
    }
    return entity;
  }
  /**
   * @internal
   */
  async dispatchOnLoadEvent() {
    for (const entity of this.#loadedEntities) {
      if (this.#eventManager.hasListeners(EventType.onLoad, entity.__meta)) {
        await this.#eventManager.dispatchEvent(EventType.onLoad, { entity, meta: entity.__meta, em: this.#em });
        helper(entity).__onLoadFired = true;
      }
    }
    this.#loadedEntities.clear();
  }
  /**
   * @internal
   */
  unmarkAsLoaded(entity) {
    this.#loadedEntities.delete(entity);
  }
  /**
   * Returns entity from the identity map. For composite keys, you need to pass an array of PKs in the same order as they are defined in `meta.primaryKeys`.
   */
  getById(entityName, id, schema, convertCustomTypes) {
    if (id == null || (Array.isArray(id) && id.length === 0)) {
      return undefined;
    }
    const meta = this.#metadata.find(entityName).root;
    let hash;
    if (meta.simplePK) {
      hash = '' + id;
    } else {
      let keys = Array.isArray(id) ? Utils.flatten(id) : [id];
      keys = meta.getPrimaryProps(true).map((p, i) => {
        if (!convertCustomTypes && p.customType) {
          return p.customType.convertToDatabaseValue(keys[i], this.#platform, {
            key: p.name,
            mode: 'hydration',
          });
        }
        return keys[i];
      });
      hash = Utils.getPrimaryKeyHash(keys);
    }
    schema ??= meta.schema ?? this.#em.config.getSchema();
    if (schema) {
      hash = `${schema}:${hash}`;
    }
    return this.#identityMap.getByHash(meta, hash);
  }
  /**
   * Returns entity from the identity map by an alternate key (non-PK property).
   * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format for lookup.
   *                             If false (default), the value is assumed to be in JS format already.
   */
  getByKey(entityName, key, value, schema, convertCustomTypes) {
    const meta = this.#metadata.find(entityName).root;
    schema ??= meta.schema ?? this.#em.config.getSchema();
    const prop = meta.properties[key];
    // Convert from DB format to JS format if needed
    if (convertCustomTypes && prop?.customType) {
      value = prop.customType.convertToJSValue(value, this.#platform, { mode: 'hydration' });
    }
    const hash = this.#identityMap.getKeyHash(key, '' + value, schema);
    return this.#identityMap.getByHash(meta, hash);
  }
  /**
   * Stores an entity in the identity map under an alternate key (non-PK property).
   * Also sets the property value on the entity.
   * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format.
   *                             If false (default), the value is assumed to be in JS format already.
   */
  storeByKey(entity, key, value, schema, convertCustomTypes) {
    const meta = entity.__meta.root;
    schema ??= meta.schema ?? this.#em.config.getSchema();
    const prop = meta.properties[key];
    // Convert from DB format to JS format if needed
    if (convertCustomTypes && prop?.customType) {
      value = prop.customType.convertToJSValue(value, this.#platform, { mode: 'hydration' });
    }
    // Set the property on the entity
    entity[key] = value;
    this.#identityMap.storeByKey(entity, key, '' + value, schema);
  }
  /** Attempts to extract a primary key from the where condition and look up the entity in the identity map. */
  tryGetById(entityName, where, schema, strict = true) {
    const pk = Utils.extractPK(where, this.#metadata.find(entityName), strict);
    if (!pk) {
      return null;
    }
    return this.getById(entityName, pk, schema);
  }
  /**
   * Returns map of all managed entities.
   */
  getIdentityMap() {
    return this.#identityMap;
  }
  /**
   * Returns stored snapshot of entity state that is used for change set computation.
   */
  getOriginalEntityData(entity) {
    return helper(entity).__originalEntityData;
  }
  /** Returns the set of entities scheduled for persistence. */
  getPersistStack() {
    return this.#persistStack;
  }
  /** Returns the set of entities scheduled for removal. */
  getRemoveStack() {
    return this.#removeStack;
  }
  /** Returns all computed change sets for the current flush. */
  getChangeSets() {
    return [...this.#changeSets.values()];
  }
  /** Returns all M:N collections that need synchronization. */
  getCollectionUpdates() {
    return [...this.#collectionUpdates];
  }
  /** Returns extra updates needed for relations that could not be resolved in the initial pass. */
  getExtraUpdates() {
    return this.#extraUpdates;
  }
  /** Checks whether an auto-flush is needed before querying the given entity type. */
  shouldAutoFlush(meta) {
    if (insideFlush.getStore()) {
      return false;
    }
    if (this.#queuedActions.has(meta.class) || this.#queuedActions.has(meta.root.class)) {
      return true;
    }
    if (meta.discriminatorMap && Object.values(meta.discriminatorMap).some(v => this.#queuedActions.has(v))) {
      return true;
    }
    return false;
  }
  /** Clears the queue of entity types that triggered auto-flush detection. */
  clearActionsQueue() {
    this.#queuedActions.clear();
  }
  /** Computes and registers a change set for the given entity. */
  computeChangeSet(entity, type) {
    const wrapped = helper(entity);
    if (type === ChangeSetType.DELETE || type === ChangeSetType.DELETE_EARLY) {
      this.#changeSets.set(entity, new ChangeSet(entity, type, {}, wrapped.__meta));
      return;
    }
    const cs = this.#changeSetComputer.computeChangeSet(entity);
    if (!cs || this.checkUniqueProps(cs)) {
      return;
    }
    /* v8 ignore next */
    if (type) {
      cs.type = type;
    }
    this.initIdentifier(entity);
    this.#changeSets.set(entity, cs);
    this.#persistStack.delete(entity);
    wrapped.__originalEntityData = this.#comparator.prepareEntity(entity);
  }
  /** Recomputes and merges the change set for an already-tracked entity. */
  recomputeSingleChangeSet(entity) {
    const changeSet = this.#changeSets.get(entity);
    if (!changeSet) {
      return;
    }
    const cs = this.#changeSetComputer.computeChangeSet(entity);
    if (cs && !this.checkUniqueProps(cs)) {
      Object.assign(changeSet.payload, cs.payload);
      helper(entity).__originalEntityData = this.#comparator.prepareEntity(entity);
    }
  }
  /** Marks an entity for persistence, cascading to related entities. */
  persist(entity, visited, options = {}) {
    EntityHelper.ensurePropagation(entity);
    if (options.checkRemoveStack && this.#removeStack.has(entity)) {
      return;
    }
    const wrapped = helper(entity);
    this.#persistStack.add(entity);
    this.#queuedActions.add(wrapped.__meta.class);
    this.#removeStack.delete(entity);
    if (!wrapped.__managed && wrapped.hasPrimaryKey()) {
      this.#identityMap.store(entity);
    }
    if (options.cascade ?? true) {
      this.cascade(entity, Cascade.PERSIST, visited, options);
    }
  }
  /** Marks an entity for removal, cascading to related entities. */
  remove(entity, visited, options = {}) {
    // allow removing not managed entities if they are not part of the persist stack
    if (helper(entity).__managed || !this.#persistStack.has(entity)) {
      this.#removeStack.add(entity);
      this.#queuedActions.add(helper(entity).__meta.class);
    } else {
      this.#persistStack.delete(entity);
      this.#identityMap.delete(entity);
    }
    // remove from referencing relations that are nullable
    for (const prop of helper(entity).__meta.bidirectionalRelations) {
      const inverseProp = prop.mappedBy || prop.inversedBy;
      const relation = Reference.unwrapReference(entity[prop.name]);
      const prop2 = prop.targetMeta.properties[inverseProp];
      if (prop.kind === ReferenceKind.ONE_TO_MANY && prop2.nullable && Utils.isCollection(relation)) {
        for (const item of relation.getItems(false)) {
          delete item[inverseProp];
        }
        continue;
      }
      const target = relation?.[inverseProp];
      if (relation && Utils.isCollection(target)) {
        target.removeWithoutPropagation(entity);
      }
    }
    if (options.cascade ?? true) {
      this.cascade(entity, Cascade.REMOVE, visited);
    }
  }
  /** Flushes all pending changes to the database within a transaction. */
  async commit() {
    if (this.#working) {
      if (insideFlush.getStore()) {
        throw ValidationError.cannotCommit();
      }
      return new Promise((resolve, reject) => {
        this.#flushQueue.push(() => {
          return insideFlush.run(true, () => {
            return this.doCommit().then(resolve, reject);
          });
        });
      });
    }
    try {
      this.#working = true;
      await insideFlush.run(true, () => this.doCommit());
      while (this.#flushQueue.length) {
        await this.#flushQueue.shift()();
      }
    } finally {
      this.postCommitCleanup();
      this.#working = false;
    }
  }
  async doCommit() {
    const oldTx = this.#em.getTransactionContext();
    try {
      await this.#eventManager.dispatchEvent(EventType.beforeFlush, { em: this.#em, uow: this });
      this.computeChangeSets();
      for (const cs of this.#changeSets.values()) {
        cs.entity.__helper.__processing = true;
      }
      await this.#eventManager.dispatchEvent(EventType.onFlush, { em: this.#em, uow: this });
      this.filterCollectionUpdates();
      // nothing to do, do not start transaction
      if (this.#changeSets.size === 0 && this.#collectionUpdates.size === 0 && this.#extraUpdates.size === 0) {
        await this.#eventManager.dispatchEvent(EventType.afterFlush, { em: this.#em, uow: this });
        return;
      }
      const groups = this.getChangeSetGroups();
      const platform = this.#em.getPlatform();
      const runInTransaction =
        !this.#em.isInTransaction() && platform.supportsTransactions() && this.#em.config.get('implicitTransactions');
      if (runInTransaction) {
        const loggerContext = Utils.merge(
          { id: this.#em._id },
          this.#em.getLoggerContext({ disableContextResolution: true }),
        );
        await this.#em.getConnection('write').transactional(trx => this.persistToDatabase(groups, trx), {
          ctx: oldTx,
          eventBroadcaster: new TransactionEventBroadcaster(this.#em),
          loggerContext,
        });
      } else {
        await this.persistToDatabase(groups, this.#em.getTransactionContext());
      }
      this.resetTransaction(oldTx);
      for (const cs of this.#changeSets.values()) {
        cs.entity.__helper.__processing = false;
      }
      await this.#eventManager.dispatchEvent(EventType.afterFlush, { em: this.#em, uow: this });
    } finally {
      this.resetTransaction(oldTx);
    }
  }
  async lock(entity, options) {
    if (!this.getById(entity.constructor, helper(entity).__primaryKeys, helper(entity).__schema)) {
      throw ValidationError.entityNotManaged(entity);
    }
    const meta = this.#metadata.find(entity.constructor);
    if (options.lockMode === LockMode.OPTIMISTIC) {
      await this.lockOptimistic(entity, meta, options.lockVersion);
    } else if (options.lockMode != null) {
      await this.lockPessimistic(entity, options);
    }
  }
  clear() {
    this.#identityMap.clear();
    this.#loadedEntities.clear();
    this.postCommitCleanup();
  }
  unsetIdentity(entity) {
    this.#identityMap.delete(entity);
    const wrapped = helper(entity);
    const serializedPK = wrapped.getSerializedPrimaryKey();
    // remove references of this entity in all managed entities, otherwise flushing could reinsert the entity
    for (const { meta, prop } of wrapped.__meta.referencingProperties) {
      for (const referrer of this.#identityMap.getStore(meta).values()) {
        const rel = Reference.unwrapReference(referrer[prop.name]);
        if (Utils.isCollection(rel)) {
          rel.removeWithoutPropagation(entity);
        } else if (
          rel &&
          (prop.mapToPk
            ? helper(this.#em.getReference(prop.targetMeta.class, rel)).getSerializedPrimaryKey() === serializedPK
            : rel === entity)
        ) {
          if (prop.formula) {
            delete referrer[prop.name];
          } else {
            delete helper(referrer).__data[prop.name];
          }
        }
      }
    }
    delete wrapped.__identifier;
    delete wrapped.__originalEntityData;
    wrapped.__managed = false;
  }
  computeChangeSets() {
    this.#changeSets.clear();
    const visited = new Set();
    for (const entity of this.#removeStack) {
      this.cascade(entity, Cascade.REMOVE, visited);
    }
    visited.clear();
    for (const entity of this.#identityMap) {
      if (!this.#removeStack.has(entity) && !this.#persistStack.has(entity) && !this.#orphanRemoveStack.has(entity)) {
        this.cascade(entity, Cascade.PERSIST, visited, { checkRemoveStack: true });
      }
    }
    for (const entity of this.#persistStack) {
      this.cascade(entity, Cascade.PERSIST, visited, { checkRemoveStack: true });
    }
    visited.clear();
    for (const entity of this.#persistStack) {
      this.findNewEntities(entity, visited);
    }
    for (const entity of this.#orphanRemoveStack) {
      if (!helper(entity).__processing) {
        this.#removeStack.add(entity);
      }
    }
    // Check insert stack if there are any entities matching something from delete stack. This can happen when recreating entities.
    const inserts = {};
    for (const cs of this.#changeSets.values()) {
      if (cs.type === ChangeSetType.CREATE) {
        inserts[cs.meta.uniqueName] ??= [];
        inserts[cs.meta.uniqueName].push(cs);
      }
    }
    for (const cs of this.#changeSets.values()) {
      if (cs.type === ChangeSetType.UPDATE) {
        this.findEarlyUpdates(cs, inserts[cs.meta.uniqueName]);
      }
    }
    for (const entity of this.#removeStack) {
      const wrapped = helper(entity);
      /* v8 ignore next */
      if (wrapped.__processing) {
        continue;
      }
      const deletePkHash = [wrapped.getSerializedPrimaryKey(), ...this.expandUniqueProps(entity)];
      let type = ChangeSetType.DELETE;
      for (const cs of inserts[wrapped.__meta.uniqueName] ?? []) {
        if (
          deletePkHash.some(
            hash =>
              hash === cs.getSerializedPrimaryKey() || this.expandUniqueProps(cs.entity).find(child => hash === child),
          )
        ) {
          type = ChangeSetType.DELETE_EARLY;
        }
      }
      this.computeChangeSet(entity, type);
    }
  }
  scheduleExtraUpdate(changeSet, props) {
    if (props.length === 0) {
      return;
    }
    let conflicts = false;
    let type = ChangeSetType.UPDATE;
    if (!props.some(prop => prop.name in changeSet.payload)) {
      return;
    }
    for (const cs of this.#changeSets.values()) {
      for (const prop of props) {
        if (prop.name in cs.payload && cs.rootMeta === changeSet.rootMeta && cs.type === changeSet.type) {
          conflicts = true;
          if (changeSet.payload[prop.name] == null) {
            type = ChangeSetType.UPDATE_EARLY;
          }
        }
      }
    }
    if (!conflicts) {
      return;
    }
    this.#extraUpdates.add([
      changeSet.entity,
      props.map(p => p.name),
      props.map(p => changeSet.entity[p.name]),
      changeSet,
      type,
    ]);
    for (const p of props) {
      delete changeSet.entity[p.name];
      delete changeSet.payload[p.name];
    }
  }
  scheduleOrphanRemoval(entity, visited) {
    if (entity) {
      const wrapped = helper(entity);
      wrapped.__em = this.#em;
      this.#orphanRemoveStack.add(entity);
      this.#queuedActions.add(wrapped.__meta.class);
      this.cascade(entity, Cascade.SCHEDULE_ORPHAN_REMOVAL, visited);
    }
  }
  cancelOrphanRemoval(entity, visited) {
    this.#orphanRemoveStack.delete(entity);
    this.cascade(entity, Cascade.CANCEL_ORPHAN_REMOVAL, visited);
  }
  getOrphanRemoveStack() {
    return this.#orphanRemoveStack;
  }
  getChangeSetPersister() {
    return this.#changeSetPersister;
  }
  findNewEntities(entity, visited, idx = 0, processed = new Set()) {
    if (visited.has(entity)) {
      return;
    }
    visited.add(entity);
    processed.add(entity);
    const wrapped = helper(entity);
    if (wrapped.__processing || this.#removeStack.has(entity) || this.#orphanRemoveStack.has(entity)) {
      return;
    }
    // Set entityManager default schema
    wrapped.__schema ??= this.#em.schema;
    this.initIdentifier(entity);
    for (const prop of wrapped.__meta.relations) {
      const targets = Utils.unwrapProperty(entity, wrapped.__meta, prop);
      for (const [target] of targets) {
        const kind = Reference.unwrapReference(target);
        this.processReference(entity, prop, kind, visited, processed, idx);
      }
    }
    const changeSet = this.#changeSetComputer.computeChangeSet(entity);
    if (changeSet && !this.checkUniqueProps(changeSet)) {
      // For TPT child entities, create changesets for each table in hierarchy
      if (wrapped.__meta.inheritanceType === 'tpt' && wrapped.__meta.tptParent) {
        this.createTPTChangeSets(entity, changeSet);
      } else {
        this.#changeSets.set(entity, changeSet);
      }
    }
  }
  /**
   * For TPT inheritance, creates separate changesets for each table in the hierarchy.
   * Uses the same entity instance for all changesets - only the metadata and payload differ.
   */
  createTPTChangeSets(entity, originalChangeSet) {
    const meta = helper(entity).__meta;
    const isCreate = originalChangeSet.type === ChangeSetType.CREATE;
    let current = meta;
    let leafCs;
    const parentChangeSets = [];
    while (current) {
      const isRoot = !current.tptParent;
      const payload = {};
      for (const prop of current.ownProps) {
        if (prop.name in originalChangeSet.payload) {
          payload[prop.name] = originalChangeSet.payload[prop.name];
        }
      }
      // For CREATE on non-root tables, include the PK (EntityIdentifier for deferred resolution)
      if (isCreate && !isRoot) {
        const wrapped = helper(entity);
        const identifier = wrapped.__identifier;
        const identifiers = Array.isArray(identifier) ? identifier : [identifier];
        for (let i = 0; i < current.primaryKeys.length; i++) {
          const pk = current.primaryKeys[i];
          payload[pk] = identifiers[i] ?? originalChangeSet.payload[pk];
        }
      }
      if (!isCreate && Object.keys(payload).length === 0) {
        current = current.tptParent;
        continue;
      }
      const cs = new ChangeSet(entity, originalChangeSet.type, payload, current);
      if (current === meta) {
        cs.originalEntity = originalChangeSet.originalEntity;
        leafCs = cs;
      } else {
        parentChangeSets.push(cs);
      }
      current = current.tptParent;
    }
    // When only parent properties changed (UPDATE), leaf payload is empty—create a stub anchor
    if (!leafCs && parentChangeSets.length > 0) {
      leafCs = new ChangeSet(entity, originalChangeSet.type, {}, meta);
      leafCs.originalEntity = originalChangeSet.originalEntity;
    }
    // Store the leaf changeset in the main map (entity as key), with parent CSs attached
    if (leafCs) {
      if (parentChangeSets.length > 0) {
        leafCs.tptChangeSets = parentChangeSets;
      }
      this.#changeSets.set(entity, leafCs);
    }
  }
  /**
   * Returns `true` when the change set should be skipped as it will be empty after the extra update.
   */
  checkUniqueProps(changeSet) {
    if (changeSet.type !== ChangeSetType.UPDATE) {
      return false;
    }
    // when changing a unique nullable property (or a 1:1 relation), we can't do it in a single
    // query as it would cause unique constraint violations
    const uniqueProps = changeSet.meta.uniqueProps.filter(prop => {
      return prop.nullable || changeSet.type !== ChangeSetType.CREATE;
    });
    this.scheduleExtraUpdate(changeSet, uniqueProps);
    return changeSet.type === ChangeSetType.UPDATE && !Utils.hasObjectKeys(changeSet.payload);
  }
  expandUniqueProps(entity) {
    const wrapped = helper(entity);
    if (!wrapped.__meta.hasUniqueProps) {
      return [];
    }
    const simpleUniqueHashes = wrapped.__meta.uniqueProps
      .map(prop => {
        if (entity[prop.name] != null) {
          return prop.kind === ReferenceKind.SCALAR || prop.mapToPk
            ? entity[prop.name]
            : helper(entity[prop.name]).getSerializedPrimaryKey();
        }
        if (wrapped.__originalEntityData?.[prop.name] != null) {
          return Utils.getPrimaryKeyHash(Utils.asArray(wrapped.__originalEntityData[prop.name]));
        }
        return undefined;
      })
      .filter(i => i);
    const compoundUniqueHashes = wrapped.__meta.uniques
      .map(unique => {
        const props = Utils.asArray(unique.properties);
        if (props.every(prop => entity[prop] != null)) {
          return Utils.getPrimaryKeyHash(
            props.map(p => {
              const prop = wrapped.__meta.properties[p];
              return prop.kind === ReferenceKind.SCALAR || prop.mapToPk
                ? entity[prop.name]
                : helper(entity[prop.name]).getSerializedPrimaryKey();
            }),
          );
        }
        return undefined;
      })
      .filter(i => i);
    return simpleUniqueHashes.concat(compoundUniqueHashes);
  }
  initIdentifier(entity) {
    const wrapped = entity && helper(entity);
    if (!wrapped || wrapped.__identifier || wrapped.hasPrimaryKey()) {
      return;
    }
    const pks = wrapped.__meta.getPrimaryProps();
    const idents = [];
    for (const pk of pks) {
      if (pk.kind === ReferenceKind.SCALAR) {
        idents.push(new EntityIdentifier(entity[pk.name]));
      } else if (entity[pk.name]) {
        this.initIdentifier(entity[pk.name]);
        idents.push(helper(entity[pk.name])?.__identifier);
      }
    }
    if (pks.length === 1) {
      wrapped.__identifier = idents[0];
    } else {
      wrapped.__identifier = idents;
    }
  }
  processReference(parent, prop, kind, visited, processed, idx) {
    const isToOne = prop.kind === ReferenceKind.MANY_TO_ONE || prop.kind === ReferenceKind.ONE_TO_ONE;
    if (isToOne && Utils.isEntity(kind)) {
      return this.processToOneReference(kind, visited, processed, idx);
    }
    if (Utils.isCollection(kind)) {
      kind
        .getItems(false)
        .filter(item => !item.__helper.__originalEntityData)
        .forEach(item => {
          // propagate schema from parent
          item.__helper.__schema ??= helper(parent).__schema;
        });
      if (prop.kind === ReferenceKind.MANY_TO_MANY && kind.isDirty()) {
        this.processToManyReference(kind, visited, processed, parent, prop);
      }
    }
  }
  processToOneReference(kind, visited, processed, idx) {
    if (!kind.__helper.__managed) {
      this.findNewEntities(kind, visited, idx, processed);
    }
  }
  processToManyReference(collection, visited, processed, parent, prop) {
    if (this.isCollectionSelfReferenced(collection, processed)) {
      this.#extraUpdates.add([parent, prop.name, collection, undefined, ChangeSetType.UPDATE]);
      const coll = new Collection(parent);
      coll.property = prop;
      parent[prop.name] = coll;
      return;
    }
    collection
      .getItems(false)
      .filter(item => !item.__helper.__originalEntityData)
      .forEach(item => this.findNewEntities(item, visited, 0, processed));
  }
  async runHooks(type, changeSet, sync = false) {
    const meta = changeSet.meta;
    if (!this.#eventManager.hasListeners(type, meta)) {
      return;
    }
    if (!sync) {
      await this.#eventManager.dispatchEvent(type, { entity: changeSet.entity, meta, em: this.#em, changeSet });
      return;
    }
    const copy = this.#comparator.prepareEntity(changeSet.entity);
    await this.#eventManager.dispatchEvent(type, { entity: changeSet.entity, meta, em: this.#em, changeSet });
    const current = this.#comparator.prepareEntity(changeSet.entity);
    const diff = this.#comparator.diffEntities(changeSet.meta.class, copy, current);
    Object.assign(changeSet.payload, diff);
    const wrapped = helper(changeSet.entity);
    if (wrapped.__identifier) {
      const idents = Utils.asArray(wrapped.__identifier);
      let i = 0;
      for (const pk of wrapped.__meta.primaryKeys) {
        if (diff[pk]) {
          idents[i].setValue(diff[pk]);
        }
        i++;
      }
    }
  }
  postCommitCleanup() {
    for (const cs of this.#changeSets.values()) {
      const wrapped = helper(cs.entity);
      wrapped.__processing = false;
      delete wrapped.__pk;
    }
    this.#persistStack.clear();
    this.#removeStack.clear();
    this.#orphanRemoveStack.clear();
    this.#changeSets.clear();
    this.#collectionUpdates.clear();
    this.#extraUpdates.clear();
    this.#queuedActions.clear();
    this.#working = false;
  }
  cascade(entity, type, visited = new Set(), options = {}) {
    if (visited.has(entity)) {
      return;
    }
    visited.add(entity);
    switch (type) {
      case Cascade.PERSIST:
        this.persist(entity, visited, options);
        break;
      case Cascade.MERGE:
        this.merge(entity, visited);
        break;
      case Cascade.REMOVE:
        this.remove(entity, visited, options);
        break;
      case Cascade.SCHEDULE_ORPHAN_REMOVAL:
        this.scheduleOrphanRemoval(entity, visited);
        break;
      case Cascade.CANCEL_ORPHAN_REMOVAL:
        this.cancelOrphanRemoval(entity, visited);
        break;
    }
    for (const prop of helper(entity).__meta.relations) {
      this.cascadeReference(entity, prop, type, visited, options);
    }
  }
  cascadeReference(entity, prop, type, visited, options) {
    this.fixMissingReference(entity, prop);
    if (!this.shouldCascade(prop, type)) {
      return;
    }
    const kind = Reference.unwrapReference(entity[prop.name]);
    if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && Utils.isEntity(kind)) {
      return this.cascade(kind, type, visited, options);
    }
    const collection = kind;
    if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind) && collection) {
      for (const item of collection.getItems(false)) {
        this.cascade(item, type, visited, options);
      }
    }
  }
  isCollectionSelfReferenced(collection, processed) {
    const filtered = collection.getItems(false).filter(item => !helper(item).__originalEntityData);
    return filtered.some(items => processed.has(items));
  }
  shouldCascade(prop, type) {
    if (
      [Cascade.REMOVE, Cascade.SCHEDULE_ORPHAN_REMOVAL, Cascade.CANCEL_ORPHAN_REMOVAL, Cascade.ALL].includes(type) &&
      prop.orphanRemoval
    ) {
      return true;
    }
    // ignore user settings for merge, it is kept only for back compatibility, this should have never been configurable
    if (type === Cascade.MERGE) {
      return true;
    }
    return prop.cascade && (prop.cascade.includes(type) || prop.cascade.includes(Cascade.ALL));
  }
  async lockPessimistic(entity, options) {
    if (!this.#em.isInTransaction()) {
      throw ValidationError.transactionRequired();
    }
    await this.#em.getDriver().lockPessimistic(entity, { ctx: this.#em.getTransactionContext(), ...options });
  }
  async lockOptimistic(entity, meta, version) {
    if (!meta.versionProperty) {
      throw OptimisticLockError.notVersioned(meta);
    }
    if (typeof version === 'undefined') {
      return;
    }
    const wrapped = helper(entity);
    if (!wrapped.__initialized) {
      await wrapped.init();
    }
    const previousVersion = entity[meta.versionProperty];
    if (previousVersion !== version) {
      throw OptimisticLockError.lockFailedVersionMismatch(entity, version, previousVersion);
    }
  }
  fixMissingReference(entity, prop) {
    const reference = entity[prop.name];
    const target = Reference.unwrapReference(reference);
    if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && target && !prop.mapToPk) {
      if (!Utils.isEntity(target)) {
        entity[prop.name] = this.#em.getReference(prop.targetMeta.class, target, {
          wrapped: !!prop.ref,
        });
      } else if (!helper(target).__initialized && !helper(target).__em) {
        const pk = helper(target).getPrimaryKey();
        entity[prop.name] = this.#em.getReference(prop.targetMeta.class, pk, {
          wrapped: !!prop.ref,
        });
      }
    }
    // perf: set the `Collection._property` to skip the getter, as it can be slow when there are a lot of relations
    if (Utils.isCollection(target)) {
      target.property = prop;
    }
    const isCollection = [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind);
    if (isCollection && Array.isArray(target)) {
      const collection = new Collection(entity);
      collection.property = prop;
      entity[prop.name] = collection;
      collection.set(target);
    }
  }
  async persistToDatabase(groups, ctx) {
    if (ctx) {
      this.#em.setTransactionContext(ctx);
    }
    const commitOrder = this.getCommitOrder();
    const commitOrderReversed = [...commitOrder].reverse();
    // early delete - when we recreate entity in the same UoW, we need to issue those delete queries before inserts
    for (const meta of commitOrderReversed) {
      await this.commitDeleteChangeSets(groups[ChangeSetType.DELETE_EARLY].get(meta) ?? [], ctx);
    }
    // early update - when we recreate entity in the same UoW, we need to issue those delete queries before inserts
    for (const meta of commitOrder) {
      await this.commitUpdateChangeSets(groups[ChangeSetType.UPDATE_EARLY].get(meta) ?? [], ctx);
    }
    // extra updates
    await this.commitExtraUpdates(ChangeSetType.UPDATE_EARLY, ctx);
    // create
    for (const meta of commitOrder) {
      await this.commitCreateChangeSets(groups[ChangeSetType.CREATE].get(meta) ?? [], ctx);
    }
    // update
    for (const meta of commitOrder) {
      await this.commitUpdateChangeSets(groups[ChangeSetType.UPDATE].get(meta) ?? [], ctx);
    }
    // extra updates
    await this.commitExtraUpdates(ChangeSetType.UPDATE, ctx);
    // collection updates
    await this.commitCollectionUpdates(ctx);
    // delete - entity deletions need to be in reverse commit order
    for (const meta of commitOrderReversed) {
      await this.commitDeleteChangeSets(groups[ChangeSetType.DELETE].get(meta) ?? [], ctx);
    }
    // take snapshots of all persisted collections
    const visited = new Set();
    for (const changeSet of this.#changeSets.values()) {
      this.takeCollectionSnapshots(changeSet.entity, visited);
    }
  }
  async commitCreateChangeSets(changeSets, ctx) {
    if (changeSets.length === 0) {
      return;
    }
    const props = changeSets[0].meta.root.relations.filter(prop => {
      return (
        (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner) ||
        prop.kind === ReferenceKind.MANY_TO_ONE ||
        (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner && !this.#platform.usesPivotTable())
      );
    });
    for (const changeSet of changeSets) {
      this.findExtraUpdates(changeSet, props);
      await this.runHooks(EventType.beforeCreate, changeSet, true);
    }
    await this.#changeSetPersister.executeInserts(changeSets, { ctx });
    for (const changeSet of changeSets) {
      this.register(changeSet.entity, changeSet.payload, { refresh: true });
      await this.runHooks(EventType.afterCreate, changeSet);
    }
  }
  findExtraUpdates(changeSet, props) {
    for (const prop of props) {
      const ref = changeSet.entity[prop.name];
      if (!ref || prop.deferMode === DeferMode.INITIALLY_DEFERRED) {
        continue;
      }
      if (Utils.isCollection(ref)) {
        ref.getItems(false).some(item => {
          const cs = this.#changeSets.get(Reference.unwrapReference(item));
          const isScheduledForInsert = cs?.type === ChangeSetType.CREATE && !cs.persisted;
          if (isScheduledForInsert) {
            this.scheduleExtraUpdate(changeSet, [prop]);
            return true;
          }
          return false;
        });
        continue;
      }
      const refEntity = Reference.unwrapReference(ref);
      // For mapToPk properties, the value is a primitive (string/array), not an entity
      if (!Utils.isEntity(refEntity)) {
        continue;
      }
      // For TPT entities, check if the ROOT table's changeset has been persisted
      // (since the FK is to the root table, not the concrete entity's table)
      let cs = this.#changeSets.get(refEntity);
      if (cs?.tptChangeSets?.length) {
        // Root table changeset is the last one (ordered immediate parent → root)
        cs = cs.tptChangeSets[cs.tptChangeSets.length - 1];
      }
      const isScheduledForInsert = cs?.type === ChangeSetType.CREATE && !cs.persisted;
      if (isScheduledForInsert) {
        this.scheduleExtraUpdate(changeSet, [prop]);
      }
    }
  }
  findEarlyUpdates(changeSet, inserts = []) {
    const props = changeSet.meta.uniqueProps;
    for (const prop of props) {
      const insert = inserts.find(c => Utils.equals(c.payload[prop.name], changeSet.originalEntity[prop.name]));
      const propEmpty = changeSet.payload[prop.name] === null || changeSet.payload[prop.name] === undefined;
      if (
        prop.name in changeSet.payload &&
        insert &&
        // We only want to update early if the unique property on the changeset is going to be empty, so that
        // the previous unique value can be set on a different entity without constraint issues
        propEmpty
      ) {
        changeSet.type = ChangeSetType.UPDATE_EARLY;
      }
    }
  }
  async commitUpdateChangeSets(changeSets, ctx, batched = true) {
    if (changeSets.length === 0) {
      return;
    }
    for (const changeSet of changeSets) {
      await this.runHooks(EventType.beforeUpdate, changeSet, true);
    }
    await this.#changeSetPersister.executeUpdates(changeSets, batched, { ctx });
    for (const changeSet of changeSets) {
      const wrapped = helper(changeSet.entity);
      wrapped.__originalEntityData = this.#comparator.prepareEntity(changeSet.entity);
      if (!wrapped.__initialized) {
        for (const prop of changeSet.meta.relations) {
          if (
            [ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind) &&
            changeSet.entity[prop.name] == null
          ) {
            changeSet.entity[prop.name] = Collection.create(
              changeSet.entity,
              prop.name,
              undefined,
              wrapped.isInitialized(),
            );
          }
        }
        wrapped.__initialized = true;
      }
      await this.runHooks(EventType.afterUpdate, changeSet);
    }
  }
  async commitDeleteChangeSets(changeSets, ctx) {
    if (changeSets.length === 0) {
      return;
    }
    for (const changeSet of changeSets) {
      await this.runHooks(EventType.beforeDelete, changeSet, true);
    }
    await this.#changeSetPersister.executeDeletes(changeSets, { ctx });
    for (const changeSet of changeSets) {
      this.unsetIdentity(changeSet.entity);
      await this.runHooks(EventType.afterDelete, changeSet);
    }
  }
  async commitExtraUpdates(type, ctx) {
    const extraUpdates = [];
    for (const extraUpdate of this.#extraUpdates) {
      if (extraUpdate[4] !== type) {
        continue;
      }
      if (Array.isArray(extraUpdate[1])) {
        extraUpdate[1].forEach((p, i) => (extraUpdate[0][p] = extraUpdate[2][i]));
      } else {
        extraUpdate[0][extraUpdate[1]] = extraUpdate[2];
      }
      const changeSet = this.#changeSetComputer.computeChangeSet(extraUpdate[0]);
      if (changeSet) {
        extraUpdates.push([changeSet, extraUpdate[3]]);
      }
    }
    await this.commitUpdateChangeSets(
      extraUpdates.map(u => u[0]),
      ctx,
      false,
    );
    // propagate the new values to the original changeset
    for (const extraUpdate of extraUpdates) {
      if (extraUpdate[1]) {
        Object.assign(extraUpdate[1].payload, extraUpdate[0].payload);
      }
    }
  }
  async commitCollectionUpdates(ctx) {
    this.filterCollectionUpdates();
    const loggerContext = Utils.merge(
      { id: this.#em._id },
      this.#em.getLoggerContext({ disableContextResolution: true }),
    );
    await this.#em.getDriver().syncCollections(this.#collectionUpdates, {
      ctx,
      schema: this.#em.schema,
      loggerContext,
    });
    for (const coll of this.#collectionUpdates) {
      coll.takeSnapshot();
    }
  }
  filterCollectionUpdates() {
    for (const coll of this.#collectionUpdates) {
      let skip = true;
      if (coll.property.owner || coll.getItems(false).filter(item => !item.__helper.__initialized).length > 0) {
        if (this.#platform.usesPivotTable()) {
          skip = false;
        }
      } else if (coll.property.kind === ReferenceKind.ONE_TO_MANY && coll.getSnapshot() === undefined) {
        skip = false;
      } else if (coll.property.kind === ReferenceKind.MANY_TO_MANY && !coll.property.owner) {
        skip = false;
      }
      if (skip) {
        this.#collectionUpdates.delete(coll);
      }
    }
  }
  /**
   * Orders change sets so FK constrains are maintained, ensures stable order (needed for node < 11)
   */
  getChangeSetGroups() {
    const groups = {
      [ChangeSetType.CREATE]: new Map(),
      [ChangeSetType.UPDATE]: new Map(),
      [ChangeSetType.DELETE]: new Map(),
      [ChangeSetType.UPDATE_EARLY]: new Map(),
      [ChangeSetType.DELETE_EARLY]: new Map(),
    };
    const addToGroup = cs => {
      // Skip stub TPT changesets with empty payload (e.g. leaf with no own-property changes on UPDATE)
      if (
        (cs.type === ChangeSetType.UPDATE || cs.type === ChangeSetType.UPDATE_EARLY) &&
        !Utils.hasObjectKeys(cs.payload)
      ) {
        return;
      }
      const group = groups[cs.type];
      const groupKey = cs.meta.inheritanceType === 'tpt' ? cs.meta : cs.rootMeta;
      const classGroup = group.get(groupKey) ?? [];
      classGroup.push(cs);
      if (!group.has(groupKey)) {
        group.set(groupKey, classGroup);
      }
    };
    for (const cs of this.#changeSets.values()) {
      addToGroup(cs);
      for (const parentCs of cs.tptChangeSets ?? []) {
        addToGroup(parentCs);
      }
    }
    return groups;
  }
  getCommitOrder() {
    const calc = new CommitOrderCalculator();
    const set = new Set();
    this.#changeSets.forEach(cs => {
      if (cs.meta.inheritanceType === 'tpt') {
        set.add(cs.meta);
        for (const parentCs of cs.tptChangeSets ?? []) {
          set.add(parentCs.meta);
        }
      } else {
        set.add(cs.rootMeta);
      }
    });
    set.forEach(meta => calc.addNode(meta._id));
    for (const meta of set) {
      for (const prop of meta.relations) {
        if (prop.polymorphTargets) {
          for (const targetMeta of prop.polymorphTargets) {
            calc.discoverProperty({ ...prop, targetMeta }, meta._id);
          }
        } else {
          calc.discoverProperty(prop, meta._id);
        }
      }
      // For TPT, parent table must be inserted BEFORE child tables
      if (meta.inheritanceType === 'tpt' && meta.tptParent && set.has(meta.tptParent)) {
        calc.addDependency(meta.tptParent._id, meta._id, 1);
      }
    }
    return calc.sort().map(id => this.#metadata.getById(id));
  }
  resetTransaction(oldTx) {
    if (oldTx) {
      this.#em.setTransactionContext(oldTx);
    } else {
      this.#em.resetTransactionContext();
    }
  }
  /**
   * Takes snapshots of all processed collections
   */
  takeCollectionSnapshots(entity, visited) {
    if (visited.has(entity)) {
      return;
    }
    visited.add(entity);
    helper(entity)?.__meta.relations.forEach(prop => {
      const value = entity[prop.name];
      if (Utils.isCollection(value)) {
        value.takeSnapshot();
      }
      // cascade to m:1 relations as we need to snapshot the 1:m inverse side (for `removeAll()` with orphan removal)
      if (prop.kind === ReferenceKind.MANY_TO_ONE && value) {
        this.takeCollectionSnapshots(Reference.unwrapReference(value), visited);
      }
    });
  }
}
