import { Collection } from './Collection.js';
import { Utils } from '../utils/Utils.js';
import { Reference } from './Reference.js';
import { ReferenceKind, SCALAR_TYPES } from '../enums.js';
import { validateProperty } from './validators.js';
import { helper, wrap } from './wrap.js';
import { EntityHelper } from './EntityHelper.js';
import { ValidationError } from '../errors.js';
/** Handles assigning data to entities, resolving relations, and propagating changes. */
export class EntityAssigner {
  /** Assigns the given data to the entity, resolving relations and handling custom types. */
  static assign(entity, data, options = {}) {
    let opts = options;
    if (opts.visited?.has(entity)) {
      return entity;
    }
    EntityHelper.ensurePropagation(entity);
    opts.visited ??= new Set();
    opts.visited.add(entity);
    const wrapped = helper(entity);
    opts = {
      ...wrapped.__config.get('assign'),
      schema: wrapped.__schema,
      ...opts, // allow overriding the defaults
    };
    const meta = wrapped.__meta;
    const props = meta.properties;
    Object.keys(data).forEach(prop => {
      return EntityAssigner.assignProperty(entity, prop, props, data, {
        ...opts,
        em: opts.em || wrapped.__em,
        platform: wrapped.__platform,
      });
    });
    return entity;
  }
  static assignProperty(entity, propName, props, data, options) {
    let value = data[propName];
    const onlyProperties = options.onlyProperties && !(propName in props);
    const ignoreUndefined = options.ignoreUndefined === true && value === undefined;
    if (onlyProperties || ignoreUndefined) {
      return;
    }
    const prop = { ...props[propName], name: propName };
    if (prop && options.onlyOwnProperties) {
      if ([ReferenceKind.ONE_TO_MANY].includes(prop.kind)) {
        return;
      }
      if ([ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
        if (!prop.owner) {
          return;
        } else if (value?.map) {
          value = value.map(v => Utils.extractPK(v, prop.targetMeta));
        }
      }
      if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
        value = Utils.extractPK(value, prop.targetMeta);
      }
    }
    if (propName in props && !prop.nullable && value == null) {
      throw new Error(
        `You must pass a non-${value} value to the property ${propName} of entity ${entity.constructor.name}.`,
      );
    }
    // create collection instance if its missing so old items can be deleted with orphan removal
    if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop?.kind) && entity[prop.name] == null) {
      entity[prop.name] = Collection.create(entity, prop.name, undefined, helper(entity).isInitialized());
    }
    if (prop && Utils.isCollection(entity[prop.name])) {
      return EntityAssigner.assignCollection(entity, entity[prop.name], value, prop, options.em, options);
    }
    const customType = prop?.customType;
    if (options.convertCustomTypes && customType && prop.kind === ReferenceKind.SCALAR && !Utils.isEntity(data)) {
      value = customType.convertToJSValue(value, options.platform);
    }
    if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop?.kind) && value != null) {
      if (
        options.updateNestedEntities &&
        Object.hasOwn(entity, propName) &&
        Utils.isEntity(entity[propName], true) &&
        Utils.isPlainObject(value)
      ) {
        const unwrappedEntity = Reference.unwrapReference(entity[propName]);
        const wrapped = helper(unwrappedEntity);
        if (options.updateByPrimaryKey) {
          const pk = Utils.extractPK(value, prop.targetMeta);
          if (pk) {
            const ref = options.em.getReference(prop.targetMeta.class, pk, options);
            // if the PK differs, we want to change the target entity, not update it
            const wrappedChild = helper(ref);
            const sameTarget = wrappedChild.getSerializedPrimaryKey() === wrapped.getSerializedPrimaryKey();
            if (wrappedChild.__managed && wrappedChild.isInitialized() && sameTarget) {
              return EntityAssigner.assign(ref, value, options);
            }
          }
          return EntityAssigner.assignReference(entity, value, prop, options.em, options);
        }
        if (wrapped.__managed && wrap(unwrappedEntity).isInitialized()) {
          return EntityAssigner.assign(unwrappedEntity, value, options);
        }
      }
      return EntityAssigner.assignReference(entity, value, prop, options.em, options);
    }
    if (prop.kind === ReferenceKind.SCALAR && SCALAR_TYPES.has(prop.runtimeType) && (prop.setter || !prop.getter)) {
      validateProperty(prop, value, entity);
      return (entity[prop.name] = value);
    }
    if (prop.kind === ReferenceKind.EMBEDDED && EntityAssigner.validateEM(options.em)) {
      return EntityAssigner.assignEmbeddable(entity, value, prop, options.em, options);
    }
    if (options.mergeObjectProperties && Utils.isPlainObject(entity[propName]) && Utils.isPlainObject(value)) {
      entity[propName] ??= {};
      entity[propName] = Utils.merge({}, entity[propName], value);
    } else if (!prop || prop.setter || !prop.getter) {
      entity[propName] = value;
    }
  }
  /**
   * auto-wire 1:1 inverse side with owner as in no-sql drivers it can't be joined
   * also makes sure the link is bidirectional when creating new entities from nested structures
   * @internal
   */
  static autoWireOneToOne(prop, entity) {
    const ref = entity[prop.name];
    if (prop.kind !== ReferenceKind.ONE_TO_ONE || !Utils.isEntity(ref)) {
      return;
    }
    const meta2 = helper(ref).__meta;
    const prop2 = meta2.properties[prop.inversedBy || prop.mappedBy];
    /* v8 ignore next */
    if (prop2 && !ref[prop2.name]) {
      if (Reference.isReference(ref)) {
        ref.unwrap()[prop2.name] = Reference.wrapReference(entity, prop2);
      } else {
        ref[prop2.name] = Reference.wrapReference(entity, prop2);
      }
    }
  }
  static validateEM(em) {
    if (!em) {
      throw new Error(
        `To use assign() on not managed entities, explicitly provide EM instance: wrap(entity).assign(data, { em: orm.em })`,
      );
    }
    return true;
  }
  static assignReference(entity, value, prop, em, options) {
    if (Utils.isEntity(value, true)) {
      entity[prop.name] = Reference.wrapReference(value, prop);
    } else if (Utils.isPrimaryKey(value, true) && EntityAssigner.validateEM(em)) {
      entity[prop.name] = prop.mapToPk
        ? value
        : Reference.wrapReference(em.getReference(prop.targetMeta.class, value, options), prop);
    } else if (Utils.isPlainObject(value) && options.merge && EntityAssigner.validateEM(em)) {
      entity[prop.name] = Reference.wrapReference(em.merge(prop.targetMeta.class, value, options), prop);
    } else if (Utils.isPlainObject(value) && EntityAssigner.validateEM(em)) {
      entity[prop.name] = Reference.wrapReference(em.create(prop.targetMeta.class, value, options), prop);
    } else {
      const name = entity.constructor.name;
      throw new Error(
        `Invalid reference value provided for '${name}.${prop.name}' in ${name}.assign(): ${JSON.stringify(value)}`,
      );
    }
    EntityAssigner.autoWireOneToOne(prop, entity);
  }
  static assignCollection(entity, collection, value, prop, em, options) {
    const invalid = [];
    const items = Utils.asArray(value).map((item, idx) => {
      // try to propagate missing owning side reference to the payload first
      const prop2 = prop.targetMeta?.properties[prop.mappedBy];
      if (Utils.isPlainObject(item) && prop2 && item[prop2.name] == null) {
        item = { ...item, [prop2.name]: Reference.wrapReference(entity, prop2) };
      }
      if (options.updateNestedEntities && options.updateByPrimaryKey && Utils.isPlainObject(item)) {
        const pk = Utils.extractPK(item, prop.targetMeta);
        if (pk && EntityAssigner.validateEM(em)) {
          const ref = em.getUnitOfWork().getById(prop.targetMeta.class, pk, options.schema);
          if (ref) {
            return EntityAssigner.assign(ref, item, options);
          }
        }
        return this.createCollectionItem(item, em, prop, invalid, options);
      }
      /* v8 ignore next */
      if (
        options.updateNestedEntities &&
        !options.updateByPrimaryKey &&
        collection[idx] &&
        helper(collection[idx])?.isInitialized()
      ) {
        return EntityAssigner.assign(collection[idx], item, options);
      }
      return this.createCollectionItem(item, em, prop, invalid, options);
    });
    if (invalid.length > 0) {
      const name = entity.constructor.name;
      throw ValidationError.invalidCollectionValues(name, prop.name, invalid);
    }
    if (Array.isArray(value)) {
      collection.set(items);
    } else {
      // append to the collection in case of assigning a single value instead of array
      collection.add(items);
    }
  }
  static assignEmbeddable(entity, value, prop, em, options) {
    const propName = prop.embedded ? prop.embedded[1] : prop.name;
    if (value == null) {
      entity[propName] = value;
      return;
    }
    // if the value is not an array, we just push, otherwise we replace the array
    if (prop.array && (Array.isArray(value) || entity[propName] == null)) {
      entity[propName] = [];
    }
    if (prop.array) {
      return Utils.asArray(value).forEach(item => {
        const tmp = {};
        this.assignEmbeddable(tmp, item, { ...prop, array: false }, em, options);
        entity[propName].push(...Object.values(tmp));
      });
    }
    const create = () =>
      EntityAssigner.validateEM(em) &&
      em.getEntityFactory().createEmbeddable(prop.targetMeta.class, value, {
        convertCustomTypes: options.convertCustomTypes,
        newEntity: options.mergeEmbeddedProperties ? !('propName' in entity) : true,
      });
    entity[propName] = options.mergeEmbeddedProperties ? entity[propName] || create() : create();
    Object.keys(value).forEach(key => {
      EntityAssigner.assignProperty(entity[propName], key, prop.embeddedProps, value, options);
    });
  }
  static createCollectionItem(item, em, prop, invalid, options) {
    if (Utils.isEntity(item)) {
      return item;
    }
    if (Utils.isPrimaryKey(item) && EntityAssigner.validateEM(em)) {
      return em.getReference(prop.targetMeta.class, item, options);
    }
    if (Utils.isPlainObject(item) && options.merge && EntityAssigner.validateEM(em)) {
      return em.merge(prop.targetMeta.class, item, options);
    }
    if (Utils.isPlainObject(item) && EntityAssigner.validateEM(em)) {
      return em.create(prop.targetMeta.class, item, options);
    }
    invalid.push(item);
    return item;
  }
}
export const assign = EntityAssigner.assign;
