/* eslint-disable @typescript-eslint/no-explicit-any */
import { TableName } from "pg-promise";
import decamelcaseKeys from "decamelcase-keys";
import isObject from "lodash/isObject.js";
import isArray from "lodash/isArray.js";
import zipObject from "lodash/zipObject.js";
import isEmpty from "lodash/isEmpty.js";
import omit from "lodash/omit.js";
import { IDatabase, IMain, IResult } from "../db/types.js";
import { PgRecordInfo, idKeysOf } from "./record-info.js";

export interface ICrudRepository<
  TUnsavedR,
  TSavedR extends TUnsavedR & IdKeyT,
  IdKeyT extends object
> {
  findById(id: IdKeyT): Promise<TSavedR | null>;
  findBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
  findAll(): Promise<TSavedR[]>;
  findAllBy(example: Partial<TSavedR>): Promise<TSavedR[]>;
  existsById(id: IdKeyT): Promise<boolean>;
  countBy(example: Partial<TSavedR>): Promise<number>;
  count(): Promise<number>;
  insert(record: TUnsavedR): Promise<TSavedR>;
  insertAll(toInsert: TUnsavedR[]): Promise<TSavedR[]>;
  updateById(id: IdKeyT, attrs: Partial<TSavedR>): Promise<TSavedR>;
  update(record: TSavedR): Promise<TSavedR>;
  updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]>;
  remove(record: TSavedR): Promise<number>;
  removeAll(toRemove: TSavedR[]): Promise<number>;
  removeBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
  removeById(id: IdKeyT): Promise<number>;
}

// The snake cased object going into the db
type DatabaseRow = Record<string, unknown>;

/**
 * Base class for generic CRUD operations on a repository for a specific type.
 *
 * Several assumptions are made about your environment for this generic CRUD repository to work:
 *
 * - the underlying column names are snake_cased (this behavior can be changed, see [[columnize]])
 * - the rows have only a single primary key (composite keys are not supported)
 *
 * @typeParam ID The type of the id column
 * @typeParam T The type of the record
 */
export abstract class CrudRepository<
  TUnsavedR,
  TSavedR extends TUnsavedR & IdKeyT,
  IdKeyT extends object
> implements ICrudRepository<TUnsavedR, TSavedR, IdKeyT>
{
  /**
   * the fully qualified table name
   */
  abstract schemaTable: TableName;
  abstract recordType: PgRecordInfo<TUnsavedR, TSavedR, IdKeyT>;
  abstract db: IDatabase;
  abstract pgp: IMain;

  /**
   * Converts the record's columns into snake_case
   *
   * @param record the record of type T to convert
   */
  columnize(record: TSavedR | Partial<TSavedR>): DatabaseRow {
    return decamelcaseKeys(record);
  }

  /*
   * Creates a simple where clause with each key-value in `example` is
   * formatted as KEY=VALUE and all kv-pairs are ANDed together.
   *
   * @param example key value pair of column names and values
   */
  where(example: Partial<TSavedR>): string {
    const snaked = this.columnize(example);
    const clauses = Object.keys(snaked).reduce((acc, cur) => {
      const colName = this.pgp.as.format("$1:name", cur);
      return `${acc} and ${colName} = $<${cur}>`;
    }, "");
    const where = this.pgp.as.format(`WHERE 1=1 ${clauses}`, { ...snaked }); // Pre-format WHERE condition
    return where;
  }

  /**
   * Converts a value containing the id of the record (which could be a primitive type, a composite object, or an array of values)
   * into an object which can be safely passed to [[where]].
   */
  idsObj(idValues: IdKeyT): IdKeyT {
    if (isEmpty(idValues)) {
      throw new Error(`idsObj(${this.schemaTable}): passed empty id(s)`);
    }

    let ids = {};
    const idKeys = idKeysOf(this.recordType as any);
    if (isArray(idValues)) {
      ids = zipObject(idKeys, idValues);
    } else if (isObject(idValues)) {
      ids = idValues;
    } else {
      if (idKeys.length !== 1) {
        throw new Error(
          `idsObj(${this.schemaTable}): passed record has multiple primary keys. the ids must be passed as an object or array. ${idValues}`
        );
      }

      ids[idKeys[0]] = idValues;
    }

    // this is a sanity check so we don't do something like
    // deleting  all the data if a WHERE slips in with no ids
    if (isEmpty(ids)) {
      throw new Error(`idsObj(${this.schemaTable}): passed empty ids`);
    }

    return ids as IdKeyT;
  }

  /**
   *  Returns all rows in the table
   */
  async findAll(): Promise<TSavedR[]> {
    return this.db.any("SELECT * FROM $1", [this.schemaTable]);
  }

  /**
   * Returns the number of rows in the table
   */
  async count(): Promise<number> {
    return this.db.one(
      "SELECT count(*) FROM $1",
      [this.schemaTable],
      (a: { count: string }) => Number(a.count)
    );
  }

  /**
   * Returns the number of rows in the table matching the example
   */
  async countBy(example: Partial<TSavedR>): Promise<number> {
    return this.db.one(
      "SELECT count(*) FROM $1 $2:raw ",
      [this.schemaTable, this.where(example)],
      (a: { count: string }) => Number(a.count)
    );
  }

  /**
   * Find a single row where the example are true.
   * @param example key-value pairs of column names and values
   */
  async findBy(example: Partial<TSavedR>): Promise<TSavedR | null> {
    return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [
      this.schemaTable,
      this.where(example),
    ]);
  }

  /**
   * Retrieves a row by ID
   * @param id
   */
  async findById(id: IdKeyT): Promise<TSavedR | null> {
    const where = this.idsObj(id);
    return this.db.oneOrNone("SELECT * FROM $1 $2:raw", [
      this.schemaTable,
      this.where(where),
    ]);
  }

  /**
   * Returns whether a given row with id exists
   * @param id
   */
  async existsById(id: IdKeyT): Promise<boolean> {
    return this.db.one(
      "SELECT EXISTS(SELECT 1 FROM $1 $2:raw)",
      [this.schemaTable, this.where(this.idsObj(id))],
      (a: { exists: boolean }) => a.exists
    );
  }

  /**
   * Find all rows where the example are true.
   * @param example key-value pairs of column names and values
   */
  async findAllBy(example: Partial<TSavedR>): Promise<TSavedR[]> {
    return this.db.any("SELECT * FROM $1 $2:raw", [
      this.schemaTable,
      this.where(example),
    ]);
  }

  /**
   * Creates a new row
   * @param record
   * @return the new row
   */
  async insert(record: TUnsavedR): Promise<TSavedR> {
    return this.db.one("INSERT INTO $1 ($2:name) VALUES ($2:csv) RETURNING *", [
      this.schemaTable,
      this.columnize(record as any),
    ]);
  }

  /**
   * Like `insert` but will insert/update a batch of rows at once
   */
  async insertAll(toInsert: TUnsavedR[]): Promise<TSavedR[]> {
    return this.db.tx((t) => {
      const insertCommands: any[] = [];

      toInsert.forEach((record) => {
        insertCommands.push(this.insert(record));
      });

      return t.batch(insertCommands);
    });
  }

  /**
   * Deletes a row by id
   * @param id
   * @return the number of rows affected
   */
  async removeById(id: IdKeyT): Promise<number> {
    return this.db.result(
      "DELETE FROM $1 $2:raw",
      [this.schemaTable, this.where(this.idsObj(id))],
      (r: IResult) => r.rowCount
    );
  }

  /**
   * Delete records matching the query
   * @param example key-value pairs of column names and values
   */
  async removeBy(example: Partial<TSavedR>): Promise<TSavedR | null> {
    if (isEmpty(example))
      throw new Error(
        `removeBy(${this.schemaTable}): passed empty constraint!`
      );
    return this.db.result("DELETE FROM $1 $2:raw", [
      this.schemaTable,
      this.where(example),
    ]);
  }

  /**
   * Deletes the given row
   *
   * @param record to remove
   * @return the number of rows affected
   */
  async remove(record: TSavedR): Promise<number> {
    return this.removeById(this.recordType.idOf(record));
  }

  /**
   * Deletes all rows
   * @param toRemove a list of rows to remove, if empty, DELETES ALL ROWS
   * @return the number of rows affected
   */
  async removeAll(toRemove: TSavedR[] = []): Promise<number> {
    if (toRemove.length === 0) {
      return this.db.result(
        "DELETE FROM $1 WHERE 1=1;",
        [this.schemaTable],
        (r: IResult) => r.rowCount
      );
    }

    const results = await this.db.tx((t) => {
      const delCommands: any[] = [];

      toRemove.forEach((record) => {
        delCommands.push(this.remove(record));
      });

      return t.batch(delCommands);
    });
    return results.length;
  }

  /**
   * Updates an existing row
   * @param id
   * @param attrs
   * @return the updated row
   */
  async updateById(id: IdKeyT, attrs: Partial<TSavedR>): Promise<TSavedR> {
    const idKeys = idKeysOf(this.recordType as any);
    const attrsSafe = omit(attrs, idKeys);
    return this.db.one(
      "UPDATE $1 SET ($2:name) = ROW($2:csv) $3:raw RETURNING *",
      [this.schemaTable, this.columnize(attrsSafe), this.where(this.idsObj(id))]
    );
  }

  async update(record: TSavedR): Promise<TSavedR> {
    return this.updateById(this.recordType.idOf(record), record);
  }

  /**
   * Update a batch of records at once
   */
  async updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]> {
    return this.db.tx((t) => {
      const updateCommands: any[] = [];

      toUpdate.forEach((record) => {
        updateCommands.push(this.update(record));
      });

      return t.batch(updateCommands);
    });
  }
}
