import { EntityMetadata } from '../typings.js';
import { BaseEntity } from '../entity/BaseEntity.js';
import { Cascade, ReferenceKind } from '../enums.js';
import { Type } from '../types/Type.js';
import { Utils } from '../utils/Utils.js';
import { EnumArrayType } from '../types/EnumArrayType.js';
/** Class-less entity definition that provides a programmatic API for defining entities without decorators. */
export class EntitySchema {
  /**
   * When schema links the entity class via `class` option, this registry allows the lookup from opposite side,
   * so we can use the class in `entities` option just like the EntitySchema instance.
   */
  static REGISTRY = new Map();
  _meta;
  internal = false;
  initialized = false;
  constructor(meta) {
    meta.name = meta.class ? meta.class.name : meta.name;
    if (meta.name) {
      meta.abstract ??= false;
    }
    this._meta = new EntityMetadata({
      className: meta.name,
      ...meta,
    });
    this._meta.root ??= this._meta;
    if (meta.class && !meta.internal) {
      EntitySchema.REGISTRY.set(meta.class, this);
    }
  }
  /**
   * Checks if the given value is an EntitySchema instance, using duck-typing
   * as a fallback when `instanceof` fails due to CJS/ESM dual-package hazard
   * (e.g. when using `tsx` or `@swc-node/register` with `"type": "commonjs"` projects).
   */
  static is(item) {
    if (item instanceof EntitySchema) {
      return true;
    }
    return item != null && typeof item === 'object' && item.constructor?.name === 'EntitySchema' && 'meta' in item;
  }
  /** Creates an EntitySchema from existing EntityMetadata (used internally). */
  static fromMetadata(meta) {
    const schema = new EntitySchema({ ...meta, internal: true });
    schema.internal = true;
    return schema;
  }
  /** Adds a scalar property to the entity schema. */
  addProperty(name, type, options = {}) {
    this.renameCompositeOptions(name, options);
    const prop = {
      name,
      kind: ReferenceKind.SCALAR,
      ...options,
      ...this.normalizeType(options, type),
    };
    if (type && Type.isMappedType(type.prototype)) {
      prop.type = type;
    }
    if (typeof prop.formula === 'string') {
      const formula = prop.formula;
      prop.formula = () => formula;
    }
    if (prop.formula) {
      prop.persist ??= false;
    }
    this._meta.properties[name] = prop;
  }
  /** Adds an enum property to the entity schema. */
  addEnum(name, type, options = {}) {
    if (options.items instanceof Function) {
      options.items = Utils.extractEnumValues(options.items());
    }
    // enum arrays are simple numeric/string arrays, the constraint is enforced in the custom type only
    if (options.array && !options.type) {
      options.type = new EnumArrayType(`${this._meta.className}.${name}`, options.items);
      options.enum = false;
    }
    const prop = { enum: true, ...options };
    if (prop.array) {
      prop.enum = false;
    }
    // force string labels on native enums
    if (prop.nativeEnumName && Array.isArray(prop.items)) {
      prop.items = prop.items.map(val => '' + val);
    }
    this.addProperty(name, this.internal ? type : type || 'enum', prop);
  }
  /** Adds a version property for optimistic locking. */
  addVersion(name, type, options = {}) {
    this.addProperty(name, type, { version: true, ...options });
  }
  /** Adds a primary key property to the entity schema. */
  addPrimaryKey(name, type, options = {}) {
    this.addProperty(name, type, { primary: true, ...options });
  }
  /** Adds a serialized primary key property (e.g. for MongoDB ObjectId). */
  addSerializedPrimaryKey(name, type, options = {}) {
    this._meta.serializedPrimaryKey = name;
    this.addProperty(name, type, { serializedPrimaryKey: true, ...options });
  }
  /** Adds an embedded property to the entity schema. */
  addEmbedded(name, options) {
    this.renameCompositeOptions(name, options);
    Utils.defaultValue(options, 'prefix', true);
    if (options.array) {
      options.object = true; // force object mode for arrays
    }
    this._meta.properties[name] = {
      name,
      kind: ReferenceKind.EMBEDDED,
      ...this.normalizeType(options),
      ...options,
    };
  }
  /** Adds a many-to-one relation to the entity schema. */
  addManyToOne(name, type, options) {
    const prop = this.createProperty(ReferenceKind.MANY_TO_ONE, options);
    prop.owner = true;
    if (prop.joinColumns && !prop.fieldNames) {
      prop.fieldNames = prop.joinColumns;
    }
    if (prop.fieldNames && !prop.joinColumns) {
      prop.joinColumns = prop.fieldNames;
    }
    // By default, the foreign key constraint is created on the relation
    Utils.defaultValue(prop, 'createForeignKeyConstraint', true);
    this.addProperty(name, type, prop);
  }
  /** Adds a many-to-many relation to the entity schema. */
  addManyToMany(name, type, options) {
    options.fixedOrder = options.fixedOrder || !!options.fixedOrderColumn;
    if (!options.owner && !options.mappedBy) {
      options.owner = true;
    }
    if (options.owner) {
      Utils.renameKey(options, 'mappedBy', 'inversedBy');
      // By default, the foreign key constraint is created on the relation
      Utils.defaultValue(options, 'createForeignKeyConstraint', true);
    }
    const prop = this.createProperty(ReferenceKind.MANY_TO_MANY, options);
    this.addProperty(name, type, prop);
  }
  /** Adds a one-to-many relation to the entity schema. */
  addOneToMany(name, type, options) {
    const prop = this.createProperty(ReferenceKind.ONE_TO_MANY, options);
    this.addProperty(name, type, prop);
  }
  /** Adds a one-to-one relation to the entity schema. */
  addOneToOne(name, type, options) {
    const prop = this.createProperty(ReferenceKind.ONE_TO_ONE, options);
    Utils.defaultValue(prop, 'owner', !!prop.inversedBy || !prop.mappedBy);
    Utils.defaultValue(prop, 'unique', prop.owner);
    if (prop.owner) {
      if (options.mappedBy) {
        Utils.renameKey(prop, 'mappedBy', 'inversedBy');
      }
      // By default, the foreign key constraint is created on the relation
      Utils.defaultValue(prop, 'createForeignKeyConstraint', true);
    }
    if (prop.joinColumns && !prop.fieldNames) {
      prop.fieldNames = prop.joinColumns;
    }
    if (prop.fieldNames && !prop.joinColumns) {
      prop.joinColumns = prop.fieldNames;
    }
    this.addProperty(name, type, prop);
  }
  /** Adds an index definition to the entity schema. */
  addIndex(options) {
    this._meta.indexes.push(options);
  }
  /** Adds a unique constraint definition to the entity schema. */
  addUnique(options) {
    this._meta.uniques.push(options);
  }
  /** Sets a custom repository class for this entity. */
  setCustomRepository(repository) {
    this._meta.repository = repository;
  }
  /** Sets the base entity that this schema extends. */
  setExtends(base) {
    this._meta.extends = base;
  }
  /** Sets or replaces the entity class associated with this schema. */
  setClass(cls) {
    const oldClass = this._meta.class;
    const sameClass = this._meta.class === cls;
    this._meta.class = cls;
    this._meta.prototype = cls.prototype;
    this._meta.className = this._meta.name ?? cls.name;
    if (!sameClass || !this._meta.constructorParams) {
      this._meta.constructorParams = Utils.getConstructorParams(cls);
    }
    if (!this.internal) {
      // Remove old class from registry if it's being replaced with a different class
      if (oldClass && oldClass !== cls && EntitySchema.REGISTRY.get(oldClass) === this) {
        EntitySchema.REGISTRY.delete(oldClass);
      }
      EntitySchema.REGISTRY.set(cls, this);
    }
    const base = Object.getPrototypeOf(cls);
    // Only set extends if the parent is NOT the auto-generated class for this same entity.
    // When the user extends the auto-generated class (from defineEntity without a class option)
    // and registers their custom class via setClass, we don't want to discover the
    // auto-generated class as a separate parent entity.
    if (base !== BaseEntity && base.name !== this._meta.className) {
      this._meta.extends ??= base.name ? base : undefined;
    }
  }
  /** Returns the underlying EntityMetadata. */
  get meta() {
    return this._meta;
  }
  /** Returns the entity class name. */
  get name() {
    return this._meta.className;
  }
  /** Returns the database table name. */
  get tableName() {
    return this._meta.tableName;
  }
  get class() {
    return this._meta.class;
  }
  get properties() {
    return this._meta.properties;
  }
  new(...params) {
    return new this._meta.class(...params);
  }
  /**
   * @internal
   */
  init() {
    if (this.initialized) {
      return this;
    }
    this.setClass(this._meta.class);
    // Abstract TPT entities keep their name because they have their own table
    const isTPT = this._meta.inheritance === 'tpt' || this.isPartOfTPTHierarchy();
    if (this._meta.abstract && !this._meta.discriminatorColumn && !isTPT) {
      delete this._meta.name;
    }
    const tableName = this._meta.collection ?? this._meta.tableName;
    if (tableName?.includes('.') && !this._meta.schema) {
      this._meta.schema = tableName.substring(0, tableName.indexOf('.'));
      this._meta.tableName = tableName.substring(tableName.indexOf('.') + 1);
    }
    this.initProperties();
    this.initPrimaryKeys();
    this._meta.props = Object.values(this._meta.properties);
    this._meta.relations = this._meta.props.filter(
      prop =>
        typeof prop.kind !== 'undefined' && prop.kind !== ReferenceKind.SCALAR && prop.kind !== ReferenceKind.EMBEDDED,
    );
    this.initialized = true;
    return this;
  }
  /**
   * Check if this entity is part of a TPT hierarchy by walking up the extends chain.
   * This handles mid-level abstract entities (e.g., Animal -> Mammal -> Dog where Mammal is abstract).
   */
  isPartOfTPTHierarchy() {
    let parent = this._meta.extends;
    while (parent) {
      const parentSchema = EntitySchema.is(parent) ? parent : EntitySchema.REGISTRY.get(parent);
      if (!parentSchema) {
        break;
      }
      if (parentSchema._meta.inheritance === 'tpt') {
        return true;
      }
      parent = parentSchema._meta.extends;
    }
    return false;
  }
  initProperties() {
    Utils.entries(this._meta.properties).forEach(([name, options]) => {
      if (Type.isMappedType(options.type)) {
        options.type ??= options.type.constructor.name;
      }
      switch (options.kind) {
        case ReferenceKind.ONE_TO_ONE:
          this.addOneToOne(name, options.type, options);
          break;
        case ReferenceKind.ONE_TO_MANY:
          this.addOneToMany(name, options.type, options);
          break;
        case ReferenceKind.MANY_TO_ONE:
          this.addManyToOne(name, options.type, options);
          break;
        case ReferenceKind.MANY_TO_MANY:
          this.addManyToMany(name, options.type, options);
          break;
        case ReferenceKind.EMBEDDED:
          this.addEmbedded(name, options);
          break;
        default:
          if (options.enum) {
            this.addEnum(name, options.type, options);
          } else if (options.primary) {
            this.addPrimaryKey(name, options.type, options);
          } else if (options.serializedPrimaryKey) {
            this.addSerializedPrimaryKey(name, options.type, options);
          } else if (options.version) {
            this.addVersion(name, options.type, options);
          } else {
            this.addProperty(name, options.type, options);
          }
      }
    });
  }
  initPrimaryKeys() {
    const pks = Object.values(this._meta.properties).filter(prop => prop.primary);
    if (pks.length > 0) {
      this._meta.primaryKeys = pks.map(prop => prop.name);
      this._meta.compositePK = pks.length > 1;
      this._meta.simplePK = !this._meta.compositePK && pks[0].kind === ReferenceKind.SCALAR && !pks[0].customType;
    }
    if (pks.length === 1 && ['number', 'bigint'].includes(pks[0].type)) {
      pks[0].autoincrement ??= true;
    }
    const serializedPrimaryKey = Object.values(this._meta.properties).find(prop => prop.serializedPrimaryKey);
    if (serializedPrimaryKey) {
      this._meta.serializedPrimaryKey = serializedPrimaryKey.name;
    }
  }
  normalizeType(options, type) {
    if ('entity' in options) {
      /* v8 ignore next */
      if (typeof options.entity === 'string') {
        throw new Error(
          `Relation target needs to be an entity class or EntitySchema instance, string '${options.entity}' given instead for ${this._meta.className}.${options.name}.`,
        );
      } else if (options.entity) {
        const tmp = options.entity();
        type = options.type = Array.isArray(tmp)
          ? tmp
              .map(t => Utils.className(t))
              .sort()
              .join(' | ')
          : Utils.className(tmp);
        const target = EntitySchema.is(tmp) ? tmp.meta.class : tmp;
        return { type, target };
      }
    }
    if (type instanceof Function) {
      type = type.name;
    }
    if (['String', 'Number', 'Boolean', 'Array'].includes(type)) {
      type = type.toLowerCase();
    }
    return { type };
  }
  createProperty(kind, options) {
    return {
      kind,
      cascade: [Cascade.PERSIST],
      ...options,
    };
  }
  rename(data, from, to) {
    if (from in data && !(to in data)) {
      // @ts-ignore
      data[to] = [data[from]];
      // @ts-ignore
      delete data[from];
    }
  }
  renameCompositeOptions(name, options = {}) {
    if (name !== options.name && !options.fieldNames) {
      Utils.renameKey(options, 'name', 'fieldName');
    } else if (options.name && (options.fieldNames?.length ?? 0) > 1) {
      delete options.name;
    }
    this.rename(options, 'fieldName', 'fieldNames');
    this.rename(options, 'joinColumn', 'joinColumns');
    this.rename(options, 'inverseJoinColumn', 'inverseJoinColumns');
    this.rename(options, 'referenceColumnName', 'referencedColumnNames');
    this.rename(options, 'columnType', 'columnTypes');
  }
  /**
   * Adds a lifecycle hook handler to the entity schema.
   * This method allows registering hooks after the entity is defined,
   * which can be useful for avoiding circular type references.
   *
   * @example
   * ```ts
   * export const Article = defineEntity({
   *   name: 'Article',
   *   properties: { ... },
   * });
   *
   * Article.addHook('beforeCreate', async args => {
   *   args.entity.slug = args.entity.title.toLowerCase();
   * });
   * ```
   */
  addHook(event, handler) {
    this._meta.hooks[event] ??= [];
    this._meta.hooks[event].push(handler);
    return this;
  }
}
