<?php

namespace Acms\Services\Session;

use SessionHandlerInterface;
use DB;
use SQL;

class DatabaseHandler implements SessionHandlerInterface
{
    /**
     * アドバイザリロックのタイムアウト秒数。
     * MySQL の innodb_lock_wait_timeout の既定値に合わせている。
     */
    private const LOCK_TIMEOUT = 50;

    /**
     * 現在取得しているアドバイザリロック名（未取得時は null）。
     *
     * @var string|null
     */
    private ?string $lockName = null;

    /**
     * @inheritDoc
     */
    public function open($savePath, $sessionName): bool
    {
        return true;
    }

    /**
     * @inheritDoc
     */
    public function close(): bool
    {
        $this->releaseLock();
        return true;
    }

    /**
     * @inheritDoc
     */
    #[\ReturnTypeWillChange]
    public function read($id)
    {
        if (!$this->isValidSessionId($id)) {
            return '';
        }
        // PHP標準のファイルセッションは flock により同一セッションの同時リクエストを直列化する。
        // DBセッションにはその仕組みがないため、アドバイザリロックで同等の直列化を行い、
        // セッション依存の二重送信対策（formUniqueToken）が競合ですり抜けるのを防ぐ。
        $this->acquireLock($id);

        $sql = SQL::newSelect('session_php');
        $sql->addSelect('session_data');
        $sql->addWhereOpr('session_id', $id);
        $data = DB::query($sql->get(dsn()), 'one');

        return $data ? $data : '';
    }

    /**
     * @inheritDoc
     */
    public function write($id, $data): bool
    {
        if (!$this->isValidSessionId($id)) {
            return false;
        }
        $sql = SQL::newSelect('session_php');
        $sql->addSelect('session_id');
        $sql->addWhereOpr('session_id', $id);
        $expire = REQUEST_TIME + (int)ini_get('session.gc_maxlifetime');

        if (DB::query($sql->get(dsn()), 'one')) {
            $sql = SQL::newUpdate('session_php');
            $sql->addUpdate('session_expire', $expire);
            $sql->addUpdate('session_data', $data);
            $sql->addWhereOpr('session_id', $id);
        } else {
            $sql = SQL::newInsert('session_php');
            $sql->addInsert('session_id', $id);
            $sql->addInsert('session_expire', $expire);
            $sql->addInsert('session_data', $data);
        }
        DB::query($sql->get(dsn()), 'exec');

        return true; // 必ず true を返すことでセッション保存を失敗と誤認しない
    }

    /**
     * @inheritDoc
     */
    public function destroy($id): bool
    {
        if (!$this->isValidSessionId($id)) {
            return false;
        }
        $sql = SQL::newDelete('session_php');
        $sql->addWhereOpr('session_id', $id);
        DB::query($sql->get(dsn()), 'exec');

        $this->releaseLock();

        return true;
    }

    /**
     * @inheritDoc
     */
    #[\ReturnTypeWillChange]
    public function gc($maxlifetime)
    {
        $sql = SQL::newDelete('session_php');
        $sql->addWhereOpr('session_expire', REQUEST_TIME, '<');
        DB::query($sql->get(dsn()), 'exec');

        return  DB::affected_rows();
    }

    /**
     * アドバイザリロック（MySQL GET_LOCK）のロック名を生成する。
     *
     * GET_LOCK はMySQLサーバ全体で共有され、ロック名は最大64文字という制約がある。
     * そのため次の性質を満たすよう、名前空間とセッションIDを連結してハッシュ化する。
     * - 64文字以内（'acms_sess_' 10文字 + sha1 40文字 = 50文字）
     * - 安全な文字種のみ（[a-z0-9_]）
     * - 同一MySQLサーバを複数インストールで共有しても衝突しない（名前空間で分離）
     *
     * @param string $sessionId セッションID
     * @param string $namespace インストールを識別する名前空間（DB名・テーブルプレフィックス等）
     * @return string
     */
    public static function generateLockName(string $sessionId, string $namespace): string
    {
        return 'acms_sess_' . sha1($namespace . '|' . $sessionId);
    }

    /**
     * このセッションIDに対するアドバイザリロックを取得する。
     *
     * ロックを取得できなくてもサイトは停止させない（フェイルオープン）。
     *
     * @param string $sessionId
     * @return void
     */
    private function acquireLock(string $sessionId): void
    {
        $name = self::generateLockName($sessionId, $this->getLockNamespace());
        if ($this->lockName === $name) {
            return; // 取得済み（多重取得を避ける）
        }
        if ($this->lockName !== null) {
            $this->releaseLock(); // 別IDのロックを保持中（session_regenerate_id 等）はいったん解放
        }
        try {
            $acquired = DB::query(
                ['sql' => 'SELECT GET_LOCK(?, ?)', 'params' => [$name, self::LOCK_TIMEOUT]],
                'one'
            );
            if ((string)$acquired === '1') {
                $this->lockName = $name;
            }
            // 0（タイムアウト）/ null（エラー）の場合はロックなしで処理を継続する
        } catch (\Throwable) {
            // ロック取得の失敗は可用性に影響させない
        }
    }

    /**
     * 取得中のアドバイザリロックを解放する。
     *
     * @return void
     */
    private function releaseLock(): void
    {
        if ($this->lockName === null) {
            return;
        }
        $name = $this->lockName;
        $this->lockName = null;
        try {
            DB::query(['sql' => 'SELECT RELEASE_LOCK(?)', 'params' => [$name]], 'one');
        } catch (\Throwable) {
            // 接続終了時に自動解放されるため、解放失敗は無視する
        }
    }

    /**
     * ロック名の名前空間（インストール識別子）を取得する。
     *
     * @return string
     */
    private function getLockNamespace(): string
    {
        $dbName = defined('DB_NAME') ? constant('DB_NAME') : '';
        $prefix = defined('DB_PREFIX') ? constant('DB_PREFIX') : '';

        return $dbName . '|' . $prefix;
    }

    /**
     * セッションIDとして安全かどうかを検証する
     */
    private function isValidSessionId(string $id): bool
    {
        return preg_match('/^[a-zA-Z0-9,-]{16,128}$/', $id) === 1;
    }
}
