<?php

namespace ipl\Sql;

use BadMethodCallException;
use Exception;
use InvalidArgumentException;
use ipl\Sql\Contract\Adapter;
use ipl\Sql\Contract\Quoter;
use ipl\Stdlib\Plugins;
use PDO;
use PDOStatement;

/**
 * Connection to a SQL database using the native PDO for database access
 */
class Connection implements Quoter
{
    use Plugins;

    /** @var Config */
    protected $config;

    /** @var ?PDO */
    protected $pdo;

    /** @var QueryBuilder */
    protected $queryBuilder;

    /** @var Adapter */
    protected $adapter;

    /**
     * Create a new database connection using the given config for initialising the options for the connection
     *
     * {@link init()} is called after construction.
     *
     * @param Config|iterable $config
     *
     * @throws InvalidArgumentException If there's no adapter for the given database available
     */
    public function __construct($config)
    {
        $config = $config instanceof Config ? $config : new Config($config);

        $this->addPluginLoader('adapter', __NAMESPACE__ . '\\Adapter');

        $adapter = $this->loadPlugin('adapter', $config->db);

        if (! $adapter) {
            throw new InvalidArgumentException("Can't load database adapter for '{$config->db}'.");
        }

        $this->adapter = new $adapter();
        $this->config = $config;

        $this->init();
    }

    /**
     * Proxy PDO method calls
     *
     * @param string $name      The name of the PDO method to call
     * @param array  $arguments Arguments for the method to call
     *
     * @return mixed
     *
     * @throws BadMethodCallException If the called method does not exist
     *
     */
    public function __call($name, array $arguments)
    {
        $this->connect();

        if (! method_exists($this->pdo, $name)) {
            $class = get_class($this);
            $message = "Call to undefined method $class::$name";

            throw new BadMethodCallException($message);
        }

        return call_user_func_array([$this->pdo, $name], $arguments);
    }

    /**
     * Initialise the database connection
     *
     * If you have to adjust the connection after construction, override this method.
     */
    public function init()
    {
    }

    /**
     * Get the database adapter
     *
     * @return Adapter
     */
    public function getAdapter()
    {
        return $this->adapter;
    }

    /**
     * Get the connection configuration
     *
     * @return Config
     */
    public function getConfig()
    {
        return $this->config;
    }

    /**
     * Get the query builder for the database connection
     *
     * @return QueryBuilder
     */
    public function getQueryBuilder()
    {
        if ($this->queryBuilder === null) {
            $this->queryBuilder = new QueryBuilder($this->adapter);
        }

        return $this->queryBuilder;
    }

    /**
     * Create and return the PDO instance
     *
     * This method is called via {@link connect()} to establish a database connection.
     * If the default PDO needs to be adjusted for a certain DBMS, override this method.
     *
     * @return PDO
     */
    protected function createPdoAdapter()
    {
        $adapter = $this->getAdapter();

        $config = $this->getConfig();

        return new PDO(
            $adapter->getDsn($config),
            $config->username,
            $config->password,
            $adapter->getOptions($config)
        );
    }

    /**
     * Connect to the database, if not already connected
     *
     * @return $this
     */
    public function connect()
    {
        if ($this->pdo !== null) {
            return $this;
        }

        $this->pdo = $this->createPdoAdapter();

        if (! empty($this->config->charset)) {
            $this->exec(sprintf('SET NAMES %s', $this->pdo->quote($this->config->charset)));
        }

        $this->adapter->setClientTimezone($this);

        return $this;
    }

    /**
     * Disconnect from the database
     *
     * @return $this
     */
    public function disconnect()
    {
        $this->pdo = null;

        return $this;
    }

    /**
     * Check whether the connection to the database is still available
     *
     * @param bool $reconnect Whether to automatically reconnect
     *
     * @return bool
     */
    public function ping($reconnect = true)
    {
        try {
            $this->query('SELECT 1')->closeCursor();
        } catch (Exception $e) {
            if (! $reconnect) {
                return false;
            }

            $this->disconnect();

            return $this->ping(false);
        }

        return true;
    }

    /**
     * Fetch and return all result rows as sequential array
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute.
     * @param array         $values Values to bind to the statement
     *
     * @return array
     */
    public function fetchAll($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetchAll();
    }

    /**
     * Fetch and return the first column of all result rows as sequential array
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute.
     * @param array         $values Values to bind to the statement
     *
     * @return array
     */
    public function fetchCol($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetchAll(PDO::FETCH_COLUMN, 0);
    }

    /**
     * Fetch and return the first row of the result rows
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute.
     * @param array         $values Values to bind to the statement
     *
     * @return array
     */
    public function fetchOne($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetch();
    }

    /**
     * Alias of {@link fetchOne()}
     */
    public function fetchRow($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetch();
    }

    /**
     * Fetch and return all result rows as an array of key-value pairs
     *
     * First column is the key and the second column is the value.
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute.
     * @param array         $values Values to bind to the statement
     *
     * @return array
     */
    public function fetchPairs($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetchAll(PDO::FETCH_KEY_PAIR);
    }

    /**
     * Fetch and return the first column of the first result row
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute.
     * @param array         $values Values to bind to the statement
     *
     * @return string
     */
    public function fetchScalar($stmt, array $values = null)
    {
        return $this->prepexec($stmt, $values)
            ->fetchColumn(0);
    }

    /**
     * Yield each result row
     *
     * `Connection::yieldAll(Select|string $stmt [[, array $values], int $fetchMode [, mixed ...$fetchModeOptions]])`
     *
     * @param Select|string $stmt    The SQL statement to prepare and execute.
     * @param mixed         ...$args Values to bind to the statement, fetch mode for the statement, fetch mode options
     *
     * @return \Generator
     */
    public function yieldAll($stmt, ...$args)
    {
        $values = null;

        if (! empty($args)) {
            if (is_array($args[0])) {
                $values = array_shift($args);
            }
        }

        $fetchMode = null;

        if (! empty($args)) {
            $fetchMode = array_shift($args);

            switch ($fetchMode) {
                case PDO::FETCH_KEY_PAIR:
                    foreach ($this->yieldPairs($stmt, $values) as $key => $value) {
                        yield $key => $value;
                    }

                    return;
                case PDO::FETCH_COLUMN:
                    if (empty($args)) {
                        $args[] = 0;
                    }

                    break;
            }
        }

        $sth = $this->prepexec($stmt, $values);

        if ($fetchMode !== null) {
            $sth->setFetchMode($fetchMode, ...$args);
        }

        foreach ($sth as $key => $row) {
            yield $key => $row;
        }
    }

    /**
     * Yield the first column of each result row
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute
     * @param array         $values Values to bind to the statement
     *
     * @return \Generator
     */
    public function yieldCol($stmt, array $values = null)
    {
        $sth = $this->prepexec($stmt, $values);

        $sth->setFetchMode(PDO::FETCH_COLUMN, 0);

        foreach ($sth as $key => $row) {
            yield $key => $row;
        }
    }

    /**
     * Yield key-value pairs with the first column as key and the second column as value for each result row
     *
     * @param Select|string $stmt   The SQL statement to prepare and execute
     * @param array         $values Values to bind to the statement
     *
     * @return \Generator
     */
    public function yieldPairs($stmt, array $values = null)
    {
        $sth = $this->prepexec($stmt, $values);

        $sth->setFetchMode(PDO::FETCH_NUM);

        foreach ($sth as $row) {
            list($key, $value) = $row;

            yield $key => $value;
        }
    }

    /**
     * Prepare and execute the given statement
     *
     * @param Delete|Insert|Select|Update|string $stmt   The SQL statement to prepare and execute
     * @param string|array                       $values Values to bind to the statement, if any
     *
     * @return PDOStatement
     */
    public function prepexec($stmt, $values = null)
    {
        if ($values !== null && ! is_array($values)) {
            $values = [$values];
        }

        if (is_object($stmt)) {
            list($stmt, $values) = $this->getQueryBuilder()->assemble($stmt);
        }

        $this->connect();

        $sth = $this->pdo->prepare($stmt);
        $sth->execute($values);

        return $sth;
    }

    /**
     * Prepare and execute the given Select query
     *
     * @param Select $select
     *
     * @return PDOStatement
     */
    public function select(Select $select)
    {
        list($stmt, $values) = $this->getQueryBuilder()->assembleSelect($select);

        return $this->prepexec($stmt, $values);
    }

    /**
     * Insert a table row with the specified data
     *
     * @param string   $table The table to insert data into. The table specification must be in
     *                        one of the following formats: 'table' or 'schema.table'
     * @param iterable $data  Row data in terms of column-value pairs
     *
     * @return PDOStatement
     *
     * @throws InvalidArgumentException If data type is invalid
     */
    public function insert($table, $data)
    {
        $insert = (new Insert())
            ->into($table)
            ->values($data);

        return $this->prepexec($insert);
    }

    /**
     * Update table rows with the specified data, optionally based on a given condition
     *
     * @param string|array $table     The table to update. The table specification must be in one of
     *                                the following formats:
     *                                'table', 'table alias', ['alias' => 'table']
     * @param iterable     $data      The columns to update in terms of column-value pairs
     * @param mixed        $condition The WHERE condition
     * @param string       $operator  The operator to combine multiple conditions with,
     *                                if the condition is in the array format
     *
     * @return PDOStatement
     *
     * @throws InvalidArgumentException If data type is invalid
     */
    public function update($table, $data, $condition = null, $operator = Sql::ALL)
    {
        $update = (new Update())
            ->table($table)
            ->set($data);

        if ($condition !== null) {
            $update->where($condition, $operator);
        }

        return $this->prepexec($update);
    }

    /**
     * Delete table rows, optionally based on a given condition
     *
     * @param string|array $table     The table to delete data from. The table specification must be in one of the
     *                                following formats: 'table', 'table alias', ['alias' => 'table']
     * @param mixed        $condition The WHERE condition
     * @param string       $operator  The operator to combine multiple conditions with,
     *                                if the condition is in the array format
     *
     * @return PDOStatement
     */
    public function delete($table, $condition = null, $operator = Sql::ALL)
    {
        $delete = (new Delete())
            ->from($table);

        if ($condition !== null) {
            $delete->where($condition, $operator);
        }

        return $this->prepexec($delete);
    }

    /**
     * Begin a transaction
     *
     * @return bool Whether the transaction was started successfully
     */
    public function beginTransaction()
    {
        $this->connect();

        return $this->pdo->beginTransaction();
    }

    /**
     * Commit a transaction
     *
     * @return bool Whether the transaction was committed successfully
     */
    public function commitTransaction()
    {
        return $this->pdo->commit();
    }

    /**
     * Roll back a transaction
     *
     * @return bool Whether the transaction was rolled back successfully
     */
    public function rollBackTransaction()
    {
        return $this->pdo->rollBack();
    }

    /**
     * Run the given callback in a transaction
     *
     * @param callable $callback The callback to run in a transaction.
     *                           This connection instance is passed as parameter to the callback
     *
     * @return  mixed The return value of the callback
     *
     * @throws  Exception If an error occurs when running the callback
     */
    public function transaction(callable $callback)
    {
        $this->beginTransaction();

        try {
            $result = call_user_func($callback, $this);
            $this->commitTransaction();
        } catch (Exception $e) {
            $this->rollBackTransaction();

            throw $e;
        }

        return $result;
    }

    public function quoteIdentifier($identifier)
    {
        return $this->getAdapter()->quoteIdentifier($identifier);
    }
}
