import {
  EagerProps,
  EntityName,
  EntityRepositoryType,
  HiddenProps,
  OptionalProps,
  PrimaryKeyProp,
} from '../typings.js';
import { EntityTransformer } from '../serialization/EntityTransformer.js';
import { Reference } from './Reference.js';
import { Utils } from '../utils/Utils.js';
import { WrappedEntity } from './WrappedEntity.js';
import { ReferenceKind } from '../enums.js';
import { helper } from './wrap.js';
import { inspect } from '../logging/inspect.js';
import { getEnv } from '../utils/env-vars.js';
/**
 * @internal
 */
export class EntityHelper {
  static decorate(meta, em) {
    const fork = em.fork(); // use fork so we can access `EntityFactory`
    const serializedPrimaryKey = meta.props.find(p => p.serializedPrimaryKey);
    if (serializedPrimaryKey) {
      Object.defineProperty(meta.prototype, serializedPrimaryKey.name, {
        get() {
          return this._id ? em.getPlatform().normalizePrimaryKey(this._id) : null;
        },
        set(id) {
          this._id = id ? em.getPlatform().denormalizePrimaryKey(id) : null;
        },
        configurable: true,
      });
    }
    EntityHelper.defineBaseProperties(meta, meta.prototype, fork);
    EntityHelper.defineCustomInspect(meta);
    if (em.config.get('propagationOnPrototype') && !meta.embeddable && !meta.virtual) {
      EntityHelper.defineProperties(meta, fork);
    }
    const prototype = meta.prototype;
    if (!prototype.toJSON) {
      // toJSON can be overridden
      Object.defineProperty(prototype, 'toJSON', {
        value: function (...args) {
          // Guard against being called on the prototype itself (e.g. by serializers
          // walking the object graph and calling toJSON on prototype objects)
          if (this === prototype) {
            return {};
          }
          return EntityTransformer.toObject(this, ...args);
        },
        writable: true,
        configurable: true,
        enumerable: false,
      });
    }
  }
  /**
   * As a performance optimization, we create entity state methods lazily. We first add
   * the `null` value to the prototype to reserve space in memory. Then we define a setter on the
   * prototype that will be executed exactly once per entity instance. There we redefine the given
   * property on the entity instance, so shadowing the prototype setter.
   */
  static defineBaseProperties(meta, prototype, em) {
    // oxfmt-ignore
    const helperParams = meta.embeddable || meta.virtual ? [] : [em.getComparator().getPkGetter(meta), em.getComparator().getPkSerializer(meta), em.getComparator().getPkGetterConverted(meta)];
    Object.defineProperties(prototype, {
      __entity: { value: !meta.embeddable, configurable: true },
      __meta: { value: meta, configurable: true },
      __config: { value: em.config, configurable: true },
      __platform: { value: em.getPlatform(), configurable: true },
      __factory: { value: em.getEntityFactory(), configurable: true },
      __helper: {
        get() {
          Object.defineProperty(this, '__helper', {
            value: new WrappedEntity(this, em.getHydrator(), ...helperParams),
            enumerable: false,
            configurable: true,
          });
          return this.__helper;
        },
        configurable: true, // otherwise jest fails when trying to compare entities ¯\_(ツ)_/¯
      },
    });
  }
  /**
   * Defines getter and setter for every owning side of m:1 and 1:1 relation. This is then used for propagation of
   * changes to the inverse side of bi-directional relations. Rest of the properties are also defined this way to
   * achieve dirtiness, which is then used for fast checks whether we need to auto-flush because of managed entities.
   *
   * First defines a setter on the prototype, once called, actual get/set handlers are registered on the instance rather
   * than on its prototype. Thanks to this we still have those properties enumerable (e.g. part of `Object.keys(entity)`).
   */
  static defineProperties(meta, em) {
    Object.values(meta.properties).forEach(prop => {
      const isCollection = [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind);
      // oxfmt-ignore
      const isReference = [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) && (prop.inversedBy || prop.mappedBy) && !prop.mapToPk;
      if (isReference) {
        Object.defineProperty(meta.prototype, prop.name, {
          set(val) {
            EntityHelper.defineReferenceProperty(meta, prop, this, em.getHydrator());
            this[prop.name] = val;
          },
          configurable: true,
        });
        return;
      }
      if (prop.inherited || prop.primary || prop.accessor || prop.persist === false || prop.embedded || isCollection) {
        return;
      }
      Object.defineProperty(meta.prototype, prop.name, {
        set(val) {
          Object.defineProperty(this, prop.name, {
            get() {
              return this.__helper?.__data[prop.name];
            },
            set(val) {
              this.__helper.__data[prop.name] = val;
            },
            enumerable: true,
            configurable: true,
          });
          this.__helper.__data[prop.name] = val;
        },
        configurable: true,
      });
    });
  }
  static defineCustomInspect(meta) {
    // @ts-ignore
    meta.prototype[Symbol.for('nodejs.util.inspect.custom')] ??= function (depth = 2) {
      const object = {};
      const keys = new Set(Utils.keys(this));
      for (const prop of meta.props) {
        if (keys.has(prop.name) || (prop.getter && prop.accessor === prop.name)) {
          object[prop.name] = this[prop.name];
        }
      }
      for (const key of keys) {
        if (!meta.properties[key]) {
          object[key] = this[key];
        }
      }
      // ensure we dont have internal symbols in the POJO
      [OptionalProps, EntityRepositoryType, PrimaryKeyProp, EagerProps, HiddenProps, EntityName].forEach(
        sym => delete object[sym],
      );
      meta.props.filter(prop => object[prop.name] === undefined).forEach(prop => delete object[prop.name]);
      const ret = inspect(object, { depth });
      let name = this.constructor.name;
      const showEM = ['true', 't', '1'].includes(getEnv('MIKRO_ORM_LOG_EM_ID')?.toLowerCase() ?? '');
      if (showEM) {
        if (helper(this).__em) {
          name += ` [managed by ${helper(this).__em.id}]`;
        } else {
          name += ` [not managed]`;
        }
      }
      // distinguish not initialized entities
      if (!helper(this).__initialized) {
        name = `(${name})`;
      }
      return ret === '[Object]' ? `[${name}]` : name + ' ' + ret;
    };
  }
  static defineReferenceProperty(meta, prop, ref, hydrator) {
    const wrapped = helper(ref);
    Object.defineProperty(ref, prop.name, {
      get() {
        return helper(ref).__data[prop.name];
      },
      set(val) {
        const entity = Reference.unwrapReference(val ?? wrapped.__data[prop.name]);
        const old = Reference.unwrapReference(wrapped.__data[prop.name]);
        // oxfmt-ignore
        if (old && old !== entity && prop.kind === ReferenceKind.MANY_TO_ONE && prop.inversedBy && old[prop.inversedBy]) {
                    old[prop.inversedBy].removeWithoutPropagation(this);
                }
        wrapped.__data[prop.name] = Reference.wrapReference(val, prop);
        // when propagation from inside hydration, we set the FK to the entity data immediately
        if (val && hydrator.isRunning() && wrapped.__originalEntityData && prop.owner) {
          wrapped.__originalEntityData[prop.name] = Utils.getPrimaryKeyValues(
            wrapped.__data[prop.name],
            prop.targetMeta,
            true,
          );
        }
        EntityHelper.propagate(meta, entity, this, prop, Reference.unwrapReference(val), old);
      },
      enumerable: true,
      configurable: true,
    });
  }
  static propagate(meta, entity, owner, prop, value, old) {
    // For polymorphic relations, get bidirectional relations from the actual entity's metadata
    let bidirectionalRelations;
    if (prop.polymorphic && prop.polymorphTargets?.length) {
      // For polymorphic relations, we need to get the bidirectional relations from the actual value's metadata
      if (!value) {
        return; // No value means no propagation needed
      }
      bidirectionalRelations = helper(value).__meta.bidirectionalRelations;
    } else {
      bidirectionalRelations = prop.targetMeta.bidirectionalRelations;
    }
    for (const prop2 of bidirectionalRelations) {
      if ((prop2.inversedBy || prop2.mappedBy) !== prop.name) {
        continue;
      }
      // oxfmt-ignore
      if (prop2.targetMeta.abstract ? prop2.targetMeta.root.class !== meta.root.class : prop2.targetMeta.class !== meta.class) {
                continue;
            }
      const inverse = value?.[prop2.name];
      if (prop.ref && owner[prop.name]) {
        // eslint-disable-next-line dot-notation
        owner[prop.name]['property'] = prop;
      }
      if (Utils.isCollection(inverse) && inverse.isPartial()) {
        continue;
      }
      if (prop.kind === ReferenceKind.MANY_TO_ONE && Utils.isCollection(inverse) && inverse.isInitialized()) {
        inverse.addWithoutPropagation(owner);
        helper(owner).__em?.getUnitOfWork().cancelOrphanRemoval(owner);
      }
      if (prop.kind === ReferenceKind.ONE_TO_ONE) {
        if (
          (value != null && Reference.unwrapReference(inverse) !== owner) ||
          (value == null && entity?.[prop2.name] != null)
        ) {
          if (entity && (!prop.owner || helper(entity).__initialized)) {
            EntityHelper.propagateOneToOne(entity, owner, prop, prop2, value, old);
          }
          if (old && prop.orphanRemoval) {
            helper(old).__em?.getUnitOfWork().scheduleOrphanRemoval(old);
          }
        }
      }
    }
  }
  static propagateOneToOne(entity, owner, prop, prop2, value, old) {
    helper(entity).__pk = helper(entity).getPrimaryKey();
    // the inverse side will be changed on the `value` too, so we need to clean-up and schedule orphan removal there too
    if (
      !prop.primary &&
      !prop2.mapToPk &&
      value?.[prop2.name] != null &&
      Reference.unwrapReference(value[prop2.name]) !== entity
    ) {
      const other = Reference.unwrapReference(value[prop2.name]);
      delete helper(other).__data[prop.name];
      if (prop2.orphanRemoval) {
        helper(other).__em?.getUnitOfWork().scheduleOrphanRemoval(other);
      }
    }
    // Skip setting the inverse side to null if it's a primary key - the entity will be removed via orphan removal
    // Setting a primary key to null would corrupt the entity and cause validation errors
    if (value == null && prop.orphanRemoval && prop2.primary) {
      return;
    }
    if (value == null) {
      entity[prop2.name] = value;
    } else if (prop2.mapToPk) {
      entity[prop2.name] = helper(owner).getPrimaryKey();
    } else {
      entity[prop2.name] = Reference.wrapReference(owner, prop);
    }
    if (old?.[prop2.name] != null) {
      delete helper(old).__data[prop2.name];
      old[prop2.name] = null;
    }
  }
  static ensurePropagation(entity) {
    if (entity.__gettersDefined) {
      return;
    }
    const wrapped = helper(entity);
    const meta = wrapped.__meta;
    const platform = wrapped.__platform;
    const serializedPrimaryKey = meta.props.find(p => p.serializedPrimaryKey);
    const values = [];
    if (serializedPrimaryKey) {
      const pk = meta.getPrimaryProps()[0];
      const val = entity[serializedPrimaryKey.name];
      delete entity[serializedPrimaryKey.name];
      Object.defineProperty(entity, serializedPrimaryKey.name, {
        get() {
          return this[pk.name] ? platform.normalizePrimaryKey(this[pk.name]) : null;
        },
        set(id) {
          this[pk.name] = id ? platform.denormalizePrimaryKey(id) : null;
        },
        configurable: true,
      });
      if (entity[pk.name] == null && val != null) {
        values.push(serializedPrimaryKey.name, val);
      }
    }
    for (const prop of meta.trackingProps) {
      if (entity[prop.name] !== undefined) {
        values.push(prop.name, entity[prop.name]);
      }
      delete entity[prop.name];
    }
    Object.defineProperties(entity, meta.definedProperties);
    for (let i = 0; i < values.length; i += 2) {
      entity[values[i]] = values[i + 1];
    }
  }
}
