import { ReferenceKind } from './enums.js';
import { Reference } from './entity/Reference.js';
import { EntityHelper } from './entity/EntityHelper.js';
import { helper } from './entity/wrap.js';
import { Utils } from './utils/Utils.js';
import { EntityComparator } from './utils/EntityComparator.js';
import { BaseEntity } from './entity/BaseEntity.js';
/** Symbol used to declare a custom repository type on an entity class (e.g., `[EntityRepositoryType]?: BookRepository`). */
export const EntityRepositoryType = Symbol('EntityRepositoryType');
/** Symbol used to declare the primary key property name(s) on an entity (e.g., `[PrimaryKeyProp]?: 'id'`). */
export const PrimaryKeyProp = Symbol('PrimaryKeyProp');
/** Symbol used to declare which properties are optional in `em.create()` (e.g., `[OptionalProps]?: 'createdAt'`). */
export const OptionalProps = Symbol('OptionalProps');
/** Symbol used to declare which relation properties should be eagerly loaded (e.g., `[EagerProps]?: 'author'`). */
export const EagerProps = Symbol('EagerProps');
/** Symbol used to declare which properties are hidden from serialization (e.g., `[HiddenProps]?: 'password'`). */
export const HiddenProps = Symbol('HiddenProps');
/** Symbol used to declare type-level configuration on an entity (e.g., `[Config]?: DefineConfig<{ forceObject: true }>`). */
export const Config = Symbol('Config');
/** Symbol used to declare the entity name as a string literal type (used by `defineEntity`). */
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const EntityName = Symbol('EntityName');
/**
 * Runtime metadata for an entity, holding its properties, relations, indexes, hooks, and more.
 * Created during metadata discovery and used throughout the ORM lifecycle.
 */
export class EntityMetadata {
  static counter = 0;
  _id = 1000 * EntityMetadata.counter++; // keep the id >= 1000 to allow computing cache keys by simple addition
  propertyOrder = new Map();
  constructor(meta = {}) {
    this.properties = {};
    this.props = [];
    this.primaryKeys = [];
    this.filters = {};
    this.hooks = {};
    this.indexes = [];
    this.uniques = [];
    this.checks = [];
    this.referencingProperties = [];
    this.concurrencyCheckKeys = new Set();
    Object.assign(this, meta);
    const name = meta.className ?? meta.name;
    if (!this.class && name) {
      const Class =
        this.extends === BaseEntity ? { [name]: class extends BaseEntity {} }[name] : { [name]: class {} }[name];
      this.class = Class;
    }
  }
  addProperty(prop) {
    this.properties[prop.name] = prop;
    this.propertyOrder.set(prop.name, this.props.length);
    this.sync();
  }
  removeProperty(name, sync = true) {
    delete this.properties[name];
    this.propertyOrder.delete(name);
    if (sync) {
      this.sync();
    }
  }
  getPrimaryProps(flatten = false) {
    const pks = this.primaryKeys.map(pk => this.properties[pk]);
    if (flatten) {
      return pks.flatMap(pk => {
        if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(pk.kind)) {
          return pk.targetMeta.getPrimaryProps(true);
        }
        return [pk];
      });
    }
    return pks;
  }
  getPrimaryProp() {
    return this.properties[this.primaryKeys[0]];
  }
  /**
   * Creates a mapping from property names to field names.
   * @param alias - Optional alias to prefix field names. Can be a string (same for all) or a function (per-property).
   *                When provided, also adds toString() returning the alias for backwards compatibility with formulas.
   * @param toStringAlias - Optional alias to return from toString(). Defaults to `alias` when it's a string.
   */
  createColumnMappingObject(alias, toStringAlias) {
    const resolveAlias = typeof alias === 'function' ? alias : () => alias;
    const defaultAlias = toStringAlias ?? (typeof alias === 'string' ? alias : undefined);
    const result = Object.values(this.properties).reduce((o, prop) => {
      if (prop.fieldNames) {
        const propAlias = resolveAlias(prop);
        o[prop.name] = propAlias ? `${propAlias}.${prop.fieldNames[0]}` : prop.fieldNames[0];
      }
      return o;
    }, {});
    // Add toString() for backwards compatibility when alias is provided
    Object.defineProperty(result, 'toString', {
      value: () => defaultAlias ?? '',
      enumerable: false,
    });
    // Wrap in Proxy to detect old formula signature usage where the first param was FormulaTable.
    // If user accesses `.alias` or `.qualifiedName` (FormulaTable-only properties), warn them.
    const warnedProps = new Set(['alias', 'qualifiedName']);
    return new Proxy(result, {
      get(target, prop, receiver) {
        if (typeof prop === 'string' && warnedProps.has(prop) && !(prop in target)) {
          // eslint-disable-next-line no-console
          console.warn(
            `[MikroORM] Detected old formula callback signature. The first parameter is now 'columns', not 'table'. ` +
              `Accessing '.${prop}' on the columns object will return undefined. ` +
              `Update your formula: formula(cols => quote\`\${cols.propName} ...\`). See the v7 upgrade guide.`,
          );
        }
        return Reflect.get(target, prop, receiver);
      },
    });
  }
  /**
   * Creates a column mapping for schema callbacks (indexes, checks, generated columns).
   * For TPT entities, only includes properties that belong to the current table (ownProps).
   */
  createSchemaColumnMappingObject() {
    // For TPT entities, only include properties that belong to this entity's table
    const props = this.inheritanceType === 'tpt' && this.ownProps ? this.ownProps : Object.values(this.properties);
    return props.reduce((o, prop) => {
      if (prop.fieldNames) {
        o[prop.name] = prop.fieldNames[0];
      }
      return o;
    }, {});
  }
  get tableName() {
    return this.collection;
  }
  set tableName(name) {
    this.collection = name;
  }
  get uniqueName() {
    return this.tableName + '_' + this._id;
  }
  sync(initIndexes = false, config) {
    this.root ??= this;
    const props = Object.values(this.properties).sort(
      (a, b) => this.propertyOrder.get(a.name) - this.propertyOrder.get(b.name),
    );
    this.props = [...props.filter(p => p.primary), ...props.filter(p => !p.primary)];
    this.relations = this.props.filter(
      prop =>
        typeof prop.kind !== 'undefined' && prop.kind !== ReferenceKind.SCALAR && prop.kind !== ReferenceKind.EMBEDDED,
    );
    this.bidirectionalRelations = this.relations.filter(prop => prop.mappedBy || prop.inversedBy);
    this.uniqueProps = this.props.filter(prop => prop.unique);
    this.getterProps = this.props.filter(prop => prop.getter);
    this.comparableProps = this.props.filter(prop => EntityComparator.isComparable(prop, this));
    this.validateProps = this.props.filter(prop => {
      if (prop.inherited || (prop.persist === false && prop.userDefined !== false)) {
        return false;
      }
      return prop.kind === ReferenceKind.SCALAR && ['string', 'number', 'boolean', 'Date'].includes(prop.type);
    });
    this.hydrateProps = this.props.filter(prop => {
      // `prop.userDefined` is either `undefined` or `false`
      const discriminator = this.root.discriminatorColumn === prop.name && prop.userDefined === false;
      // even if we don't have a setter, do not ignore value from database!
      const onlyGetter = prop.getter && !prop.setter && prop.persist === false;
      return !prop.inherited && prop.hydrate !== false && !discriminator && !prop.embedded && !onlyGetter;
    });
    this.trackingProps = this.hydrateProps.filter(prop => {
      return !prop.getter && !prop.setter && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
    });
    this.selfReferencing = this.relations.some(prop => {
      return this.root.uniqueName === prop.targetMeta?.root.uniqueName;
    });
    this.hasUniqueProps = this.uniques.length + this.uniqueProps.length > 0;
    // Normalize object-form `view` option: `view: { materialized: true, withData: false }`
    // into flat metadata fields (`view: true`, `materialized: true`, `withData: false`).
    if (typeof this.view === 'object') {
      this.materialized = this.view.materialized;
      this.withData = this.view.withData;
      this.view = true;
    }
    // If `view` is set, this is a database view entity (not a virtual entity).
    // Virtual entities evaluate expressions at query time, view entities create actual database views.
    this.virtual = !!this.expression && !this.view;
    if (config) {
      for (const prop of this.props) {
        if (prop.enum && !prop.nativeEnumName && prop.items?.every(item => typeof item === 'string')) {
          const name = config.getNamingStrategy().indexName(this.tableName, prop.fieldNames, 'check');
          const exists = this.checks.findIndex(check => check.name === name);
          if (exists !== -1) {
            this.checks.splice(exists, 1);
          }
          this.checks.push({
            name,
            property: prop.name,
            expression: `${config.getPlatform().quoteIdentifier(prop.fieldNames[0])} in ('${prop.items.join("', '")}')`,
          });
        }
      }
    }
    this.checks = Utils.removeDuplicates(this.checks);
    this.indexes = Utils.removeDuplicates(this.indexes);
    this.uniques = Utils.removeDuplicates(this.uniques);
    for (const hook of Utils.keys(this.hooks)) {
      this.hooks[hook] = Utils.removeDuplicates(this.hooks[hook]);
    }
    if (this.virtual || this.view) {
      this.readonly = true;
    }
    if (initIndexes && this.name) {
      this.props.forEach(prop => this.initIndexes(prop));
    }
    this.definedProperties = this.trackingProps.reduce(
      (o, prop) => {
        const hasInverse = (prop.inversedBy || prop.mappedBy) && !prop.mapToPk;
        if (hasInverse) {
          // eslint-disable-next-line @typescript-eslint/no-this-alias
          const meta = this;
          o[prop.name] = {
            get() {
              return this.__helper.__data[prop.name];
            },
            set(val) {
              const wrapped = this.__helper;
              const hydrator = wrapped.hydrator;
              const entity = Reference.unwrapReference(val ?? wrapped.__data[prop.name]);
              const old = Reference.unwrapReference(wrapped.__data[prop.name]);
              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) {
                const targetMeta = prop.targetMeta ?? helper(entity)?.__meta;
                if (targetMeta) {
                  wrapped.__originalEntityData[prop.name] = Utils.getPrimaryKeyValues(val, targetMeta, true);
                }
              }
              EntityHelper.propagate(meta, entity, this, prop, Reference.unwrapReference(val), old);
            },
            enumerable: true,
            configurable: true,
          };
        }
        return o;
      },
      { __gettersDefined: { value: true, enumerable: false } },
    );
  }
  initIndexes(prop) {
    const simpleIndex = this.indexes.find(
      index => index.properties === prop.name && !index.options && !index.type && !index.expression,
    );
    const simpleUnique = this.uniques.find(index => index.properties === prop.name && !index.options);
    const owner = prop.kind === ReferenceKind.MANY_TO_ONE;
    if (!prop.index && simpleIndex) {
      Utils.defaultValue(simpleIndex, 'name', true);
      prop.index = simpleIndex.name;
      this.indexes.splice(this.indexes.indexOf(simpleIndex), 1);
    }
    if (!prop.unique && simpleUnique) {
      Utils.defaultValue(simpleUnique, 'name', true);
      prop.unique = simpleUnique.name;
      this.uniques.splice(this.uniques.indexOf(simpleUnique), 1);
    }
    if (prop.index && owner && prop.fieldNames.length > 1) {
      this.indexes.push({ properties: prop.name });
      prop.index = false;
    }
    /* v8 ignore next */
    if (owner && prop.fieldNames.length > 1 && prop.unique) {
      this.uniques.push({ properties: prop.name });
      prop.unique = false;
    }
  }
  /** @internal */
  clone() {
    return this;
  }
  /** @ignore */
  [Symbol.for('nodejs.util.inspect.custom')]() {
    return `[${this.constructor.name}<${this.className}>]`;
  }
}
