import { Utils } from '../utils/Utils.js';
import { QueryHelper } from '../utils/QueryHelper.js';
import { EventType, ReferenceKind } from '../enums.js';
import { Reference } from './Reference.js';
import { helper } from './wrap.js';
import { EntityHelper } from './EntityHelper.js';
import { JsonType } from '../types/JsonType.js';
import { isRaw } from '../utils/RawQueryFragment.js';
/** @internal Factory responsible for creating, merging, and hydrating entity instances. */
export class EntityFactory {
  #driver;
  #platform;
  #config;
  #metadata;
  #hydrator;
  #eventManager;
  #comparator;
  #em;
  constructor(em) {
    this.#em = em;
    this.#driver = this.#em.getDriver();
    this.#platform = this.#driver.getPlatform();
    this.#config = this.#em.config;
    this.#metadata = this.#em.getMetadata();
    this.#hydrator = this.#config.getHydrator(this.#metadata);
    this.#eventManager = this.#em.getEventManager();
    this.#comparator = this.#em.getComparator();
  }
  /** Creates a new entity instance or returns an existing one from the identity map, hydrating it with the provided data. */
  create(entityName, data, options = {}) {
    data = Reference.unwrapReference(data);
    options.initialized ??= true;
    if (data.__entity) {
      return data;
    }
    const meta = this.#metadata.get(entityName);
    if (meta.virtual) {
      data = { ...data };
      const entity = this.createEntity(data, meta, options);
      this.hydrate(entity, meta, data, options);
      return entity;
    }
    if (meta.serializedPrimaryKey) {
      this.denormalizePrimaryKey(meta, data);
    }
    const meta2 = this.processDiscriminatorColumn(meta, data);
    const exists = this.findEntity(data, meta2, options);
    let wrapped = exists && helper(exists);
    if (wrapped && !options.refresh) {
      wrapped.__processing = true;
      Utils.dropUndefinedProperties(data);
      this.mergeData(meta2, exists, data, options);
      wrapped.__processing = false;
      if (wrapped.isInitialized()) {
        return exists;
      }
    }
    data = { ...data };
    const entity = exists ?? this.createEntity(data, meta2, options);
    wrapped = helper(entity);
    wrapped.__processing = true;
    wrapped.__initialized = options.initialized;
    if (options.newEntity || meta.forceConstructor || meta.virtual) {
      const tmp = { ...data };
      meta.constructorParams?.forEach(prop => delete tmp[prop]);
      this.hydrate(entity, meta2, tmp, options);
      // since we now process only a copy of the `data` via hydrator, but later we register the state with the full snapshot,
      // we need to go through all props with custom types that have `ensureComparable: true` and ensure they are comparable
      // even if they are not part of constructor parameters (as this is otherwise normalized during hydration, here only in `tmp`)
      if (options.convertCustomTypes) {
        for (const prop of meta.props) {
          if (prop.customType?.ensureComparable(meta, prop) && data[prop.name]) {
            if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
              continue;
            }
            if (
              [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
              Utils.isPlainObject(data[prop.name])
            ) {
              data[prop.name] = Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
            }
            if (prop.customType instanceof JsonType && this.#platform.convertsJsonAutomatically()) {
              data[prop.name] = prop.customType.convertToDatabaseValue(data[prop.name], this.#platform, {
                key: prop.name,
                mode: 'hydration',
              });
            }
          }
        }
      }
    } else {
      this.hydrate(entity, meta2, data, options);
    }
    if (exists && meta.root.inheritanceType && !(entity instanceof meta2.class)) {
      Object.setPrototypeOf(entity, meta2.prototype);
    }
    if (options.merge && wrapped.hasPrimaryKey()) {
      this.unitOfWork.register(entity, data, {
        // Always refresh to ensure the payload is in correct shape for joined strategy. When loading nested relations,
        // they will be created early without `Type.ensureComparable` being properly handled, resulting in extra updates.
        refresh: options.initialized,
        newEntity: options.newEntity,
        loaded: options.initialized,
      });
      if (options.recomputeSnapshot) {
        wrapped.__originalEntityData = this.#comparator.prepareEntity(entity);
      }
    }
    if (this.#eventManager.hasListeners(EventType.onInit, meta2)) {
      this.#eventManager.dispatchEvent(EventType.onInit, { entity, meta: meta2, em: this.#em });
    }
    wrapped.__processing = false;
    return entity;
  }
  /** Merges new data into an existing entity, preserving user-modified properties. */
  mergeData(meta, entity, data, options = {}) {
    // merge unchanged properties automatically
    data = QueryHelper.processParams(data);
    const existsData = this.#comparator.prepareEntity(entity);
    const originalEntityData = helper(entity).__originalEntityData ?? {};
    const diff = this.#comparator.diffEntities(meta.class, originalEntityData, existsData);
    // version properties are not part of entity snapshots
    if (
      meta.versionProperty &&
      data[meta.versionProperty] &&
      data[meta.versionProperty] !== originalEntityData[meta.versionProperty]
    ) {
      diff[meta.versionProperty] = data[meta.versionProperty];
    }
    const diff2 = this.#comparator.diffEntities(meta.class, existsData, data, { includeInverseSides: true });
    // do not override values changed by user
    Utils.keys(diff).forEach(key => delete diff2[key]);
    Utils.keys(diff2)
      .filter(key => {
        // ignore null values if there is already present non-null value
        if (existsData[key] != null) {
          return diff2[key] == null;
        }
        return diff2[key] === undefined;
      })
      .forEach(key => delete diff2[key]);
    // but always add collection properties and formulas if they are part of the `data`
    Utils.keys(data)
      .filter(
        key =>
          meta.properties[key]?.formula ||
          [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(meta.properties[key]?.kind),
      )
      .forEach(key => (diff2[key] = data[key]));
    // rehydrated with the new values, skip those changed by user
    // use full hydration if the entity is already initialized, even if the caller used `initialized: false`
    // (e.g. from createReference), otherwise scalar properties in diff2 won't be applied
    const initialized = options.initialized || helper(entity).__initialized;
    this.hydrate(entity, meta, diff2, initialized ? { ...options, initialized } : options);
    // we need to update the entity data only with keys that were not present before
    const nullVal = this.#config.get('forceUndefined') ? undefined : null;
    Utils.keys(diff2).forEach(key => {
      const prop = meta.properties[key];
      if (
        [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
        Utils.isPlainObject(data[prop.name])
      ) {
        // oxfmt-ignore
        diff2[key] = entity[prop.name] ? helper(entity[prop.name]).getPrimaryKey(options.convertCustomTypes) : null;
      }
      if (
        !options.convertCustomTypes &&
        [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE, ReferenceKind.SCALAR].includes(prop.kind) &&
        prop.customType?.ensureComparable(meta, prop) &&
        diff2[key] != null
      ) {
        const converted = prop.customType.convertToJSValue(diff2[key], this.#platform, { force: true });
        diff2[key] = prop.customType.convertToDatabaseValue(converted, this.#platform, { fromQuery: true });
      }
      originalEntityData[key] = diff2[key] === null ? nullVal : diff2[key];
      helper(entity).__loadedProperties.add(key);
    });
    // in case of joined loading strategy, we need to cascade the merging to possibly loaded relations manually
    meta.relations.forEach(prop => {
      if (
        [ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind) &&
        Array.isArray(data[prop.name])
      ) {
        // instead of trying to match the collection items (which could easily fail if the collection was loaded with different ordering),
        // we just create the entity from scratch, which will automatically pick the right one from the identity map and call `mergeData` on it
        data[prop.name]
          .filter(child => Utils.isPlainObject(child)) // objects with prototype can be PKs (e.g. `ObjectId`)
          .forEach(child => this.create(prop.targetMeta.class, child, options)); // we can ignore the value, we just care about the `mergeData` call
        return;
      }
      if (
        [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
        Utils.isPlainObject(data[prop.name]) &&
        entity[prop.name] &&
        helper(entity[prop.name]).__initialized
      ) {
        this.create(prop.targetMeta.class, data[prop.name], options); // we can ignore the value, we just care about the `mergeData` call
      }
    });
    this.unitOfWork.normalizeEntityData(meta, originalEntityData);
  }
  /** Creates or retrieves an uninitialized entity reference by its primary key or alternate key. */
  createReference(entityName, id, options = {}) {
    options.convertCustomTypes ??= true;
    const meta = this.#metadata.get(entityName);
    const schema = this.#driver.getSchemaName(meta, options);
    // Handle alternate key lookup
    if (options.key) {
      const value = '' + (Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id);
      const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
      if (exists) {
        return exists;
      }
      // Create entity stub - storeByKey will set the alternate key property and store in identity map
      const entity = this.create(entityName, {}, { ...options, initialized: false });
      this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
      return entity;
    }
    if (meta.simplePK) {
      const exists = this.unitOfWork.getById(entityName, id, schema);
      if (exists) {
        return exists;
      }
      const data = Utils.isPlainObject(id) ? id : { [meta.primaryKeys[0]]: Array.isArray(id) ? id[0] : id };
      return this.create(entityName, data, { ...options, initialized: false });
    }
    if (Array.isArray(id)) {
      id = Utils.getPrimaryKeyCondFromArray(id, meta);
    }
    const pks = Utils.getOrderedPrimaryKeys(id, meta, this.#platform);
    const exists = this.unitOfWork.getById(entityName, pks, schema, options.convertCustomTypes);
    if (exists) {
      return exists;
    }
    if (Utils.isPrimaryKey(id)) {
      id = { [meta.primaryKeys[0]]: id };
    }
    return this.create(entityName, id, { ...options, initialized: false });
  }
  /** Creates an embeddable entity instance from the provided data. */
  createEmbeddable(entityName, data, options = {}) {
    data = { ...data };
    const meta = this.#metadata.get(entityName);
    const meta2 = this.processDiscriminatorColumn(meta, data);
    return this.createEntity(data, meta2, options);
  }
  /** Returns the EntityComparator instance used for diffing entities. */
  getComparator() {
    return this.#comparator;
  }
  createEntity(data, meta, options) {
    const schema = this.#driver.getSchemaName(meta, options);
    if (options.newEntity || meta.forceConstructor || meta.virtual) {
      if (meta.polymorphs) {
        throw new Error(`Cannot create entity ${meta.className}, class prototype is unknown`);
      }
      const params = this.extractConstructorParams(meta, data, options);
      const Entity = meta.class;
      // creates new instance via constructor as this is the new entity
      const entity = new Entity(...params);
      // creating managed entity instance when `forceEntityConstructor` is enabled,
      // we need to wipe all the values as they would cause update queries on next flush
      if (!options.newEntity && (meta.forceConstructor || this.#config.get('forceEntityConstructor'))) {
        meta.props
          .filter(prop => prop.persist !== false && !prop.primary && data[prop.name] === undefined)
          .forEach(prop => delete entity[prop.name]);
      }
      if (meta.virtual) {
        return entity;
      }
      helper(entity).__schema = schema;
      if (options.initialized) {
        EntityHelper.ensurePropagation(entity);
      }
      return entity;
    }
    // creates new entity instance, bypassing constructor call as its already persisted entity
    const entity = Object.create(meta.class.prototype);
    helper(entity).__managed = true;
    helper(entity).__processing = !meta.embeddable && !meta.virtual;
    helper(entity).__schema = schema;
    if (options.merge && !options.newEntity) {
      this.#hydrator.hydrateReference(
        entity,
        meta,
        data,
        this,
        options.convertCustomTypes,
        options.schema,
        options.parentSchema,
      );
      this.unitOfWork.register(entity);
    }
    if (options.initialized) {
      EntityHelper.ensurePropagation(entity);
    }
    return entity;
  }
  assignDefaultValues(entity, meta) {
    for (const prop of meta.props) {
      if (prop.embedded || [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
        continue;
      }
      if (prop.onCreate) {
        entity[prop.name] ??= prop.onCreate(entity, this.#em);
      } else if (prop.default != null && !isRaw(prop.default) && entity[prop.name] === undefined) {
        entity[prop.name] = prop.default;
      }
      if (prop.kind === ReferenceKind.EMBEDDED && entity[prop.name]) {
        const items = prop.array ? entity[prop.name] : [entity[prop.name]];
        for (const item of items) {
          this.assignDefaultValues(item, prop.targetMeta);
        }
      }
    }
  }
  hydrate(entity, meta, data, options) {
    if (options.initialized) {
      this.#hydrator.hydrate(
        entity,
        meta,
        data,
        this,
        'full',
        options.newEntity,
        options.convertCustomTypes,
        options.schema,
        this.#driver.getSchemaName(meta, options),
        options.normalizeAccessors,
      );
    } else {
      this.#hydrator.hydrateReference(
        entity,
        meta,
        data,
        this,
        options.convertCustomTypes,
        options.schema,
        this.#driver.getSchemaName(meta, options),
        options.normalizeAccessors,
      );
    }
    Utils.keys(data).forEach(key => {
      helper(entity)?.__loadedProperties.add(key);
      helper(entity)?.__serializationContext.fields?.add(key);
    });
    const processOnCreateHooksEarly =
      options.processOnCreateHooksEarly ?? this.#config.get('processOnCreateHooksEarly');
    if (options.newEntity && processOnCreateHooksEarly) {
      this.assignDefaultValues(entity, meta);
    }
  }
  findEntity(data, meta, options) {
    const schema = this.#driver.getSchemaName(meta, options);
    if (meta.simplePK) {
      return this.unitOfWork.getById(meta.class, data[meta.primaryKeys[0]], schema);
    }
    if (!Array.isArray(data) && meta.primaryKeys.some(pk => data[pk] == null)) {
      return undefined;
    }
    const pks = Utils.getOrderedPrimaryKeys(data, meta, this.#platform, options.convertCustomTypes);
    return this.unitOfWork.getById(meta.class, pks, schema);
  }
  processDiscriminatorColumn(meta, data) {
    // Handle STI discriminator (persisted column)
    if (meta.root.inheritanceType === 'sti') {
      const prop = meta.properties[meta.root.discriminatorColumn];
      const value = data[prop.name];
      const type = meta.root.discriminatorMap[value];
      meta = type ? this.#metadata.get(type) : meta;
      return meta;
    }
    // Handle TPT discriminator (computed at query time)
    if (meta.root.inheritanceType === 'tpt' && meta.root.discriminatorMap) {
      const value = data[meta.root.tptDiscriminatorColumn];
      if (value) {
        const type = meta.root.discriminatorMap[value];
        meta = type ? this.#metadata.get(type) : meta;
      }
    }
    return meta;
  }
  /**
   * denormalize PK to value required by driver (e.g. ObjectId)
   */
  denormalizePrimaryKey(meta, data) {
    const pk = meta.getPrimaryProp();
    const spk = meta.properties[meta.serializedPrimaryKey];
    if (!spk?.serializedPrimaryKey) {
      return;
    }
    if (pk.type === 'ObjectId' && (data[pk.name] != null || data[spk.name] != null)) {
      data[pk.name] = this.#platform.denormalizePrimaryKey(data[spk.name] || data[pk.name]);
      delete data[spk.name];
    }
  }
  /**
   * returns parameters for entity constructor, creating references from plain ids
   */
  extractConstructorParams(meta, data, options) {
    if (!meta.constructorParams) {
      return [data];
    }
    return meta.constructorParams.map(k => {
      const prop = meta.properties[k];
      const value = data[k];
      if (prop && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && value) {
        const pk = Reference.unwrapReference(value);
        const entity = this.unitOfWork.getById(prop.targetMeta.class, pk, options.schema, true);
        if (entity) {
          return entity;
        }
        if (Utils.isEntity(value)) {
          return value;
        }
        const nakedPk = Utils.extractPK(value, prop.targetMeta, true);
        if (Utils.isObject(value) && !nakedPk) {
          return this.create(prop.targetMeta.class, value, options);
        }
        const { newEntity, initialized, ...rest } = options;
        const target = this.createReference(prop.targetMeta.class, nakedPk, rest);
        return Reference.wrapReference(target, prop);
      }
      if (prop?.kind === ReferenceKind.EMBEDDED && value) {
        /* v8 ignore next */
        if (Utils.isEntity(value)) {
          return value;
        }
        return this.createEmbeddable(prop.targetMeta.class, value, options);
      }
      if (!prop) {
        const tmp = { ...data };
        for (const prop of meta.props) {
          if (!options.convertCustomTypes || !prop.customType || tmp[prop.name] == null) {
            continue;
          }
          if (
            [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
            Utils.isPlainObject(tmp[prop.name]) &&
            !Utils.extractPK(tmp[prop.name], prop.targetMeta, true)
          ) {
            tmp[prop.name] = Reference.wrapReference(this.create(prop.targetMeta.class, tmp[prop.name], options), prop);
          } else if (prop.kind === ReferenceKind.SCALAR) {
            tmp[prop.name] = prop.customType.convertToJSValue(tmp[prop.name], this.#platform);
          }
        }
        return tmp;
      }
      if (options.convertCustomTypes && prop.customType && value != null) {
        return prop.customType.convertToJSValue(value, this.#platform);
      }
      return value;
    });
  }
  get unitOfWork() {
    return this.#em.getUnitOfWork(false);
  }
}
