<?php

namespace EesyPHP;

use Exception;
use PDO;
use \Envms\FluentPDO\Query;

class Db {

  /**
   * The PDO object of the database connection
   * @var PDO
   */
  public $pdo;

  /**
   * The PDO object of the database connection
   * @var \Envms\FluentPDO\Query
   */
  public $fpdo;

  /**
   * Date format as returned by database
   * @var string
   */
  protected $date_format = '%Y-%m-%d';

  /**
   * Datetime format as returned by database
   * @var string
   */
  protected $datetime_format = '%Y-%m-%d %H:%M:%S';

  /**
   * Locale for time (as expected by LC_TIME)
   * @var string|null
   */
  protected $locale_time = null;

  /**
   * Keep trace of total queries times (in ns)
   * @var int;
   */
  public static $total_query_time = 0;

  /**
   * Connect to database and return FluentPDO Query object
   * @param string $dsn Database DSN
   * @param string|null $user Username (optional)
   * @param string|null $password password (optional)
   * @param string|array $options Connection options (optional)
   * @param string|null $date_format Date format in DB (optional)
   * @param string|null $datetime_format Datetime format in DB (optional)
   * @param string|null $locale_time Locale for time (optional)
   * @return void
   */
  public function __construct($dsn, $user=null, $password=null, $options=null,
                              $date_format=null, $datetime_format=null, $locale_time=null) {
    if (!$dsn)
      Log :: fatal('Database DSN not configured');
    if ($date_format) $this -> date_format = $date_format;
    if ($datetime_format) $this -> datetime_format = $datetime_format;
    if ($locale_time) $this -> locale_time = $locale_time;

    try {
      // Connect to database
      $this -> pdo = new PDO($dsn, $user, $password, $options);
      $this -> fpdo = new Query($this -> pdo);

      // Register the debug query handler to log it
      $this -> fpdo -> debug = array(self :: class, 'debug_query');

      Log :: trace("DB connection established (DSN: '%s')", $dsn);
    }
    catch(Exception $e) {
      Log :: error("Fail to connect to DB (DSN : '%s') : %s", $dsn, $e->getMessage());
      Log :: fatal(I18n::_('Unable to connect to the database.'));
    }
  }

  /**
   * Debug a query
   * @param \Envms\FluentPDO\Queries\Base $q
   * @return void
   */
  public static function debug_query($q) {
    $error = $q->getMessage();
    // Execution time not available in case of execution error
    if (!$error)
      self :: $total_query_time += intval(ceil($q->getExecutionTime() * 1000000000));
    $msg = "# DB query";
    if ($q->getResult())
        $msg .= sprintf(
            " (%0.3f ms; rows = %d)",
            $q->getExecutionTime() * 1000,
            $q->getResult()->rowCount()
        );
    $msg .= ": ".$q->getQuery();

    $parameters = $q->getParameters();
    if ($parameters) {
        if (is_array($parameters)) {
          $msg .= "\n# Parameters: '" . implode("', '", $parameters) . "'";
        }
        else {  // @phpstan-ignore-line
          $msg .= "\n# Parameters: '" . vardump($parameters) . "'";
        }
    }
    if ($error)
      $msg .= "\n# ERROR: $error";

    Log :: debug($msg);
  }

  /**
   * Set autocommit (only available on OCI, Firebird or MySQL connection)
   * @param bool $value
   * @see https://www.php.net/manual/en/pdo.setattribute.php
   * @return void
   */
  public function set_autocommit($value) {
    $this -> pdo -> setAttribute(PDO::ATTR_AUTOCOMMIT, $value);
  }

  /*
   * Simple request helpers
   */

  /**
   * Helper to retreive one row from a table of the database
   * @param string $table               The table name
   * @param array|string $where         WHERE clause(s) as expected by Envms\FluentPDO\Query
   * @param array|string|null $fields   The expected fields as string (separeted by comma) or an
   *                                    array (optional, default: all table fields will be returned)
   * @param array<array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string}>|array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string} $joins    Join specification as array (see apply_joins())
   * @return array|false
   */
  public function get_one($table, $where, $fields=null, $joins=null) {
    try {
      $query = $this -> fpdo -> from($table) -> where($where);
      if ($joins)
        self :: apply_joins($query, $joins);
      if ($fields)
        $query -> select(null) -> select($fields);
      if ($query->execute() === false)
        return false;
      $return = $query->fetchAll();
      if (is_array($return) && count($return) == 1)
        return $return[0];
    }
    catch (Exception $e) {
      self :: _log_simple_select_query_error(
        false, $table, $e, $where, $fields, null, null, $joins
      );
    }
    return false;
  }


  /**
   * Helper to retreive multiple rows from a table of the database
   * @param string $table               The table name
   * @param array|string $where         WHERE clause(s) as expected by Envms\FluentPDO\Query
   * @param array|string|null $fields   The expected fields as string (separeted by comma) or an
   *                                    array (optional, default: all table fields will be returned)
   * @param string|null $order_by       An optional ORDER clause as a string
   * @param string|null $limit          An optional LIMIT clause as a string
   * @param array<array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string}>|array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string} $joins    Join specification as array (see apply_joins())
   * @return array|false
   */
  public function get_many(
    $table, $where=null, $fields=null, $order_by=null, $limit=null, $joins=null
  ) {
    try {
      $query = $this -> fpdo -> from($table);
      if ($joins)
        self :: apply_joins($query, $joins);
      if ($fields)
        $query -> select(null) -> select($fields);
      if ($where)
        $query -> where($where);
      if ($order_by)
        $query -> orderBy($order_by);
      if ($query->execute() === false)
        return false;
      $return = $query->fetchAll();
      if (is_array($return))
        return $return;
    }
    catch (Exception $e) {
      self :: _log_simple_select_query_error(
        false, $table, $e, $where, $fields, $order_by, $limit, $joins
      );
    }
    return false;
  }

  /**
   * Mapping of JOIN type with corresponding query object method
   * @var array<string,string>
   */
  private static $join_type_to_query_method = array(
    'LEFT' => 'leftJoin',
    'RIGHT' => 'rightJoin',
    'INNER' => 'innerJoin',
    'OUTER' => 'outerJoin',
    'FULL' => 'fullJoin',
  );

  /**
   * Apply JOIN clauses on a query object
   * @param \Envms\FluentPDO\Query $query  The reference of the query object
   * @param array<array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string}>|array{0: 'LEFT'|'RIGHT'|'INNER'|'OUTER'|'FULL', 1: string, 2: string} $joins    Join specification as array: ['type', 'table', 'ON clause']
   *                                       - type: LEFT, RIGHT, INNER, OUTER or FULL
   *                                       - table: the joined table name
   *                                       - ON clause: the ON clause (ex: "user.id = article.user_id")
   * @return void
   */
  public static function apply_joins(&$query, &$joins) {
    if (!$joins) return;
    if (isset($joins[0]) && !is_array($joins[0]))
      $joins = [$joins];
    foreach ($joins as $join) {
      if (!is_array($join) || count($join) != 3) {
        throw new Exception(sprintf("Invalid JOIN clause provided: %s", vardump($join)));
      }
      if (!array_key_exists(strtoupper($join[0]), self :: $join_type_to_query_method)) {
        throw new Exception(sprintf("Invalid JOIN type '%s'", $join[0]));
      }
      $method = self :: $join_type_to_query_method[strtoupper($join[0])];
      call_user_func([$query, $method], sprintf("%s ON %s", $join[1], $join[2]));
    }
  }

  /**
   * Helper to log error during simple select query
   * @param bool $multiple              True if expected multiple rows, False instead
   * @param string $table               The table name
   * @param Exception $e                The exception
   * @param array|string $where         WHERE clause(s) as expected by Envms\FluentPDO\Query
   * @param array|string|null $fields   The expected fields as string (separeted by comma) or an
   *                                    array (optional, default: all table fields will be returned)
   * @param string|null $order_by       An optional ORDER clause as a string
   * @param string|null $limit          An optional LIMIT clause as a string
   * @param array<array<string>>|null $joins Join specification as array (see apply_joins())
   * @return void
   */
  private function _log_simple_select_query_error(
    $multiple, $table, $e, $where=null, $fields=null, $order_by=null, $limit=null, $joins=null
  ) {
    $msg = "Error occured getting %s of the table %s";
    $params = [
      $multiple?"rows":"one row",
      $table,
    ];
    if (is_array($joins)) {
      foreach($joins as $join) {
        $msg .= ", %s join with table %s on '%s'";
        $params = array_merge($params, $join);
      }
    }
    $extra_clauses = [];
    if ($where)
      $extra_clauses['where'] = (
        is_array($where)?
        preg_replace("/\n */", " ", print_r($where, true)):
        vardump($where)
      );
    if ($fields)
      $extra_clauses['selected fields'] = is_array($fields)?implode(',', $fields):$fields;
    if ($order_by)
      $extra_clauses['order by'] = $order_by;
    if ($limit)
      $extra_clauses['limit'] = $limit;
    $msg .= "in database (%s): %s";
    $params[] = implode_with_keys($extra_clauses);
    $params[] = $e->getMessage();
    Log :: error($msg, $params);
  }

  /**
   * Helper to insert a row in a table
   * @param string               $table    The table name
   * @param array<string,mixed>  $values   The values of the row
   * @param boolean $want_id  Set to true if you want to retreive the ID of the inserted row
   * @return bool|int The ID of the inserted row if $want_id, or true/false in case of success/error
   */
  public function insert($table, $values, $want_id=false) {
    try {
      $id = $this -> fpdo -> insertInto($table)
        -> values($values)
        -> execute();
    }
    catch (Exception $e) {
      Log :: error(
        "Error occured inserting row in the table %s of the database: %s\nValues:\n%s",
        $table,
        $e->getMessage(),
        vardump($values)
      );
      return false;
    }
    if ($id !== false) {
      Log::debug('Row insert in table %s with ID #%s', $table, $id);
      return $want_id?$id:true;
    }
    return false;
  }

  /**
   * Helper to update a row in database
   * @param string $table                         The table name
   * @param array<string,mixed> $changes          Associative array of changes
   * @param array|string $where                   WHERE clause(s) as expected by Envms\FluentPDO\Query
   * @param null|int|false $expected_row_changes  The number of expected row affected by the query
   *                                              or false if you don't want to check it (optional,
   *                                              default: null == 1)
   * @return bool
   */
  public function update($table, $changes, $where, $expected_row_changes=null) {
    if (is_null($expected_row_changes)) $expected_row_changes = 1;
    try {
      $result = $this -> fpdo -> update($table)
        -> set($changes)
        -> where($where)
        -> execute();
    }
    catch (Exception $e) {
      Log :: error(
        "Error occured updating %s in the table %s of the database (where %s): %s\nChanges:\n%s",
        $expected_row_changes == 1?'row':'rows',
        $table,
        is_array($where)?
        preg_replace("/\n */", " ", print_r($where, true)):
        vardump($where),
        $e->getMessage(),
        vardump($changes)
      );
      return false;
    }
    if (!is_int($result))
      return false;
    if ($expected_row_changes === false)
      return true;
    return $expected_row_changes == $result;
  }

  /**
   * Helper to delete one or multiple rows in a table of the database
   * @param string $table                         The table name
   * @param array|string $where                   WHERE clause(s) as expected by Envms\FluentPDO\Query
   * @param null|int|false $expected_row_changes  The number of expected row affected by the query
   *                                              or false if you don't want to check it (optional,
   *                                              default: null == 1)
   * @return bool
   */
  public function delete($table, $where, $expected_row_changes=null) {
    if (is_null($expected_row_changes)) $expected_row_changes = 1;
    try {
      $result = $this -> fpdo -> deleteFrom($table)
        -> where($where)
        -> execute();
    }
    catch (Exception $e) {
      Log :: error(
        "Error occured deleting %s in the table %s of the database (where %s): %s",
        $expected_row_changes == 1?'one row':'some rows',
        $table,
        is_array($where)?
        preg_replace("/\n */", " ", print_r($where, true)):
        vardump($where),
        $e->getMessage()
      );
      return false;
    }
    // Bad return type declared in \Envms\FluentPDO\Queries\Delete
    // @phpstan-ignore-next-line
    if (!is_int($result))
      return false;
    // @phpstan-ignore-next-line
    if ($expected_row_changes === false)
      return true;
    return $expected_row_changes == $result;
  }


  /*
   * Handle date/datetime format
   */
  public function set_locale() {
    if ($this -> locale_time)
      setlocale(LC_TIME, $this -> locale_time);
  }

  public function date2time($date) {
    $this -> set_locale();
    $pdate = strptime($date, $this -> date_format);
    return mktime(
      $pdate['tm_hour'], $pdate['tm_min'], $pdate['tm_sec'],
      $pdate['tm_mon'] + 1, $pdate['tm_mday'], $pdate['tm_year'] + 1900
    );
  }

  public function time2date($time) {
    $this -> set_locale();
    return strftime($this -> date_format, $time);
  }

  public function datetime2time($date) {
    $this -> set_locale();
    $pdate = strptime($date, $this -> datetime_format);
    return mktime(
      $pdate['tm_hour'], $pdate['tm_min'], $pdate['tm_sec'],
      $pdate['tm_mon'] + 1, $pdate['tm_mday'], $pdate['tm_year'] + 1900
    );
  }

  public function time2datetime($time) {
    $this -> set_locale();
    return strftime($this -> datetime_format, $time);
  }

  /**
   * Helper method to format row info
   * @param array<string,mixed> $row The raw row info
   * @param array<string> $datetime_fields List of field in datetime format
   * @param array<string> $date_fields List of field in date format
   * @return array
   */
  public function format_row_info($row, $datetime_fields=null, $date_fields=null) {
    // Convert datetime fields
    if (is_array($datetime_fields))
      foreach($datetime_fields as $field)
        if ($row[$field])
          $row[$field] = $this -> datetime2time($row[$field]);
    // Convert date fields
    if (is_array($date_fields))
      foreach($date_fields as $field)
        if ($row[$field])
          $row[$field] = $this -> date2time($row[$field]);
    return $row;
  }
}
