import { chain, isArrayLike, isEmpty, isNil } from 'lodash';
import { Paths } from 'type-fest';

export type FilterOperator =
  | '$eq'
  | '$gt'
  | '$gte'
  | '$in'
  | '$null'
  | '$lt'
  | '$lte'
  | '$btw'
  | '$ilike'
  | '$sw'
  | '$contains';
export type FilterSuffix = '$not';
export type FilterModeSuffix = '$and' | '$or';
export type OrderSort = 'ASC' | 'DESC';

export type FilterEntry = [
  string,
  [FilterOperator, FilterSuffix?],
  any,
  FilterModeSuffix?,
  number?,
];
export type OrderEntry = [string, OrderSort];

export class PaginationQueryString<
  T = any,
  TPaths extends Paths<T> = Paths<T>,
> {
  readonly select?: TPaths[];
  readonly filter?: FilterEntry[];
  readonly sortBy?: OrderEntry[];
  readonly search?: string;
  readonly limit?: number;
  readonly page?: number;
  readonly lastNullGroup?: number;

  /**
   *  @deprecated Use the static method {@link PaginationQueryString.builder()} or the {@link PaginationQueryStringBuilder} instead.
   */
  constructor(query: Partial<PaginationQueryString<T, TPaths>>) {
    if (query.select != undefined) this.select = query.select;
    if (query.filter != undefined) this.filter = query.filter;
    if (query.sortBy != undefined) this.sortBy = query.sortBy;
    if (query.search != undefined) this.search = query.search;
    if (query.limit != undefined) this.limit = query.limit;
    if (query.page != undefined) this.page = query.page;
    if (query.lastNullGroup != undefined)
      this.lastNullGroup = query.lastNullGroup;
  }

  static builder<T = any, TPaths extends Paths<T> = Paths<T>>(
    query?: Partial<PaginationQueryString<T, TPaths>>,
  ): PaginationQueryStringBuilder<T, TPaths> {
    return new PaginationQueryStringBuilder<T, TPaths>(query);
  }

  paramsify() {
    const params: Record<string, unknown> = {};

    if (this.limit !== undefined) {
      params['limit'] = this.limit;
    }
    if (this.page !== undefined) {
      params['page'] = this.page;
    }
    if (this.sortBy !== undefined) {
      params['sortBy'] = this.sortBy.map(([field, sort]) => {
        return `${field}:${sort}`;
      });
    }
    if (this.search !== undefined) {
      params['search'] = this.search;
    }
    if (this.filter !== undefined) {
      chain(this.filter)
        .groupBy(([field]) => field)
        .forEach((group) => {
          const [field] = group[0];
          return (params[`filter.${field}`] = group.map(
            ([_field, operators, filter, mode, group]) => {
              const [operator, suffix] = operators;
              return `${group ? `[${group}]:` : ''}${mode ? `${mode}:` : ''}${suffix ? `${suffix}:` : ''}${operator}${
                filter != null ? `:${filter}` : ''
              }`;
            },
          ));
        })
        .value();
    }
    if (this.select !== undefined) {
      params['select'] = this.select.join(',');
    }

    return params;
  }

  stringify(): string {
    const params: string[] = [];

    if (this.limit !== undefined) {
      params.push(`limit=${this.limit}`);
    }
    if (this.page !== undefined) {
      params.push(`page=${this.page}`);
    }
    if (this.sortBy !== undefined) {
      params.push(
        ...this.sortBy.map(([field, sort]) => {
          return `sortBy=${field}:${sort}`;
        }),
      );
    }
    if (this.search !== undefined) {
      params.push(`search=${this.search}`);
    }
    if (this.filter !== undefined) {
      params.push(
        ...this.filter.map(([field, operators, filter, mode, group]) => {
          const [operator, suffix] = operators;
          return `filter.${field}=${group ? `[${group}]:` : ''}${mode ? `${mode}:` : ''}${
            suffix ? `${suffix}:` : ''
          }${operator}:${filter != null ? `:${filter}` : ''}`;
        }),
      );
    }
    if (this.select !== undefined) {
      params.push(`select=${this.select.join(',')}`);
    }

    return params.join('&');
  }
}

export class PaginationQueryStringBuilder<
  T = any,
  TPaths extends Paths<T> = Paths<T>,
> {
  private select?: TPaths[];
  private filter?: FilterEntry[];
  private sortBy?: OrderEntry[];
  private search?: string;
  private limit?: number;
  private page?: number;

  public lastNullGroup = 100;

  constructor(query?: Partial<PaginationQueryString<T, TPaths>>) {
    if (!query) return;

    if (query.select != undefined) this.setSelect<TPaths>(query.select);
    if (query.filter != undefined)
      query.filter.forEach((f) => this.addFilter(f[0], f[1], f[2], f[3], f[4]));
    if (query.sortBy != undefined)
      query.sortBy.forEach((s) => this.addOrder(s[0], s[1]));
    if (query.search != undefined) this.setSearch(query.search);
    this.setPagination({ limit: query.limit, page: query.page });
    if (query.lastNullGroup != undefined)
      this.lastNullGroup = query.lastNullGroup;
  }

  setPagination(
    opts: Pick<PaginationQueryString<T, TPaths>, 'limit' | 'page'>,
  ): PaginationQueryStringBuilder<T, TPaths> {
    if (
      opts.limit !== undefined &&
      !(opts.limit >= 0 && Number.isInteger(opts.limit))
    ) {
      throw new Error(`Limit must be non-negative integer`);
    }

    if (
      opts.page !== undefined &&
      !(opts.page >= 0 && Number.isInteger(opts.page))
    ) {
      throw new Error(`Page must be non-negative integer`);
    }

    this.limit = opts.limit;
    this.page = opts.page;
    return this;
  }

  setSelect<TSelection extends Paths<T>>(
    fields: readonly TSelection[],
  ): PaginationQueryStringBuilder<T, TSelection> {
    this.select = fields.map((field) => field as unknown as TPaths);
    return this as unknown as PaginationQueryStringBuilder<T, TSelection>;
  }

  setSearch(value: string): PaginationQueryStringBuilder<T, TPaths> {
    this.search = value;
    return this;
  }

  clearFilter(field: string) {
    this.filter ??= [];
    this.filter = this.filter?.filter((x) => x[0] !== field);
    return this;
  }

  addFilter(
    field: string,
    operators: [FilterOperator, FilterSuffix?],
    value: any,
    mode?: FilterModeSuffix,
    group?: number,
  ): PaginationQueryStringBuilder<T, TPaths> {
    if (
      (isNil(value) || (isArrayLike(value) && isEmpty(value))) &&
      operators[0] != '$null'
    )
      return this;

    this.filter ??= [];
    if (
      isArrayLike(value) &&
      operators[0] === '$in' &&
      (value as any[]).includes(null)
    ) {
      this.lastNullGroup++;
      this.filter.push([
        field,
        ['$null', operators[1]],
        undefined,
        mode,
        this.lastNullGroup + (group ?? 0),
      ]);
      value = (value as any[]).filter((x) => x !== null);
      if (value.length > 0) {
        this.filter.push([
          field,
          operators,
          value,
          '$or',
          this.lastNullGroup + (group ?? 0),
        ]);
      }
    } else {
      this.filter.push([field, operators, value, mode, group]);
    }
    return this;
  }

  addFilters(
    field: string,
    operators: [FilterOperator, FilterSuffix?],
    values: any[],
    mode?: FilterModeSuffix,
    group?: number,
  ): PaginationQueryStringBuilder<T, TPaths> {
    if (isNil(values) || (isArrayLike(values) && isEmpty(values))) return this;

    this.filter ??= [];
    // const [first, ...rest] = values;
    const [operator, suffix] = operators;

    // this.filter.push([field, [operator, suffix], first]);
    // for (const value of rest) {
    for (const value of values) {
      this.filter.push([field, [operator, suffix], value, mode, group]);
    }
    return this;
  }

  addOrder(
    field: string,
    sort: OrderSort,
  ): PaginationQueryStringBuilder<T, TPaths> {
    this.sortBy ??= [];
    this.sortBy.push([field, sort]);
    return this;
  }

  finalize(): PaginationQueryString<T, TPaths> {
    return new PaginationQueryString({
      select: this.select,
      filter: this.filter,
      sortBy: this.sortBy,
      search: this.search,
      limit: this.limit,
      page: this.page,
    });
  }
}
