import { Utils } from '../utils/Utils.js';
import { MetadataError, ValidationError } from '../errors.js';
import { DataloaderType, ReferenceKind } from '../enums.js';
import { Reference } from './Reference.js';
import { helper, wrap } from './wrap.js';
import { QueryHelper } from '../utils/QueryHelper.js';
import { inspect } from '../logging/inspect.js';
/** Represents a to-many relation (1:m or m:n) as an iterable, managed collection of entities. */
export class Collection {
  owner;
  #items = new Set();
  #initialized = true;
  #dirty = false;
  #partial = false; // mark partially loaded collections, propagation is disabled for those
  #snapshot = []; // used to create a diff of the collection at commit time, undefined marks overridden values so we need to wipe when flushing
  #readonly;
  #count;
  #property;
  #populated;
  constructor(owner, items, initialized = true) {
    this.owner = owner;
    /* v8 ignore next */
    if (items) {
      let i = 0;
      this.#items = new Set(items);
      this.#items.forEach(item => (this[i++] = item));
    }
    this.#initialized = !!items || initialized;
  }
  /**
   * Creates new Collection instance, assigns it to the owning entity and sets the items to it (propagating them to their inverse sides)
   */
  static create(owner, prop, items, initialized) {
    const coll = new Collection(owner, undefined, initialized);
    coll.property = helper(owner).__meta.properties[prop];
    owner[prop] = coll;
    if (items) {
      coll.set(items);
    }
    return coll;
  }
  /**
   * Ensures the collection is loaded first (without reloading it if it already is loaded).
   * Returns the Collection instance (itself), works the same as `Reference.load()`.
   */
  async load(options = {}) {
    if (this.isInitialized(true) && !options.refresh) {
      const em = this.getEntityManager(this.#items, false);
      options = { ...options, filters: QueryHelper.mergePropertyFilters(this.property.filters, options.filters) };
      await em?.populate(this.#items, options.populate, options);
      this.setSerializationContext(options);
    } else {
      await this.init({ refresh: false, ...options });
    }
    return this;
  }
  setSerializationContext(options) {
    helper(this.owner).setSerializationContext({
      populate: Array.isArray(options.populate)
        ? options.populate.map(hint => `${this.property.name}.${hint}`)
        : (options.populate ?? [this.property.name]),
    });
  }
  /**
   * Initializes the collection and returns the items
   */
  async loadItems(options) {
    await this.load(options);
    return this.getItems(false);
  }
  /**
   * Gets the count of collection items from database instead of counting loaded items.
   * The value is cached (unless you use the `where` option), use `refresh: true` to force reload it.
   */
  async loadCount(options = {}) {
    options = typeof options === 'boolean' ? { refresh: options } : options;
    const { refresh, where, ...countOptions } = options;
    if (!refresh && !where && this.#count != null) {
      return this.#count;
    }
    const em = this.getEntityManager();
    if (
      !em.getPlatform().usesPivotTable() &&
      this.property.kind === ReferenceKind.MANY_TO_MANY &&
      this.property.owner
    ) {
      return (this.#count = this.length);
    }
    const cond = this.createLoadCountCondition(where ?? {});
    const count = await em.count(this.property.targetMeta.class, cond, countOptions);
    if (!where) {
      this.#count = count;
    }
    return count;
  }
  /** Queries a subset of the collection items from the database with custom filtering, ordering, and pagination. */
  async matching(options) {
    const em = this.getEntityManager();
    const { where, ctx, ...opts } = options;
    let items;
    if (this.property.kind === ReferenceKind.MANY_TO_MANY && em.getPlatform().usesPivotTable()) {
      // M:N via pivot table bypasses em.find(), so merge all 3 levels here
      opts.orderBy = QueryHelper.mergeOrderBy(opts.orderBy, this.property.orderBy, this.property.targetMeta?.orderBy);
      options.populate = await em.preparePopulate(this.property.targetMeta.class, options);
      const cond = await em.applyFilters(this.property.targetMeta.class, where, options.filters ?? {}, 'read');
      const map = await em
        .getDriver()
        .loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, ctx, options);
      items = map[helper(this.owner).getSerializedPrimaryKey()].map(item =>
        em.merge(this.property.targetMeta.class, item, { convertCustomTypes: true }),
      );
      await em.populate(items, options.populate, options);
    } else {
      // em.find() merges entity-level orderBy, so only merge runtime + relation here
      opts.orderBy = QueryHelper.mergeOrderBy(opts.orderBy, this.property.orderBy);
      items = await em.find(this.property.targetMeta.class, this.createCondition(where), opts);
    }
    if (options.store) {
      this.hydrate(items, true);
      this.setSerializationContext(options);
      this.populated();
      this.#readonly = true;
    }
    return items;
  }
  /**
   * Returns the items (the collection must be initialized)
   */
  getItems(check = true) {
    if (check) {
      this.checkInitialized();
    }
    return [...this.#items];
  }
  /** Serializes the collection items to plain JSON objects. Returns an empty array if not initialized. */
  toJSON() {
    if (!this.isInitialized()) {
      return [];
    }
    return this.toArray();
  }
  /** Adds one or more items to the collection, propagating the change to the inverse side. Returns the number of items added. */
  add(entity, ...entities) {
    entities = Utils.asArray(entity).concat(entities);
    const unwrapped = entities.map(i => Reference.unwrapReference(i));
    this.validateModification(unwrapped);
    const em = this.getEntityManager(entities, false);
    let added = 0;
    for (const item of entities) {
      const entity = Reference.unwrapReference(item);
      if (!this.contains(entity, false)) {
        this.incrementCount(1);
        this[this.#items.size] = entity;
        this.#items.add(entity);
        added++;
        this.#dirty = true;
        this.propagate(entity, 'add');
      }
    }
    if (this.property.kind === ReferenceKind.ONE_TO_MANY && em) {
      em.persist(entities);
    }
    this.cancelOrphanRemoval(unwrapped);
    return added;
  }
  /**
   * Remove specified item(s) from the collection. Note that removing item from collection does not necessarily imply deleting the target entity,
   * it means we are disconnecting the relation - removing items from collection, not removing entities from database - `Collection.remove()`
   * is not the same as `em.remove()`. If we want to delete the entity by removing it from collection, we need to enable `orphanRemoval: true`,
   * which tells the ORM we don't want orphaned entities to exist, so we know those should be removed.
   */
  remove(entity, ...entities) {
    if (entity instanceof Function) {
      let removed = 0;
      for (const item of this.#items) {
        if (entity(item)) {
          removed += this.remove(item);
        }
      }
      return removed;
    }
    this.checkInitialized();
    entities = Utils.asArray(entity).concat(entities);
    const unwrapped = entities.map(i => Reference.unwrapReference(i));
    this.validateModification(unwrapped);
    const em = this.getEntityManager(entities, false);
    let removed = 0;
    for (const item of entities) {
      if (!item) {
        continue;
      }
      const entity = Reference.unwrapReference(item);
      if (this.#items.delete(entity)) {
        this.incrementCount(-1);
        delete this[this.#items.size]; // remove last item
        this.propagate(entity, 'remove');
        removed++;
        this.#dirty = true;
      }
      if (this.property.orphanRemoval && em) {
        em.getUnitOfWork().scheduleOrphanRemoval(entity);
      }
    }
    if (this.property.kind === ReferenceKind.ONE_TO_MANY && !this.property.orphanRemoval && em) {
      em.persist(entities);
    }
    if (removed > 0) {
      Object.assign(this, [...this.#items]); // reassign array access
    }
    return removed;
  }
  /** Checks whether the collection contains the given item. */
  contains(item, check = true) {
    if (check) {
      this.checkInitialized();
    }
    const entity = Reference.unwrapReference(item);
    return this.#items.has(entity);
  }
  /** Returns the number of items in the collection. Throws if the collection is not initialized. */
  count() {
    this.checkInitialized();
    return this.#items.size;
  }
  /** Returns true if the collection has no items. Throws if the collection is not initialized. */
  isEmpty() {
    this.checkInitialized();
    return this.count() === 0;
  }
  /** Returns whether this collection should be included in serialization based on its populated state. */
  shouldPopulate(populated) {
    if (!this.isInitialized(true)) {
      return false;
    }
    if (this.#populated != null) {
      return this.#populated;
    }
    return !!populated;
  }
  /** Marks the collection as populated or not for serialization purposes. */
  populated(populated = true) {
    this.#populated = populated;
  }
  /** Initializes the collection by loading its items from the database. */
  async init(options = {}) {
    if (this.#dirty) {
      const items = [...this.#items];
      this.#dirty = false;
      await this.init(options);
      items.forEach(i => this.add(i));
      return this;
    }
    const em = this.getEntityManager();
    options = { ...options, filters: QueryHelper.mergePropertyFilters(this.property.filters, options.filters) };
    if (options.dataloader ?? [DataloaderType.ALL, DataloaderType.COLLECTION].includes(em.config.getDataloaderType())) {
      const order = [...this.#items]; // copy order of references
      const orderBy = QueryHelper.mergeOrderBy(
        options.orderBy,
        this.property.orderBy,
        this.property.targetMeta?.orderBy,
      );
      const customOrder = orderBy.length > 0;
      const pivotTable = this.property.kind === ReferenceKind.MANY_TO_MANY && em.getPlatform().usesPivotTable();
      const loader = await em.getDataLoader(pivotTable ? 'm:n' : '1:m');
      const items = await loader.load([this, { ...options, orderBy }]);
      if (this.property.kind === ReferenceKind.MANY_TO_MANY) {
        this.#initialized = true;
        this.#dirty = false;
        if (!customOrder) {
          this.reorderItems(items, order);
        }
        return this;
      }
      this.#items.clear();
      let i = 0;
      for (const item of items) {
        this.#items.add(item);
        this[i++] = item;
      }
      this.#initialized = true;
      this.#dirty = false;
      return this;
    }
    const populate = Array.isArray(options.populate)
      ? options.populate.map(f => (f === '*' ? f : `${this.property.name}.${f}`))
      : [`${this.property.name}${options.ref ? ':ref' : ''}`];
    const schema = this.property.targetMeta.schema === '*' ? helper(this.owner).__schema : undefined;
    await em.populate(this.owner, populate, {
      refresh: true,
      ...options,
      connectionType: options.connectionType,
      schema,
      where: { [this.property.name]: options.where },
      orderBy: { [this.property.name]: options.orderBy },
    });
    return this;
  }
  getEntityManager(items = [], required = true) {
    const wrapped = helper(this.owner);
    let em = wrapped.__em;
    if (!em) {
      for (const i of items) {
        if (i && helper(i).__em) {
          em = helper(i).__em;
          break;
        }
      }
    }
    if (!em && required) {
      throw ValidationError.entityNotManaged(this.owner);
    }
    return em;
  }
  createCondition(cond = {}) {
    if (this.property.kind === ReferenceKind.ONE_TO_MANY) {
      cond[this.property.mappedBy] = helper(this.owner).getPrimaryKey();
    } else {
      // MANY_TO_MANY
      this.createManyToManyCondition(cond);
    }
    return cond;
  }
  createManyToManyCondition(cond) {
    const dict = cond;
    if (this.property.owner || this.property.pivotTable) {
      // we know there is at least one item as it was checked in load method
      const pk = this.property.targetMeta.primaryKeys[0];
      dict[pk] = { $in: [] };
      this.#items.forEach(item => dict[pk].$in.push(helper(item).getPrimaryKey()));
    } else {
      dict[this.property.mappedBy] = helper(this.owner).getPrimaryKey();
    }
  }
  createLoadCountCondition(cond) {
    const wrapped = helper(this.owner);
    const val = wrapped.__meta.compositePK ? { $in: wrapped.__primaryKeys } : wrapped.getPrimaryKey();
    const dict = cond;
    if (this.property.kind === ReferenceKind.ONE_TO_MANY) {
      dict[this.property.mappedBy] = val;
    } else {
      const key = this.property.owner ? this.property.inversedBy : this.property.mappedBy;
      dict[key] = val;
    }
    return cond;
  }
  checkInitialized() {
    if (!this.isInitialized()) {
      throw new Error(
        `Collection<${this.property.type}> of entity ${helper(this.owner).__meta.name}[${helper(this.owner).getSerializedPrimaryKey()}] not initialized`,
      );
    }
  }
  /**
   * re-orders items after searching with `$in` operator
   */
  reorderItems(items, order) {
    if (this.property.kind === ReferenceKind.MANY_TO_MANY && this.property.owner) {
      items.sort((a, b) => order.indexOf(a) - order.indexOf(b));
    }
  }
  cancelOrphanRemoval(items) {
    const em = this.getEntityManager(items, false);
    if (!em) {
      return;
    }
    for (const item of items) {
      em.getUnitOfWork().cancelOrphanRemoval(item);
    }
  }
  validateModification(items) {
    if (this.#readonly) {
      throw ValidationError.cannotModifyReadonlyCollection(this.owner, this.property);
    }
    const check = item => {
      if (!item) {
        return false;
      }
      if (!Utils.isEntity(item)) {
        throw ValidationError.notEntity(this.owner, this.property, item);
      }
      // currently we allow persisting to inverse sides only in SQL drivers
      if (this.property.pivotTable || !this.property.mappedBy) {
        return false;
      }
      if (helper(item).__initialized) {
        return false;
      }
      return !item[this.property.mappedBy] && this.property.kind === ReferenceKind.MANY_TO_MANY;
    };
    // throw if we are modifying inverse side of M:N collection when owning side is initialized (would be ignored when persisting)
    if (items.some(item => check(item))) {
      throw ValidationError.cannotModifyInverseCollection(this.owner, this.property);
    }
  }
  /** Converts all items in the collection to plain DTO objects. */
  toArray() {
    if (this.#items.size === 0) {
      return [];
    }
    return this.map(item => wrap(item).toJSON());
  }
  /** Returns the primary key values (or a specific field) of all items in the collection. */
  getIdentifiers(field) {
    const items = this.getItems();
    const targetMeta = this.property.targetMeta;
    if (items.length === 0) {
      return [];
    }
    field ??= targetMeta.compositePK
      ? targetMeta.primaryKeys
      : (targetMeta.serializedPrimaryKey ?? targetMeta.primaryKeys[0]);
    const cb = (i, f) => {
      if (Utils.isEntity(i[f], true)) {
        return wrap(i[f], true).getPrimaryKey();
      }
      return i[f];
    };
    return items.map(i => {
      if (Array.isArray(field)) {
        return field.map(f => cb(i, f));
      }
      return cb(i, field);
    });
  }
  /**
   * @internal
   */
  addWithoutPropagation(entity) {
    if (!this.contains(entity, false)) {
      this.incrementCount(1);
      this[this.#items.size] = entity;
      this.#items.add(entity);
      this.#dirty = true;
    }
  }
  /** Replaces all items in the collection with the given items. */
  set(items) {
    if (!this.#initialized) {
      this.#initialized = true;
      this.#snapshot = undefined;
    }
    if (this.compare(Utils.asArray(items).map(item => Reference.unwrapReference(item)))) {
      return;
    }
    this.remove(this.#items);
    this.add(items);
  }
  compare(items) {
    if (items.length !== this.#items.size) {
      return false;
    }
    let idx = 0;
    for (const item of this.#items) {
      if (item !== items[idx++]) {
        return false;
      }
    }
    return true;
  }
  /**
   * @internal
   */
  hydrate(items, forcePropagate, partial) {
    for (let i = 0; i < this.#items.size; i++) {
      delete this[i];
    }
    this.#initialized = true;
    this.#partial = !!partial;
    this.#items.clear();
    this.#count = 0;
    this.add(items);
    this.takeSnapshot(forcePropagate);
  }
  /**
   * Remove all items from the collection. Note that removing items from collection does not necessarily imply deleting the target entity,
   * it means we are disconnecting the relation - removing items from collection, not removing entities from database - `Collection.remove()`
   * is not the same as `em.remove()`. If we want to delete the entity by removing it from collection, we need to enable `orphanRemoval: true`,
   * which tells the ORM we don't want orphaned entities to exist, so we know those should be removed.
   */
  removeAll() {
    if (!this.#initialized) {
      this.#initialized = true;
      this.#snapshot = undefined;
    }
    this.remove(this.#items);
    this.#dirty = true;
  }
  /**
   * @internal
   */
  removeWithoutPropagation(entity) {
    if (!this.#items.delete(entity)) {
      return;
    }
    this.incrementCount(-1);
    delete this[this.#items.size];
    Object.assign(this, [...this.#items]);
    this.#dirty = true;
  }
  /**
   * Extracts a slice of the collection items starting at position start to end (exclusive) of the collection.
   * If end is null it returns all elements from start to the end of the collection.
   */
  slice(start = 0, end) {
    this.checkInitialized();
    let index = 0;
    end ??= this.#items.size;
    const items = [];
    for (const item of this.#items) {
      if (index === end) {
        break;
      }
      if (index >= start && index < end) {
        items.push(item);
      }
      index++;
    }
    return items;
  }
  /**
   * Tests for the existence of an element that satisfies the given predicate.
   */
  exists(cb) {
    this.checkInitialized();
    for (const item of this.#items) {
      if (cb(item)) {
        return true;
      }
    }
    return false;
  }
  /**
   * Returns the first element of this collection that satisfies the predicate.
   */
  find(cb) {
    this.checkInitialized();
    let index = 0;
    for (const item of this.#items) {
      if (cb(item, index++)) {
        return item;
      }
    }
    return undefined;
  }
  /**
   * Extracts a subset of the collection items.
   */
  filter(cb) {
    this.checkInitialized();
    const items = [];
    let index = 0;
    for (const item of this.#items) {
      if (cb(item, index++)) {
        items.push(item);
      }
    }
    return items;
  }
  /**
   * Maps the collection items based on your provided mapper function.
   */
  map(mapper) {
    this.checkInitialized();
    const items = [];
    let index = 0;
    for (const item of this.#items) {
      items.push(mapper(item, index++));
    }
    return items;
  }
  /**
   * Maps the collection items based on your provided mapper function to a single object.
   */
  reduce(cb, initial = {}) {
    this.checkInitialized();
    let index = 0;
    for (const item of this.#items) {
      initial = cb(initial, item, index++);
    }
    return initial;
  }
  /**
   * Maps the collection items to a dictionary, indexed by the key you specify.
   * If there are more items with the same key, only the first one will be present.
   */
  indexBy(key, valueKey) {
    return this.reduce((obj, item) => {
      obj[item[key]] ??= valueKey ? item[valueKey] : item;
      return obj;
    }, {});
  }
  /** Returns whether the collection has been initialized. Pass `fully = true` to also check that all items are initialized. */
  isInitialized(fully = false) {
    if (!this.#initialized || !fully) {
      return this.#initialized;
    }
    for (const item of this.#items) {
      if (!helper(item).__initialized) {
        return false;
      }
    }
    return true;
  }
  isDirty() {
    return this.#dirty;
  }
  /** Returns whether the collection was partially loaded (propagation is disabled for partial collections). */
  isPartial() {
    return this.#partial;
  }
  setDirty(dirty = true) {
    this.#dirty = dirty;
  }
  get length() {
    return this.count();
  }
  *[Symbol.iterator]() {
    for (const item of this.getItems()) {
      yield item;
    }
  }
  /**
   * @internal
   */
  takeSnapshot(forcePropagate) {
    this.#snapshot = [...this.#items];
    this.#dirty = false;
    if (this.property.owner || forcePropagate) {
      this.#items.forEach(item => {
        this.propagate(item, 'takeSnapshot');
      });
    }
  }
  /**
   * @internal
   */
  getSnapshot() {
    return this.#snapshot;
  }
  /**
   * @internal
   */
  get property() {
    // cannot be typed to `EntityProperty<O, T>` as it causes issues in assignability of `Loaded` type
    if (!this.#property) {
      const meta = wrap(this.owner, true).__meta;
      /* v8 ignore next */
      if (!meta) {
        throw MetadataError.fromUnknownEntity(
          this.owner.constructor.name,
          'Collection.property getter, maybe you just forgot to initialize the ORM?',
        );
      }
      this.#property = meta.relations.find(prop => this.owner[prop.name] === this);
    }
    return this.#property;
  }
  /**
   * @internal
   */
  set property(prop) {
    // cannot be typed to `EntityProperty<O, T>` as it causes issues in assignability of `Loaded` type
    this.#property = prop;
  }
  propagate(item, method) {
    if (this.property.owner && this.property.inversedBy) {
      this.propagateToInverseSide(item, method);
    } else if (!this.property.owner && this.property.mappedBy) {
      this.propagateToOwningSide(item, method);
    }
  }
  propagateToInverseSide(item, method) {
    const collection = item[this.property.inversedBy];
    if (this.shouldPropagateToCollection(collection, method)) {
      method = method === 'takeSnapshot' ? method : method + 'WithoutPropagation';
      collection[method](this.owner);
    }
  }
  propagateToOwningSide(item, method) {
    const mappedBy = this.property.mappedBy;
    const collection = item[mappedBy];
    if (this.property.kind === ReferenceKind.MANY_TO_MANY) {
      if (this.shouldPropagateToCollection(collection, method)) {
        collection[method](this.owner);
      }
    } else if (this.property.kind === ReferenceKind.ONE_TO_MANY && method !== 'takeSnapshot') {
      const prop2 = this.property.targetMeta.properties[mappedBy];
      const owner = prop2.mapToPk ? helper(this.owner).getPrimaryKey() : this.owner;
      const value = method === 'add' ? owner : null;
      if (this.property.orphanRemoval && method === 'remove') {
        // cache the PK before we propagate, as its value might be needed when flushing
        helper(item).__pk = helper(item).getPrimaryKey();
      }
      if (!prop2.nullable && prop2.deleteRule !== 'cascade' && method === 'remove') {
        if (!this.property.orphanRemoval) {
          throw ValidationError.cannotRemoveFromCollectionWithoutOrphanRemoval(this.owner, this.property);
        }
        return;
      }
      // skip if already propagated
      if (Reference.unwrapReference(item[mappedBy]) !== value) {
        item[mappedBy] = value;
      }
    }
  }
  shouldPropagateToCollection(collection, method) {
    if (!collection) {
      return false;
    }
    switch (method) {
      case 'add':
        return !collection.contains(this.owner, false);
      case 'remove':
        return collection.isInitialized() && collection.contains(this.owner, false);
      case 'takeSnapshot':
        return collection.isDirty();
    }
  }
  incrementCount(value) {
    if (typeof this.#count === 'number' && this.#initialized) {
      this.#count += value;
    }
  }
  /** @ignore */
  [Symbol.for('nodejs.util.inspect.custom')](depth = 2) {
    const object = { ...this };
    delete object.owner;
    object.initialized = this.#initialized;
    object.dirty = this.#dirty;
    const ret = inspect(object, { depth });
    const name = `${this.constructor.name}<${this.property?.type ?? 'unknown'}>`;
    return ret === '[Object]' ? `[${name}]` : name + ' ' + ret;
  }
}
Object.defineProperties(Collection.prototype, {
  $: {
    get() {
      return this;
    },
  },
  get: {
    get() {
      return () => this;
    },
  },
  __collection: { value: true, enumerable: false, writable: false },
});
