<?php

namespace Acms\Services\Preview;

use Acms\Services\Facades\Application;
use Acms\Services\Preview\Contracts\Base;
use Session;
use App;
use DB;
use SQL;
use RuntimeException;

class Engine implements Base
{
    /**
     * プレビュー共有ページのADMIN
     */
    protected const PREVIEW_SHARE_ADMIN = 'preview_share';

    /**
     * プレビュー共有ページのテンプレート
     */
    protected const PREVIEW_SHARE_TPL = 'admin/preview/share.html';

    /**
     * PHPセッションラッパー
     *
     * @var \Session
     */
    protected $session;

    /**
     * @var \Field
     */
    protected $get;

    /**
     * プレビュー共有URLの有効時間
     *
     * @var int
     */
    protected $lifetime;

    /**
     * 共有URL
     *
     * @var string
     */
    protected $shareUrl;

    /**
     * 偽装UAをセッションに保存する時のキー名
     *
     * @var string
     */
    protected $previewFakeUaKeyName = 'preview_fake_ua';

    /**
     *  偽装UAをセッションを確認するためのトークンのキー名
     *
     * @var string
     */
    protected $previewFakeUaTokenKeyName = 'preview_fake_ua_token';

    /**
     * プレビュー共有するための認証トークンのキー名
     *
     * @var string
     */
    protected $previewShareUrlTokenKeyName = 'preview-token';

    /**
     * プレビューモードか判定するために使うURLクエリパラメーター名
     *
     * @var string
     */
    protected $previewModeQueryParameter = 'acms-preview-mode';

    /**
     * isValidPreviewSharingUrl() の結果のメモ化（1 リクエスト中は再計算しない）
     *
     * Why not 型付きプロパティ（PHP 7.4+）にしない: 302x 以外の系列（301x は PHP 7.3、
     * 300x は PHP 7.2.5 以上をサポート）にも同種の修正をバックポートする前提があり、
     * 型付きプロパティを使うとそれらの系列でパースエラーになる。
     *
     * @var bool|null
     */
    private $validPreviewSharingUrl = null;

    /**
     * isValidPreviewSharingUrl() 内で確定した「リクエストURLそのものが共有URLと一致したか」
     *
     * @var bool
     */
    private $sharedUrlItself = false;

    /**
     * Engine constructor.
     *
     * Why not グローバルな App::getInstance()->getGetParameter() 決定的固定にする: テストで
     * `preview-token` の有無を出し分けたいとき、$_GET を書き換えずに済む注入経路が必要。
     * 実運用（PreviewServiceProvider）は第 3 引数を渡さないため、既定の挙動は変わらない。
     *
     * @param int $lifetime
     * @param string $shareUrl
     * @param \Field|null $getParameter
     */
    public function __construct($lifetime, $shareUrl, ?\Field $getParameter = null)
    {
        if ($getParameter !== null) {
            $this->get = $getParameter;
        } else {
            $app = App::getInstance();
            assert($app instanceof \Acms\Application);
            $this->get = $app->getGetParameter();
        }
        $this->lifetime = $lifetime;
        $this->shareUrl = $shareUrl;
    }

    /**
     * プレビューモード中か判定
     *
     * @return bool
     */
    public function isPreviewMode()
    {
        if ($session = $this->getSession()) {
            $fakeUaToken = $session->get($this->previewFakeUaTokenKeyName, false);
            if ($fakeUaToken && $fakeUaToken === $this->get->get($this->previewModeQueryParameter)) {
                setConfig('x_frame_options', 'off');
                return true;
            }
        }
        if ($this->isValidPreviewSharingUrl()) {
            // Why not sharedUrlItself を見ずに常に off にしない: in-preview セッションが
            // 生きている間は「共有URLそのもの」でない任意の URL でも isValidPreviewSharingUrl()
            // が true を返すため、そこまで x-frame-options を外すとクリックジャッキング対策が
            // 任意ページで無効化される。共有URL本体を開いたときだけ off にする。
            if ($this->sharedUrlItself) {
                setConfig('x_frame_options', 'off');
            }
            return true;
        }
        return false;
    }

    /**
     * 偽装ユーザーエージェントの取得
     *
     * @return string|false
     */
    public function getFakeUserAgent()
    {
        if ($this->isPreviewMode()) {
            $session = $this->getSession();
            return $session->get('preview_fake_ua', false);
        }
        return false;
    }

    /**
     * プレビュー共有モードになれるか判定
     *
     * Why not 呼び出しごとに再計算する: isPreviewMode() 経由で 1 リクエスト中に何度も呼ばれ、
     * DB 参照とセッション書き込みを毎回伴う。加えて isSharedUrlItself() が参照する
     * sharedUrlItself は本メソッドの実行中にしか確定しないため、再計算するたびに
     * 上書きされてしまうと同一リクエスト内でも値が安定しない。
     *
     * @return bool
     */
    public function isValidPreviewSharingUrl()
    {
        if ($this->validPreviewSharingUrl !== null) {
            return $this->validPreviewSharingUrl;
        }

        $previewToken = $this->get->get($this->previewShareUrlTokenKeyName);
        if (empty($previewToken)) {
            return $this->validPreviewSharingUrl = false;
        }
        $SQL = SQL::newSelect('preview_share');
        $SQL->addSelect('preview_share_uri');
        $SQL->addSelect('preview_share_expire');
        $SQL->addWhereOpr('preview_share_expire', date('Y-m-d H:i:s', REQUEST_TIME), '>=');
        $SQL->addWhereOpr('preview_share_token', $previewToken);
        $row = DB::query($SQL->get(dsn()), 'row');
        if (!is_array($row)) {
            return $this->validPreviewSharingUrl = false;
        }
        // Why not 発行時（getShareUrl()）のチェックだけで済ませない: 登録後に許可基準が変わった
        // 場合や、過去に緩い基準で登録された URL が url_preview_expire（既定 48 時間）の間
        // 使われ続けてしまうため、取得時にも同じ基準で再検証する。
        $url = $row['preview_share_uri'];
        if ($this->shareabilityError($url) !== null) {
            return $this->validPreviewSharingUrl = false;
        }

        $this->sharedUrlItself = $this->shareUrlFormat($this->currentRequestUrl()) === $url;
        $isPreview = $this->sharedUrlItself || $this->hasLivingPreviewSession();
        if ($isPreview) {
            $tokenExpire = (int)strtotime($row['preview_share_expire']);
            $session = Session::handle();
            // Why not 単純に REQUEST_TIME + 15 分にしない: スライディング延長がトークン自体の有効期限を
            // 超えると、期限切れトークンの共有URLでもセッションだけでプレビュー権限が残ってしまう。
            $session->set('in-preview', min(REQUEST_TIME + (60 * 15), $tokenExpire));
            $session->save();
        }

        return $this->validPreviewSharingUrl = $isPreview;
    }

    /**
     * なりすまし権限（SUID）を与えてよいリクエストか
     *
     * Why not URL 一致だけを条件にしない: REQUEST_URL はメソッドに依らず同一なので、
     * URL 一致のみを見ると POST でも成立してしまう。共有ページで採取した formToken を
     * 添えて POST すれば、任意の管理系ハンドラがルート主管理者権限で実行できてしまう
     * （CSRF トークンは攻撃者自身のセッションで発行されたものなので障壁にならない）。
     * 閲覧に必要な GET だけに絞る。
     *
     * @return bool
     */
    public function isSharedUrlItself(): bool
    {
        // Why not 未設定時のデフォルトを 'GET' にしない: CLI（静的書き出し等）では
        // $_SERVER['REQUEST_METHOD'] が定義されないことがあり、権限付与の判定である以上
        // 未設定は許可側ではなく拒否側に倒す。
        if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
            return false;
        }
        if (defined('ACMS_POST') && ACMS_POST) {
            return false;
        }

        return $this->isValidPreviewSharingUrl() && $this->sharedUrlItself;
    }

    /**
     * 共有してよい URL か
     *
     * @param string $url
     * @return bool
     */
    public function isShareableUrl(string $url): bool
    {
        return ShareUrlPolicy::isShareable($this->shareUrlFormat($url));
    }

    /**
     * 共有できない理由（利用者向けメッセージ）を返す。共有してよい URL なら null
     *
     * Why not 経路ごとに isSafeUrl() と isShareableUrl() を並べて書かない: 発行（getShareUrl）・
     * プレビュー成立判定（isValidPreviewSharingUrl）・iframe 先の取得（getSharePreviewUrl）・
     * POST ハンドラのバリデーションが同じ 2 条件を個別に持つと、片方だけ基準が変わったときに
     * 経路ごとの判定がドリフトする。理由を文字列で返すことで、判定を 1 箇所に集約したまま
     * 発行時だけは「なぜ共有できないか」を利用者に伝えられる。
     *
     * @param string $url
     * @return string|null
     */
    public function shareabilityError(string $url): ?string
    {
        if ($url === '') {
            return gettext('共有するURLが指定されていません。');
        }
        $managedDomainRegistry = Application::make('url.managed-domain');
        assert($managedDomainRegistry instanceof \Acms\Services\Url\ManagedDomainRegistry);
        if (!$managedDomainRegistry->isSafeUrl($url)) {
            return gettext('このサイト以外のURLは共有できません。');
        }
        if (!$this->isShareableUrl($url)) {
            return gettext('管理画面や会員向けページなど、公開されていないURLは共有できません。');
        }

        return null;
    }

    /**
     * 生存中のプレビュー共有セッションがあるか
     *
     * Why not 無条件に Session::handle() を呼ばない: セッション未開始のクライアントに対しても
     * 呼ぶと、有効なトークンを知っているだけで session_start() と Set-Cookie の送出（DB セッション
     * 使用時は GET_LOCK を伴うトランザクション直列化）を強制できてしまう。既にセッション Cookie を
     * 持っている場合だけ実体を見に行く。
     *
     * @return bool
     */
    private function hasLivingPreviewSession(): bool
    {
        if (!isset($_COOKIE[SESSION_NAME]) || $_COOKIE[SESSION_NAME] === '') {
            return false;
        }
        // Why not intval() の結果だけを比較する: Session::get() は未設定を false で返し、
        // intval(false) は 0 になるため「REQUEST_TIME が 0 より大きい」という暗黙の前提に
        // 判定を委ねる形になる。センチネルは数値に潰す前に明示的に弾く。
        $expire = Session::handle()->get('in-preview');
        if ($expire === false) {
            return false;
        }
        return intval($expire) >= REQUEST_TIME;
    }


    /**
     * プレビュー共有URLの取得
     *
     * @param string $url
     * @return string
     */
    public function getShareUrl($url, $lifetime = false)
    {
        $error = $this->shareabilityError($url);
        if ($error !== null) {
            throw new RuntimeException($error);
        }
        $token = uniqueString() . uniqueString();
        if (empty($lifetime)) {
            $lifetime = $this->lifetime;
        }
        $SQL = SQL::newInsert('preview_share');
        $SQL->addInsert('preview_share_uri', $this->shareUrlFormat($url));
        $SQL->addInsert('preview_share_expire', date('Y-m-d H:i:s', REQUEST_TIME + $lifetime));
        $SQL->addInsert('preview_share_token', $token);
        DB::query($SQL->get(dsn()), 'exec');

        return $this->shareUrl . "?token={$token}";
    }

    /**
     * 共有URLで実際に表示するiFrameのURL
     *
     * Why not isValidPreviewSharingUrl() と同じ再検証を省く: ここも DB の保存値をそのまま
     * iframe 先として返す読み出し経路であり、旧基準で保存された行（後から拒否対象に加わった
     * URL 等）があると素の保存値を検証なしで表示してしまう。読み出し経路ごとに基準が
     * ずれるとドリフト要因になるため、同じ shareabilityError() を通す。
     *
     * @return string
     */
    public function getSharePreviewUrl()
    {
        $token = $this->get->get('token');
        if (empty($token)) {
            throw new RuntimeException('Empty token');
        }
        $SQL = SQL::newSelect('preview_share');
        $SQL->addSelect('preview_share_uri');
        $SQL->addWhereOpr('preview_share_expire', date('Y-m-d H:i:s', REQUEST_TIME), '>=');
        $SQL->addWhereOpr('preview_share_token', $token);

        $url = DB::query($SQL->get(dsn()), 'one');
        if (is_string($url) && $this->shareabilityError($url) === null) {
            $query = parse_url($url, PHP_URL_QUERY);
            if ($query !== null && $query !== '') {
                $url .= "&preview-token={$token}";
            } else {
                $url .= "?preview-token={$token}";
            }
            return $url;
        }
        throw new RuntimeException('Failed get preview url.');
    }

    /**
     * 期限切れの共有URLを削除
     */
    public function expiredShareUrl()
    {
        $SQL = SQL::newDelete('preview_share');
        $SQL->addWhereOpr('preview_share_expire', date('Y-m-d H:i:s', REQUEST_TIME - 1), '<');
        DB::query($SQL->get(dsn()), 'exec');
    }

    /**
     * プレビューモードを開始
     *
     * @param string $fakeUserAgent
     * @param string $token
     * @return void
     */
    public function startPreviewMode($fakeUserAgent, $token)
    {
        if ($session = $this->getSession()) {
            if ($fakeUserAgent && $token) {
                $session->set($this->previewFakeUaKeyName, $fakeUserAgent);
                $session->set($this->previewFakeUaTokenKeyName, $token);
            } else {
                $session->delete($this->previewFakeUaKeyName);
                $session->delete($this->previewFakeUaTokenKeyName);
            }
            $session->save();
        }
    }

    /**
     * プレビューモードを終了
     *
     * @return void
     */
    public function endPreviewMode()
    {
        if ($session = $this->getSession()) {
            $session->delete($this->previewFakeUaKeyName);
            $session->delete($this->previewFakeUaTokenKeyName);
            $session->save();
        }
    }

    /**
     * 共有URLから余分な文字列を削除
     *
     * Why not 正規表現による文字列置換のままにしない: `/(\?|&)key=val/` の除去は、除去対象が
     * クエリの唯一のパラメータだった場合に `?` 自体も一緒に消してしまい、
     * `?acms-preview-mode=T&foo=bar` が `&foo=bar`（`?` を欠いたまま path に連結される）という
     * 壊れた形になる。parse_url / parse_str で一度分解してから再構成することで、
     * 除去後にクエリが空なら `?` を付けない・複数回通しても同じ結果になる（冪等）ことを保証する。
     *
     * @param string $url
     * @return string
     */
    protected function shareUrlFormat($url): string
    {
        $url = htmlspecialchars_decode($url, ENT_QUOTES);
        $parts = parse_url($url);
        if ($parts === false) {
            return $url;
        }

        $query = $parts['query'] ?? '';
        if ($query !== '') {
            parse_str($query, $params);
            foreach (['acms-preview-mode', 'timestamp', 'preview-token'] as $key) {
                unset($params[$key]);
            }
            $query = $params === [] ? '' : http_build_query($params, '', '&', PHP_QUERY_RFC3986);
        }

        $result = '';
        if (isset($parts['scheme'])) {
            $result .= $parts['scheme'] . '://';
        }
        if (isset($parts['host'])) {
            $result .= $parts['host'];
        }
        if (isset($parts['port'])) {
            $result .= ':' . $parts['port'];
        }
        $result .= $parts['path'] ?? '';
        if ($query !== '') {
            $result .= '?' . $query;
        }

        return $result;
    }

    /**
     * 現在のリクエストURL
     *
     * Why not REQUEST_URL を直接参照する: グローバル定数はテストから差し替えられない
     * （プロセス起動時の bootstrap で一度だけ定義され、以降 CLI/テストプロセス内で再定義できない）。
     * protected にして無名サブクラスからオーバーライドできる形にすることで、
     * 「有効な共有トークン・URL 一致がある状態」を統合テストで作れるようにする。
     *
     * @return string
     */
    protected function currentRequestUrl(): string
    {
        return REQUEST_URL;
    }

    /**
     * @return bool|mixed
     */
    private function getSession()
    {
        if ($this->session) {
            return $this->session;
        }
        if (sessionWithContribution() || ACMS_POST === 'Preview_Mode' || isset($_GET['acms-preview-mode'])) {
            $this->session = Session::handle();
            return $this->session;
        }
        return false;
    }

    /**
     * @param string $admin
     * @return bool
     */
    public function isPreviewShareAdmin(string $admin): bool
    {
        return $admin === self::PREVIEW_SHARE_ADMIN;
    }

    /**
     * @return string
     */
    public function getPreviewShareTpl(): string
    {
        return self::PREVIEW_SHARE_TPL;
    }
}
