import { Utils } from './Utils.js';
import { ReferenceKind } from '../enums.js';
import { Reference } from '../entity/Reference.js';
import { helper } from '../entity/wrap.js';
import { Raw } from '../utils/RawQueryFragment.js';
import { CursorError } from '../errors.js';
import { inspect } from '../logging/inspect.js';
/**
 * As an alternative to the offset-based pagination with `limit` and `offset`, we can paginate based on a cursor.
 * A cursor is an opaque string that defines a specific place in ordered entity graph. You can use `em.findByCursor()`
 * to access those options. Under the hood, it will call `em.find()` and `em.count()` just like the `em.findAndCount()`
 * method, but will use the cursor options instead.
 *
 * Supports `before`, `after`, `first` and `last` options while disallowing `limit` and `offset`. Explicit `orderBy` option is required.
 *
 * Use `first` and `after` for forward pagination, or `last` and `before` for backward pagination.
 *
 * - `first` and `last` are numbers and serve as an alternative to `offset`, those options are mutually exclusive, use only one at a time
 * - `before` and `after` specify the previous cursor value
 *
 * ```ts
 * const currentCursor = await em.findByCursor(User, {}, {
 *   first: 10,
 *   after: previousCursor, // can be either string or `Cursor` instance
 *   orderBy: { id: 'desc' },
 * });
 *
 * // to fetch next page
 * const nextCursor = await em.findByCursor(User, {}, {
 *   first: 10,
 *   after: currentCursor.endCursor, // or currentCursor.endCursor
 *   orderBy: { id: 'desc' },
 * });
 * ```
 *
 * The `Cursor` object provides the following interface:
 *
 * ```ts
 * Cursor<User> {
 *   items: [
 *     User { ... },
 *     User { ... },
 *     User { ... },
 *     ...
 *   ],
 *   totalCount: 50,
 *   length: 10,
 *   startCursor: 'WzRd',
 *   endCursor: 'WzZd',
 *   hasPrevPage: true,
 *   hasNextPage: true,
 * }
 * ```
 */
export class Cursor {
  items;
  totalCount;
  hasPrevPage;
  hasNextPage;
  #definition;
  constructor(items, totalCount, options, meta) {
    this.items = items;
    this.totalCount = totalCount;
    const { first, last, before, after, orderBy, overfetch } = options;
    const limit = first ?? last;
    const isLast = !first && !!last;
    const hasMorePages = !!overfetch && limit != null && items.length > limit;
    this.hasPrevPage = isLast ? hasMorePages : !!after;
    this.hasNextPage = isLast ? !!before : hasMorePages;
    if (hasMorePages) {
      if (isLast) {
        items.shift();
      } else {
        items.pop();
      }
    }
    this.#definition = Cursor.getDefinition(meta, orderBy);
  }
  get startCursor() {
    if (this.items.length === 0) {
      return null;
    }
    return this.from(this.items[0]);
  }
  get endCursor() {
    if (this.items.length === 0) {
      return null;
    }
    return this.from(this.items[this.items.length - 1]);
  }
  /**
   * Computes the cursor value for a given entity.
   */
  from(entity) {
    const processEntity = (entity, prop, direction, object = false) => {
      if (Utils.isPlainObject(direction)) {
        const unwrapped = Reference.unwrapReference(entity[prop]);
        // Check if the relation is loaded - for nested properties, undefined means not populated
        if (Utils.isEntity(unwrapped) && !helper(unwrapped).isInitialized()) {
          throw CursorError.entityNotPopulated(entity, prop);
        }
        return Utils.keys(direction).reduce((o, key) => {
          Object.assign(o, processEntity(unwrapped, key, direction[key], true));
          return o;
        }, {});
      }
      let value = entity[prop];
      // Allow null/undefined values in cursor - they will be handled in createCursorCondition
      // undefined can occur with forceUndefined config option which converts null to undefined
      if (value == null) {
        return object ? { [prop]: null } : null;
      }
      if (Utils.isEntity(value, true)) {
        value = helper(value).getPrimaryKey();
      }
      if (Utils.isScalarReference(value)) {
        value = value.unwrap();
      }
      if (object) {
        return { [prop]: value };
      }
      return value;
    };
    const value = this.#definition.map(([key, direction]) => processEntity(entity, key, direction));
    return Cursor.encode(value);
  }
  *[Symbol.iterator]() {
    for (const item of this.items) {
      yield item;
    }
  }
  get length() {
    return this.items.length;
  }
  /**
   * Computes the cursor value for given entity and order definition.
   */
  static for(meta, entity, orderBy) {
    const definition = this.getDefinition(meta, orderBy);
    return Cursor.encode(
      definition.map(([key]) => {
        const value = entity[key];
        if (value === undefined) {
          throw CursorError.missingValue(meta.className, key);
        }
        return value;
      }),
    );
  }
  static encode(value) {
    return Buffer.from(JSON.stringify(value)).toString('base64url');
  }
  static decode(value) {
    return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')).map(value => {
      if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}/.exec(value)) {
        return new Date(value);
      }
      return value;
    });
  }
  static getDefinition(meta, orderBy) {
    return Utils.asArray(orderBy).flatMap(order => {
      const ret = [];
      for (const key of Utils.getObjectQueryKeys(order)) {
        if (Raw.isKnownFragmentSymbol(key)) {
          ret.push([key, order[key]]);
          continue;
        }
        const prop = meta.properties[key];
        /* v8 ignore next */
        if (
          !prop ||
          !(
            [ReferenceKind.SCALAR, ReferenceKind.EMBEDDED, ReferenceKind.MANY_TO_ONE].includes(prop.kind) ||
            (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner)
          )
        ) {
          continue;
        }
        ret.push([prop.name, order[prop.name]]);
      }
      return ret;
    });
  }
  /** @ignore */
  /* v8 ignore next */
  [Symbol.for('nodejs.util.inspect.custom')]() {
    const type = this.items[0]?.constructor.name;
    const { items, startCursor, endCursor, hasPrevPage, hasNextPage, totalCount, length } = this;
    const options = inspect(
      { startCursor, endCursor, totalCount, hasPrevPage, hasNextPage, items, length },
      { depth: 0 },
    );
    return `Cursor${type ? `<${type}>` : ''} ${options.replace('items: [Array]', 'items: [...]')}`;
  }
}
