/**
 * Sort direction for list queries.
 */
export type SortDirection = "asc" | "desc";

/**
 * Sort option for a whitelisted field.
 */
export type SortOption<Field extends string = string> = {
  /**
   * Field to sort by.
   */
  field: Field;
  /**
   * Sort direction.
   */
  direction: SortDirection;
};

/**
 * Normalized offset pagination request.
 */
export interface OffsetPage {
  /**
   * Pagination mode.
   */
  readonly kind: "offset";
  /**
   * Page size after defaulting and max-limit clamping.
   */
  readonly limit: number;
  /**
   * Zero-based item offset.
   */
  readonly offset: number;
}

/**
 * Normalized cursor pagination request.
 */
export interface CursorPage {
  /**
   * Pagination mode.
   */
  readonly kind: "cursor";
  /**
   * Page size after defaulting and max-limit clamping.
   */
  readonly limit: number;
  /**
   * Cursor for the current page, or null for the first page.
   */
  readonly cursor: string | null;
}

/**
 * Normalized pagination request.
 */
export type Page = OffsetPage | CursorPage;

/**
 * Offset pagination response metadata.
 */
export type OffsetPageInfo = OffsetPage & {
  /**
   * Total item count.
   */
  readonly total: number;
  /**
   * Whether another page exists.
   */
  readonly hasMore: boolean;
};

/**
 * Cursor pagination response metadata.
 */
export type CursorPageInfo = CursorPage & {
  /**
   * Cursor for the next page, or null when there is no next page.
   */
  readonly nextCursor: string | null;
  /**
   * Whether another page exists.
   */
  readonly hasMore: boolean;
};

/**
 * Pagination response metadata.
 */
export type PageInfo = OffsetPageInfo | CursorPageInfo;

/**
 * Paginated list result.
 */
export interface PageResult<TItem, TPage extends PageInfo = PageInfo> {
  /**
   * Page items. Result helpers clone the input array.
   */
  readonly items: TItem[];
  /**
   * Page metadata.
   */
  readonly page: TPage;
}

/**
 * Options for normalizing pagination input.
 */
export interface NormalizePageOptions {
  /**
   * Limit used when the caller does not provide one.
   */
  readonly defaultLimit: number;
  /**
   * Maximum allowed limit. Larger caller values are clamped.
   */
  readonly maxLimit: number;
}

/**
 * Raw offset pagination input.
 */
export interface OffsetPageInput {
  /**
   * Requested page size.
   */
  readonly limit?: number | null;
  /**
   * Requested zero-based offset.
   */
  readonly offset?: number | null;
}

/**
 * Raw cursor pagination input.
 */
export interface CursorPageInput {
  /**
   * Requested page size.
   */
  readonly limit?: number | null;
  /**
   * Requested cursor.
   */
  readonly cursor?: string | null;
}

/**
 * Error thrown when pagination input is invalid.
 */
export class PaginationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "PaginationError";
  }
}

function assertPositiveInteger(name: string, value: number): void {
  if (!Number.isInteger(value) || value < 1) {
    throw new PaginationError(`${name} must be a positive integer.`);
  }
}

function normalizeLimit(
  input: number | null | undefined,
  options: NormalizePageOptions,
): number {
  assertPositiveInteger("defaultLimit", options.defaultLimit);
  assertPositiveInteger("maxLimit", options.maxLimit);

  if (options.defaultLimit > options.maxLimit) {
    throw new PaginationError(
      "defaultLimit must be less than or equal to maxLimit.",
    );
  }

  if (input == null) return options.defaultLimit;

  if (!Number.isInteger(input) || input < 1) {
    throw new PaginationError("limit must be a positive integer.");
  }

  return Math.min(input, options.maxLimit);
}

/**
 * Normalize offset pagination input.
 *
 * The limit is defaulted and clamped to `maxLimit`; offset must be a
 * non-negative integer.
 */
export function normalizeOffsetPage(
  input: OffsetPageInput,
  options: NormalizePageOptions,
): OffsetPage {
  const limit = normalizeLimit(input.limit, options);
  const offset = input.offset ?? 0;

  if (!Number.isInteger(offset) || offset < 0) {
    throw new PaginationError("offset must be a non-negative integer.");
  }

  return {
    kind: "offset",
    limit,
    offset,
  };
}

/**
 * Normalize cursor pagination input.
 *
 * The limit is defaulted and clamped to `maxLimit`; cursor must be a string or
 * null.
 */
export function normalizeCursorPage(
  input: CursorPageInput,
  options: NormalizePageOptions,
): CursorPage {
  const limit = normalizeLimit(input.limit, options);
  const cursor = input.cursor ?? null;

  if (cursor !== null && typeof cursor !== "string") {
    throw new PaginationError("cursor must be a string or null.");
  }

  return {
    kind: "cursor",
    limit,
    cursor,
  };
}

/**
 * Create an offset page result and derive `hasMore`.
 */
export function offsetPageResult<TItem>(
  items: readonly TItem[],
  page: OffsetPage,
  total: number,
): PageResult<TItem, OffsetPageInfo> {
  if (!Number.isInteger(total) || total < 0) {
    throw new PaginationError("total must be a non-negative integer.");
  }

  return {
    items: [...items],
    page: {
      ...page,
      total,
      hasMore: page.offset + items.length < total,
    },
  };
}

/**
 * Create a cursor page result and derive `hasMore` from `nextCursor`.
 */
export function cursorPageResult<TItem>(
  items: readonly TItem[],
  page: CursorPage,
  nextCursor: string | null,
): PageResult<TItem, CursorPageInfo> {
  if (nextCursor !== null && typeof nextCursor !== "string") {
    throw new PaginationError("nextCursor must be a string or null.");
  }

  return {
    items: [...items],
    page: {
      ...page,
      nextCursor,
      hasMore: nextCursor !== null,
    },
  };
}
