<?php

namespace Acms\Services\Database\Engine;

use Acms\Services\Facades\Logger as AcmsLogger;
use App;
use SQL;
use PDO;
use PDOStatement;
use PDOException;

/**
 * Class Pdo
 * @package Acms\Services\Database\Engine
 */
class PdoEngine extends Base
{
    /**
     * @var \PDOStatement|null
     */
    protected $statement;

    /**
     * @var bool
     */
    protected $throwException = false;

    /**
     * @var array
     */
    protected $params = [];

    /**
     * connect mysql server
     *
     * @param array $dsn
     */
    public function connect($dsn)
    {
        $connect_str = 'mysql:host=';
        $host = explode(':', $dsn['host']);
        $connect_str .= $host[0] . ';';
        if (!empty($dsn['name'])) {
            $connect_str .= 'dbname=' . $dsn['name'] . ';';
        }
        if (!empty($dsn['port']) || isset($host[1])) {
            $port = empty($dsn['port']) ? $host[1] : $dsn['port'];
            $connect_str .= 'port=' . $port . ';';
        }

        $options = [];

        $connect_str .= 'charset=' . $this->getCharset($dsn);

        try {
            $this->connection = new PDO(
                $connect_str,
                $dsn['user'],
                $dsn['pass'],
                $options
            );
            $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        } catch (PDOException $e) {
            throw $e;
        }

        $charset = isset($dsn['charset']) ? $dsn['charset'] : 'UTF-8';
        $this->debug = !empty($dsn['debug']);
        $this->isBenchmarkMode = isBenchMarkMode();
        $this->dsn = [
            'type' => isset($dsn['type']) ? $dsn['type'] : null,
            'debug' => $this->debug,
            'charset' => $charset,
        ];

        $q = "SET SESSION sql_mode='ALLOW_INVALID_DATES'";
        $this->query(['sql' => $q, 'params' => []], 'exec');
    }

    /**
     * reconnect mysql server
     *
     * @param $dsn
     * @return void
     */
    public function reconnect($dsn)
    {
        $this->connection = null;
        $this->statement = null;
        $this->connect($dsn);
    }

    /**
     * データベースサーバーへの接続チェック
     *
     * @return bool
     */
    public function checkConnection($dsn)
    {
        try {
            $dsn['name'] = '';
            $this->connect($dsn);
        } catch (PDOException $e) {
            return false;
        }

        return true;
    }

    /**
     * データベースへの接続チェック
     *
     * @return bool
     */
    public function checkConnectDatabase($dsn)
    {
        if (empty($dsn['name'])) {
            return false;
        }
        try {
            $this->connect($dsn);
        } catch (\Exception $e) {
            return false;
        }
        return true;
    }

    /**
     * 例外をスローするか設定
     *
     * @param bool $throw
     */
    public function setThrowException($throw = true)
    {
        $this->throwException = $throw;
    }

    /**
     * 例外をスローするかの設定を取得
     * @return bool
     */
    public function getThrowException()
    {
        return $this->throwException;
    }

    /**
     * トランザクションを開始する。
     * 自身が管理する最も外側のレベルでは PDO の BEGIN を発行し、
     * 既に PDO レベルで進行中のトランザクションがある（テスト基底クラス等が外側で
     * BEGIN している場合を含む）か、ネストしている場合は SAVEPOINT を発行する。
     */
    public function beginTransaction(): void
    {
        if ($this->transactionLevel === 0 && !$this->connection->inTransaction()) {
            $this->connection->beginTransaction();
            $this->transactionStack[] = true;
        } else {
            $this->connection->exec('SAVEPOINT trans' . ($this->transactionLevel + 1));
            $this->transactionStack[] = false;
        }
        $this->transactionLevel++;
    }

    /**
     * トランザクションをコミットする。
     * 対応する beginTransaction が PDO::beginTransaction() で開始されていれば PDO::commit() を発行し、
     * SAVEPOINT で開始されていれば RELEASE SAVEPOINT を発行する。
     */
    public function commit(): void
    {
        if ($this->transactionLevel === 0) {
            return;
        }
        $isRealTransaction = (bool) array_pop($this->transactionStack);
        // ドライバ呼び出しが例外を投げても transactionLevel と transactionStack の整合
        // (transactionLevel === count(transactionStack)) を保つため、減算を finally で確定する。
        try {
            if ($isRealTransaction) {
                $this->connection->commit();
            } else {
                $this->connection->exec('RELEASE SAVEPOINT trans' . $this->transactionLevel);
            }
        } finally {
            $this->transactionLevel--;
        }
    }

    /**
     * トランザクションをロールバックする。
     * 対応する beginTransaction が PDO::beginTransaction() で開始されていれば PDO::rollBack() を発行し、
     * SAVEPOINT で開始されていれば ROLLBACK TO SAVEPOINT を発行する。
     */
    public function rollBack(): void
    {
        if ($this->transactionLevel === 0) {
            return;
        }
        $isRealTransaction = (bool) array_pop($this->transactionStack);
        // commit() と同様、ドライバ呼び出しが例外を投げてもネスト深さの整合を保つ。
        try {
            if ($isRealTransaction) {
                $this->connection->rollBack();
            } else {
                $this->connection->exec('ROLLBACK TO SAVEPOINT trans' . $this->transactionLevel);
            }
        } finally {
            $this->transactionLevel--;
        }
    }

    /**
     * クエリ用の文字列をクオートする
     *
     * @param string $string
     * @return string
     */
    public static function quote($string)
    {
        $DB = self::singleton(dsn());
        return $DB->connection->quote($string);
    }

    /**
     * Get SQL Server Version
     *
     * @return string
     */
    public function getVersion()
    {
        static $version = false;
        if ($version) {
            return $version;
        }
        $db = self::singleton(dsn());
        $version = (string) $db->query([
            'sql' => 'select version()',
            'params' => []
        ], 'one');
        return $version;
    }

    /**
     * SQL文を指定してmodeに応じたDB操作結果を返す<br>
     * 'row'    => 最初の行の連想配列を返す(array)<br>
     * 'all'    => すべての行を連想配列で返す(array)<br>
     * 'exec'   => mysql_query()の結果を返す(resource)<br>
     * 'fetch'  => fetchキャッシュを生成する(bool)<br>
     * 'one'    => 最初の行の最初のfieldを返す<br>
     * 'seq'    => insert,update,deleteされた件数を返す(int)
     *
     * @param array{
     *  sql: string,
     *  params: list<mixed>|array<string, mixed>,
     * }|string $sql
     * @param string $mode
     * @param boolean $buffered
     * @param boolean $auditLog
     * @return mixed
     *
     * @throws \ErrorException
     */
    public function query(array|string $sql, string $mode = 'row', bool $buffered = true, bool $auditLog = true): mixed
    {
        global $query_result_count;
        $query_result_count++;

        try {
            if (is_string($sql)) {
                $sql = ['sql' => $sql, 'params' => []];
            }
            if (!isset($sql['sql'])) { // @phpstan-ignore-line
                throw new \InvalidArgumentException('Invalid SQL format');
            }
            $this->hook($sql);
            $sqlString = $sql['sql'];
            $sqlParams = $sql['params'];
            $this->params = $sqlParams;

            $start_time = microtime(true);
            // PHP 8.5 で PDO::MYSQL_ATTR_* 定数が非推奨。代替の Pdo\Mysql クラス定数は
            // PHP 8.4 で追加されたため、8.1〜8.3 との互換のためバージョンで分岐する。
            // phpcs:disable PHPCompatibility.Classes.NewClasses.pdo_mysqlFound -- 三項の 8.4+ 枝のみで評価されるため 8.1〜8.3 では参照されない
            // Pdo\Mysql は PHP 8.4 追加の名前空間付きクラスで phpstan のスタブが未対応のため未知クラス扱いになる（false positive）
            $bufferedQueryAttribute = PHP_VERSION_ID >= 80400
                ? \Pdo\Mysql::ATTR_USE_BUFFERED_QUERY // @phpstan-ignore class.notFound
                : PDO::MYSQL_ATTR_USE_BUFFERED_QUERY;
            // phpcs:enable PHPCompatibility.Classes.NewClasses.pdo_mysqlFound
            if ($buffered === false) {
                $this->connection->setAttribute($bufferedQueryAttribute, false);
            } else {
                $this->connection->setAttribute($bufferedQueryAttribute, true);
            }
            $stmt = $this->connection->prepare($sqlString);
            $stmt->execute($sqlParams);

            $exe_time = sprintf('%0.6f', microtime(true) - $start_time);
            $this->saveProcessingTime($sql, $exe_time);
            $this->affectedRows = $stmt ? $stmt->rowCount() : 0;
            $this->columnCount = $stmt ? $stmt->columnCount() : 0;
            $this->statement = $stmt;

            $method = strtolower($mode) . 'Mode';
            if (method_exists($this, $method)) {
                $result = $this->{$method}($sql, $stmt); // @phpstan-ignore-line
            } else {
                $result = $this->etcMode($sql, $stmt);
            }
            return $result;
        } catch (PDOException $e) {
            if ($auditLog) {
                AcmsLogger::debug($e->getMessage(), [
                    'code' => $e->getCode(),
                    'file' => $e->getFile(),
                    'line' => $e->getLine(),
                    'sql' => SQL::dumpSQL($sql),
                ]);
            }
            if ($this->debug) {
                $code = intval($e->getCode());
                $exception = new \ErrorException($e->getMessage(), $code, E_USER_WARNING, $e->getFile(), $e->getLine(), App::getExceptionStack());
                if ($this->throwException) {
                    throw $exception;
                } else {
                    App::setExceptionStack($exception);
                }
            }
            if ($mode === 'all') {
                return [];
            } else {
                return false;
            }
        }
    }

    /**
     * プレースホルダー付きのSQL文字列を取得
     *
     * @return string
     */
    public function getQueryString(): string
    {
        if ($this->statement === null) {
            return '';
        }
        return $this->statement->queryString;
    }

    /**
     * プレースホルダーの値を取得
     *
     * @return array
     */
    public function getQueryParameter(): array
    {
        return $this->params;
    }

    /**
     * sql文を指定して1行ずつfetchされた値を返す
     * $DB->query($SQL->get(dsn()), 'fetch');<br>
     * while ( $row = $DB->fetch($q) ) {<br>
     *     $Config->addField($row['config_key'], $row['config_value']);<br>
     * }
     *
     * @deprecated パフォーマンスの問題で非推奨
     * @param array{
     *  sql: string,
     *  params: list<mixed>|array<string, mixed>,
     * } | null $sql
     * @param bool $reset
     * @return array|bool
     */
    public function fetch($sql = null, $reset = false)
    {
        if ($sql === null) {
            return false;
        }
        $this->hook($sql);
        $encodedSql = json_encode($sql, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($encodedSql === false) {
            throw new \RuntimeException('json_encode failed: ' . json_last_error_msg());
        }
        $id = hash('sha256', $encodedSql, false);
        if (!isset($this->fetch[$id])) {
            return false;
        }
        if ($reset) {
            $this->fetch[$id]->closeCursor();
            unset($this->fetch[$id]);
            return false;
        }
        $row = $this->fetch[$id]->fetch(\PDO::FETCH_ASSOC);
        if ($row === false) {
            $this->fetch[$id]->closeCursor();
            unset($this->fetch[$id]);
            return false;
        }
        return $row;
    }

    /**
     * リソースを指定して1行ずつfetchされた値を返す
     * $statement = Database::query($sql->get(dsn()), 'exec');
     * while ($row = $DB->next($statement)) {
     *     $Config->addField($row['config_key'], $row['config_value']);
     * }
     *
     * @param PDOStatement|false $statement
     * @return array|false
     */
    public function next(PDOStatement|false $statement)
    {
        if (!$statement) {
            return false;
        }
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            $statement->closeCursor();
            return false;
        }
        return $row;
    }

    /**
     * query()の結果を返す
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return mixed
     */
    protected function execMode($sql, $response)
    {
        return $response;
    }

    /**
     * insert,update,deleteされた件数を返す
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return int
     */
    protected function seqMode($sql, $response)
    {
        if (is_bool($response)) {
            return $this->connection->lastInsertId();
        } else {
            $one = $this->query([
                'sql' => 'select last_insert_id()',
                'params' => [],
            ], 'one');
            $response->closeCursor();

            return intval($one);
        }
    }

    /**
     * すべての行を連想配列で返す
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return array
     */
    protected function allMode($sql, $response)
    {
        $all = $response->fetchAll(PDO::FETCH_ASSOC);
        $response->closeCursor();

        return $all;
    }

    /**
     * 最初の行を配列で返す
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return array
     */
    protected function listMode($sql, $response)
    {
        $list = [];
        while ($row = $response->fetch(\PDO::FETCH_ASSOC)) {
            $list[] = array_shift($row);
        }
        $response->closeCursor();

        return $list;
    }

    /**
     * 最初の行の最初のcolumnの値を返す
     *
     * 該当行が無い場合は false を返す（「該当なし」と「空文字の値」を区別するため）。
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return mixed
     */
    protected function oneMode($sql, $response)
    {
        if (!$row = $response->fetch(\PDO::FETCH_ASSOC)) {
            return false;
        }
        $one = array_shift($row);
        $response->closeCursor();

        return $one;
    }

    /**
     * 最初の行の連想配列を返す
     *
     * @param array{
     *   sql: string,
     *   params: list<mixed>|array<string, mixed>,
     * } $sql
     * @param mixed $response
     * @return array
     */
    protected function rowMode($sql, $response)
    {
        $row = $response->fetch(\PDO::FETCH_ASSOC);
        $response->closeCursor();

        return $row;
    }

    /**
     * Returns metadata for a column in a result set
     *
     * @param int $column
     *
     * @return array
     */
    public function columnMeta($column)
    {
        return $this->statement->getColumnMeta($column);
    }
}
