import { Connection } from "./connection/connection.js";
import {
  ClientConfiguration,
  ClientOptions,
  ConnectionString,
  createParams,
} from "./connection/connection_params.js";
import {
  Query,
  QueryArguments,
  QueryArrayResult,
  QueryObjectOptions,
  QueryObjectResult,
  QueryOptions,
  QueryResult,
  ResultType,
  templateStringToQuery,
} from "./query/query.js";
import { Transaction, TransactionOptions } from "./query/transaction.js";
import { isTemplateString } from "./utils/utils.js";

export interface Session {
  /**
   * This is the code for the transaction currently locking the connection.
   * If there is no transaction ongoing, the transaction code will be null
   */
  current_transaction: string | null;
  /**
   * This is the process id of the current session as assigned by the database
   * on connection. This id will undefined when there is no connection stablished
   */
  pid: number | undefined;
  /**
   * Indicates if the connection is being carried over TLS. It will be undefined when
   * there is no connection stablished
   */
  tls: boolean | undefined;
  /**
   * This indicates the protocol used to connect to the database
   *
   * The two supported transports are TCP and Unix sockets
   */
  transport: "tcp" | "socket" | undefined;
}

export abstract class QueryClient {
  #connection: Connection;
  #terminated = false;
  #transaction: string | null = null;

  constructor(connection: Connection) {
    this.#connection = connection;
  }

  get connected() {
    return this.#connection.connected;
  }

  get session(): Session {
    return {
      current_transaction: this.#transaction,
      pid: this.#connection.pid,
      tls: this.#connection.tls,
      transport: this.#connection.transport,
    };
  }

  #assertOpenConnection() {
    if (this.#terminated) {
      throw new Error(
        "Connection to the database has been terminated",
      );
    }
  }

  protected async closeConnection() {
    if (this.connected) {
      await this.#connection.end();
    }

    this.resetSessionMetadata();
  }

  /**
   * Transactions are a powerful feature that guarantees safe operations by allowing you to control
   * the outcome of a series of statements and undo, reset, and step back said operations to
   * your liking
   *
   * In order to create a transaction, use the `createTransaction` method in your client as follows:
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const client = new Client();
   * const transaction = client.createTransaction("my_transaction_name");
   *
   * await transaction.begin();
   * // All statements between begin and commit will happen inside the transaction
   * await transaction.commit(); // All changes are saved
   * ```
   *
   * All statements that fail in query execution will cause the current transaction to abort and release
   * the client without applying any of the changes that took place inside it
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const client = new Client();
   * const transaction = client.createTransaction("transaction");
   *
   * await transaction.begin();
   * await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`;
   * try {
   *   await transaction.queryArray`SELECT []`; // Invalid syntax, transaction aborted, changes won't be applied
   * }catch(e){
   *   await transaction.commit(); // Will throw, current transaction has already finished
   * }
   * ```
   *
   * This however, only happens if the error is of execution in nature, validation errors won't abort
   * the transaction
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const client = new Client();
   * const transaction = client.createTransaction("transaction");
   *
   * await transaction.begin();
   * await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`;
   * try {
   *   await transaction.rollback("unexistent_savepoint"); // Validation error
   * } catch(e) {
   *   await transaction.commit(); // Transaction will end, changes will be saved
   * }
   * ```
   *
   * A transaction has many options to ensure modifications made to the database are safe and
   * have the expected outcome, which is a hard thing to accomplish in a database with many concurrent users,
   * and it does so by allowing you to set local levels of isolation to the transaction you are about to begin
   *
   * Each transaction can execute with the following levels of isolation:
   *
   * - Read committed: This is the normal behavior of a transaction. External changes to the database
   *   will be visible inside the transaction once they are committed.
   *
   * - Repeatable read: This isolates the transaction in a way that any external changes to the data we are reading
   *   won't be visible inside the transaction until it has finished
   *   ```ts
   *   import { Client } from "./client.ts";
   *
   *   const client = new Client();
   *   const transaction = await client.createTransaction("my_transaction", { isolation_level: "repeatable_read" });
   *   ```
   *
   * - Serializable: This isolation level prevents the current transaction from making persistent changes
   *   if the data they were reading at the beginning of the transaction has been modified (recommended)
   *   ```ts
   *   import { Client } from "./client.ts";
   *
   *   const client = new Client();
   *   const transaction = await client.createTransaction("my_transaction", { isolation_level: "serializable" });
   *   ```
   *
   * Additionally, each transaction allows you to set two levels of access to the data:
   *
   * - Read write: This is the default mode, it allows you to execute all commands you have access to normally
   *
   * - Read only: Disables all commands that can make changes to the database. Main use for the read only mode
   *   is to in conjuction with the repeatable read isolation, ensuring the data you are reading does not change
   *   during the transaction, specially useful for data extraction
   *   ```ts
   *   import { Client } from "./client.ts";
   *
   *   const client = new Client();
   *   const transaction = await client.createTransaction("my_transaction", { read_only: true });
   *   ```
   *
   * Last but not least, transactions allow you to share starting point snapshots between them.
   * For example, if you initialized a repeatable read transaction before a particularly sensible change
   * in the database, and you would like to start several transactions with that same before the change state
   * you can do the following:
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const client_1 = new Client();
   * const client_2 = new Client();
   * const transaction_1 = client_1.createTransaction("transaction_1");
   *
   * const snapshot = await transaction_1.getSnapshot();
   * const transaction_2 = client_2.createTransaction("new_transaction", { isolation_level: "repeatable_read", snapshot });
   * // transaction_2 now shares the same starting state that transaction_1 had
   * ```
   *
   * https://www.postgresql.org/docs/14/tutorial-transactions.html
   * https://www.postgresql.org/docs/14/sql-set-transaction.html
   */
  createTransaction(name: string, options?: TransactionOptions): Transaction {
    this.#assertOpenConnection();

    return new Transaction(
      name,
      options,
      this,
      // Bind context so function can be passed as is
      this.#executeQuery.bind(this),
      (name: string | null) => {
        this.#transaction = name;
      },
    );
  }

  /**
   * Every client must initialize their connection previously to the
   * execution of any statement
   */
  async connect(): Promise<void> {
    if (!this.connected) {
      await this.#connection.startup(false);
      this.#terminated = false;
    }
  }

  /**
   * Closing your PostgreSQL connection will delete all non-persistent data
   * that may have been created in the course of the session and will require
   * you to reconnect in order to execute further queries
   */
  async end(): Promise<void> {
    await this.closeConnection();

    this.#terminated = true;
  }

  #executeQuery<T extends Array<unknown>>(
    _query: Query<ResultType.ARRAY>,
  ): Promise<QueryArrayResult<T>>;
  #executeQuery<T>(
    _query: Query<ResultType.OBJECT>,
  ): Promise<QueryObjectResult<T>>;
  #executeQuery(
    query: Query<ResultType>,
  ): Promise<QueryResult> {
    return this.#connection.query(query);
  }

  /**
   * This method allows executed queries to be retrieved as array entries.
   * It supports a generic interface in order to type the entries retrieved by the query
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const my_client = new Client();
   *
   * const {rows} = await my_client.queryArray(
   *   "SELECT ID, NAME FROM CLIENTS"
   * ); // Array<unknown[]>
   * ```
   *
   * You can pass type arguments to the query in order to hint TypeScript what the return value will be
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const my_client = new Client();
   * const { rows } = await my_client.queryArray<[number, string]>(
   *   "SELECT ID, NAME FROM CLIENTS"
   * ); // Array<[number, string]>
   * ```
   *
   * It also allows you to execute prepared statements with template strings
   *
   * ```ts
   * import { Client } from "./client.ts";
   * const my_client = new Client();
   *
   * const id = 12;
   * // Array<[number, string]>
   * const {rows} = await my_client.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`;
   * ```
   */
  queryArray<T extends Array<unknown>>(
    query: string,
    args?: QueryArguments,
  ): Promise<QueryArrayResult<T>>;
  queryArray<T extends Array<unknown>>(
    config: QueryOptions,
  ): Promise<QueryArrayResult<T>>;
  queryArray<T extends Array<unknown>>(
    strings: TemplateStringsArray,
    ...args: unknown[]
  ): Promise<QueryArrayResult<T>>;
  queryArray<T extends Array<unknown> = Array<unknown>>(
    query_template_or_config: TemplateStringsArray | string | QueryOptions,
    ...args: unknown[] | [QueryArguments | undefined]
  ): Promise<QueryArrayResult<T>> {
    this.#assertOpenConnection();

    if (this.#transaction !== null) {
      throw new Error(
        `This connection is currently locked by the "${this.#transaction}" transaction`,
      );
    }

    let query: Query<ResultType.ARRAY>;
    if (typeof query_template_or_config === "string") {
      query = new Query(
        query_template_or_config,
        ResultType.ARRAY,
        args[0] as QueryArguments | undefined,
      );
    } else if (isTemplateString(query_template_or_config)) {
      query = templateStringToQuery(
        query_template_or_config,
        args,
        ResultType.ARRAY,
      );
    } else {
      query = new Query(query_template_or_config, ResultType.ARRAY);
    }

    return this.#executeQuery(query);
  }

  /**
   * This method allows executed queries to be retrieved as object entries.
   * It supports a generic interface in order to type the entries retrieved by the query
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const my_client = new Client();
   *
   * {
   * 	 const { rows } = await my_client.queryObject(
   *     "SELECT ID, NAME FROM CLIENTS"
   * 	 ); // Record<string, unknown>
   * }
   *
   * {
   * 	 const { rows } = await my_client.queryObject<{id: number, name: string}>(
   *     "SELECT ID, NAME FROM CLIENTS"
   *   ); // Array<{id: number, name: string}>
   * }
   * ```
   *
   * You can also map the expected results to object fields using the configuration interface.
   * This will be assigned in the order they were provided
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const my_client = new Client();
   *
   * {
   *   const {rows} = await my_client.queryObject(
   *     "SELECT ID, NAME FROM CLIENTS"
   *   );
   *
   * 	 console.log(rows); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}]
   * }
   *
   * {
   * 	 const {rows} = await my_client.queryObject({
   *     text: "SELECT ID, NAME FROM CLIENTS",
   *  	 fields: ["personal_id", "complete_name"],
   * 	  });
   *
   * 	 console.log(rows); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}]
   * }
   * ```
   *
   * It also allows you to execute prepared statements with template strings
   *
   * ```ts
   * import { Client } from "./client.ts";
   *
   * const my_client = new Client();
   * const id = 12;
   * // Array<{id: number, name: string}>
   * const { rows } = await my_client.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`;
   * ```
   */
  queryObject<T>(
    query: string,
    args?: QueryArguments,
  ): Promise<QueryObjectResult<T>>;
  queryObject<T>(
    config: QueryObjectOptions,
  ): Promise<QueryObjectResult<T>>;
  queryObject<T>(
    query: TemplateStringsArray,
    ...args: unknown[]
  ): Promise<QueryObjectResult<T>>;
  queryObject<
    T = Record<string, unknown>,
  >(
    query_template_or_config:
      | string
      | QueryObjectOptions
      | TemplateStringsArray,
    ...args: unknown[] | [QueryArguments | undefined]
  ): Promise<QueryObjectResult<T>> {
    this.#assertOpenConnection();

    if (this.#transaction !== null) {
      throw new Error(
        `This connection is currently locked by the "${this.#transaction}" transaction`,
      );
    }

    let query: Query<ResultType.OBJECT>;
    if (typeof query_template_or_config === "string") {
      query = new Query(
        query_template_or_config,
        ResultType.OBJECT,
        args[0] as QueryArguments | undefined,
      );
    } else if (isTemplateString(query_template_or_config)) {
      query = templateStringToQuery(
        query_template_or_config,
        args,
        ResultType.OBJECT,
      );
    } else {
      query = new Query(
        query_template_or_config as QueryObjectOptions,
        ResultType.OBJECT,
      );
    }

    return this.#executeQuery<T>(query);
  }

  protected resetSessionMetadata() {
    this.#transaction = null;
  }
}

/**
 * Clients allow you to communicate with your PostgreSQL database and execute SQL
 * statements asynchronously
 *
 * ```ts
 * import { Client } from "./client.ts";
 *
 * const client = new Client();
 * await client.connect();
 * await client.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`;
 * await client.end();
 * ```
 *
 * A client will execute all their queries in a sequencial fashion,
 * for concurrency capabilities check out connection pools
 *
 * ```ts
 * import { Client } from "./client.ts";
 *
 * const client_1 = new Client();
 * await client_1.connect();
 * // Even if operations are not awaited, they will be executed in the order they were
 * // scheduled
 * client_1.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`;
 * client_1.queryArray`DELETE FROM MY_TABLE`;
 *
 * const client_2 = new Client();
 * await client_2.connect();
 * // `client_2` will execute it's queries in parallel to `client_1`
 * const {rows: result} = await client_2.queryArray`SELECT * FROM MY_TABLE`;
 *
 * await client_1.end();
 * await client_2.end();
 * ```
 */
export class Client extends QueryClient {
  constructor(config?: ClientOptions | ConnectionString) {
    super(
      new Connection(createParams(config), async () => {
        await this.closeConnection();
      }),
    );
  }
}

export class PoolClient extends QueryClient {
  #release: () => void;

  constructor(config: ClientConfiguration, releaseCallback: () => void) {
    super(
      new Connection(config, async () => {
        await this.closeConnection();
      }),
    );
    this.#release = releaseCallback;
  }

  release() {
    this.#release();

    // Cleanup all session related metadata
    this.resetSessionMetadata();
  }
}
