<?php

declare(strict_types=1);

/*
 * Copyright (c) 2017-2023 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\OAuth\Server;

use DateTimeImmutable;
use DateTimeZone;
use PDO;

class PdoStorage implements StorageInterface
{
    private PDO $db;
    private string $tablePrefix;

    public function __construct(PDO $db, string $tablePrefix = '')
    {
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        if ('sqlite' === $db->getAttribute(PDO::ATTR_DRIVER_NAME)) {
            $db->query('PRAGMA foreign_keys = ON');
        }

        $this->db = $db;
        $this->tablePrefix = $tablePrefix;
    }

    public function getAuthorization(string $authKey): ?Authorization
    {
        $stmt = $this->db->prepare(
            <<< SQL
                SELECT
                    auth_key,
                    user_id,
                    client_id,
                    scope,
                    authorized_at,
                    expires_at,
                    last_used
                FROM
                    {$this->tablePrefix}authorizations
                WHERE
                    auth_key = :auth_key
                SQL
        );

        $stmt->bindValue(':auth_key', $authKey, PDO::PARAM_STR);
        $stmt->execute();

        /** @var array{auth_key:string,user_id:string,client_id:string,scope:string,authorized_at:string,expires_at:string,last_used:string}|false */
        $queryResult = $stmt->fetch(PDO::FETCH_ASSOC);
        if (false === $queryResult) {
            return null;
        }

        return new Authorization(
            $queryResult['auth_key'],
            $queryResult['user_id'],
            $queryResult['client_id'],
            new Scope($queryResult['scope']),
            new DateTimeImmutable($queryResult['authorized_at']),
            new DateTimeImmutable($queryResult['expires_at']),
            new DateTimeImmutable($queryResult['last_used'])
        );
    }

    public function storeAuthorization(string $userId, string $clientId, Scope $scope, string $authKey, DateTimeImmutable $authorizedAt, DateTimeImmutable $expiresAt): void
    {
        // the "authorizations" table has the UNIQUE constraint on the
        // "auth_key" column, thus preventing multiple entries with the same
        // "auth_key" to make absolutely sure "auth_keys" cannot be replayed
        $stmt = $this->db->prepare(
            <<< SQL
                INSERT INTO {$this->tablePrefix}authorizations (
                    auth_key,
                    user_id,
                    client_id,
                    scope,
                    authorized_at,
                    expires_at,
                    last_used
                )
                VALUES(
                    :auth_key,
                    :user_id,
                    :client_id,
                    :scope,
                    :authorized_at,
                    :expires_at,
                    :last_used
                )
                SQL
        );

        $stmt->bindValue(':auth_key', $authKey, PDO::PARAM_STR);
        $stmt->bindValue(':user_id', $userId, PDO::PARAM_STR);
        $stmt->bindValue(':client_id', $clientId, PDO::PARAM_STR);
        $stmt->bindValue(':scope', (string) $scope, PDO::PARAM_STR);
        $stmt->bindValue(':authorized_at', $authorizedAt->setTimeZone(new DateTimeZone('UTC'))->format(DateTimeImmutable::ATOM), PDO::PARAM_STR);
        $stmt->bindValue(':expires_at', $expiresAt->setTimeZone(new DateTimeZone('UTC'))->format(DateTimeImmutable::ATOM), PDO::PARAM_STR);
        // when adding a new authorization, we use "authorized_at" for
        // "last_used"...
        $stmt->bindValue(':last_used', $authorizedAt->setTimeZone(new DateTimeZone('UTC'))->format(DateTimeImmutable::ATOM), PDO::PARAM_STR);
        $stmt->execute();
    }

    /**
     * @return array<Authorization>
     */
    public function getAuthorizations(string $userId): array
    {
        $authorizationList = [];
        $stmt = $this->db->prepare(
            <<< SQL
                SELECT
                    auth_key,
                    user_id,
                    client_id,
                    scope,
                    authorized_at,
                    expires_at,
                    last_used
                FROM
                    {$this->tablePrefix}authorizations
                WHERE
                    user_id = :user_id
                ORDER BY
                    last_used
                DESC
                SQL
        );

        $stmt->bindValue(':user_id', $userId, PDO::PARAM_STR);
        $stmt->execute();

        $queryResultSet = $stmt->fetchAll(PDO::FETCH_ASSOC);

        /** @var array{auth_key:string,user_id:string,client_id:string,scope:string,authorized_at:string,expires_at:string,last_used:string} $queryResult */
        foreach ($queryResultSet as $queryResult) {
            $authorizationList[] = new Authorization(
                $queryResult['auth_key'],
                $queryResult['user_id'],
                $queryResult['client_id'],
                new Scope($queryResult['scope']),
                new DateTimeImmutable($queryResult['authorized_at']),
                new DateTimeImmutable($queryResult['expires_at']),
                new DateTimeImmutable($queryResult['last_used'])
            );
        }

        return $authorizationList;
    }

    public function deleteAuthorization(string $authKey): void
    {
        // this will also cascade into the refresh_token_log table in case
        // there were any stored refresh_token_id entries for this "auth_key"
        $stmt = $this->db->prepare(
            <<< SQL
                DELETE FROM
                    {$this->tablePrefix}authorizations
                WHERE
                    auth_key = :auth_key
                SQL
        );

        $stmt->bindValue(':auth_key', $authKey, PDO::PARAM_STR);
        $stmt->execute();
    }

    public function isRefreshTokenReplay(string $refreshTokenId): bool
    {
        $stmt = $this->db->prepare(
            <<< SQL
                SELECT
                    COUNT(refresh_token_id)
                FROM
                    {$this->tablePrefix}refresh_token_log
                WHERE
                    refresh_token_id = :refresh_token_id
                SQL
        );

        $stmt->bindValue(':refresh_token_id', $refreshTokenId, PDO::PARAM_STR);
        $stmt->execute();

        return 1 === (int) $stmt->fetchColumn(0);
    }

    public function updateAuthorization(string $authKey, DateTimeImmutable $lastUsed): void
    {
        $stmt = $this->db->prepare(
            <<< SQL
                UPDATE
                    {$this->tablePrefix}authorizations
                SET
                    last_used = :last_used
                WHERE
                    auth_key = :auth_key
                SQL
        );

        $stmt->bindValue(':auth_key', $authKey, PDO::PARAM_STR);
        $stmt->bindValue(':last_used', $lastUsed->setTimeZone(new DateTimeZone('UTC'))->format(DateTimeImmutable::ATOM), PDO::PARAM_STR);
        $stmt->execute();
    }

    public function logRefreshToken(string $authKey, string $refreshTokenId): void
    {
        $stmt = $this->db->prepare(
            <<< SQL
                INSERT INTO {$this->tablePrefix}refresh_token_log (
                    auth_key,
                    refresh_token_id
                )
                VALUES(
                    :auth_key,
                    :refresh_token_id
                )
                SQL
        );

        $stmt->bindValue(':auth_key', $authKey, PDO::PARAM_STR);
        $stmt->bindValue(':refresh_token_id', $refreshTokenId, PDO::PARAM_STR);
        $stmt->execute();
    }

    public function deleteExpiredAuthorizations(string $userId, DateTimeImmutable $dateTime): void
    {
        $stmt = $this->db->prepare(
            <<< SQL
                DELETE FROM
                    {$this->tablePrefix}authorizations
                WHERE
                    user_id = :user_id
                AND
                    expires_at < :expires_at
                SQL
        );

        $stmt->bindValue(':user_id', $userId, PDO::PARAM_STR);
        $stmt->bindValue(':expires_at', $dateTime->setTimeZone(new DateTimeZone('UTC'))->format(DateTimeImmutable::ATOM), PDO::PARAM_STR);

        $stmt->execute();
    }

    public function init(): void
    {
        $queryList = [
            <<< SQL
                CREATE TABLE IF NOT EXISTS {$this->tablePrefix}authorizations (
                    auth_key VARCHAR(255) NOT NULL PRIMARY KEY,
                    user_id VARCHAR(255) NOT NULL,
                    client_id VARCHAR(255) NOT NULL,
                    scope VARCHAR(255) NOT NULL,
                    authorized_at VARCHAR(255) NOT NULL,
                    expires_at VARCHAR(255) NOT NULL,
                    last_used VARCHAR(255) NOT NULL,
                    UNIQUE(auth_key)
                )
                SQL,
            <<< SQL
                CREATE TABLE IF NOT EXISTS {$this->tablePrefix}refresh_token_log (
                    auth_key VARCHAR(255) NOT NULL REFERENCES {$this->tablePrefix}authorizations(auth_key) ON DELETE CASCADE,
                    refresh_token_id VARCHAR(255) NOT NULL,
                    UNIQUE(auth_key, refresh_token_id)
                )
                SQL,
        ];

        foreach ($queryList as $query) {
            $this->db->query($query);
        }
    }
}
