<?php

namespace Acms\Services\Preview;

/**
 * プレビュー共有URLとして登録・使用してよいURLかを判定する。
 *
 * DB・セッション・リクエスト変数には触れず、判定対象の URL を引数で受け取る。
 * DB アクセスやセッション操作は呼び出し側（Engine）が担う。
 *
 * 判定の基準は「そのURLが実際にどの URL コンテキストへ解決されるか」であり、解決には
 * a-blog cms 本体のルーティングパーサ parseAcmsPath() をそのまま使う。
 * Application も `parseAcmsPath(rawurldecode(REQUEST_PATH))` の結果を $Q に載せているため、
 * ここでの判定と実際のリクエスト解決は一致する。
 *
 * Why not セグメント名の文字列一致でパスを判定しない: 予約セグメントの多く（login / signin /
 * withdrawal 等）はパス末尾に来たときだけそのページに解決される規則なので、パス全体に対する
 * 部分一致で判定すると `login` というコードのカテゴリー配下にある公開エントリー
 * （/login/how-to.html）まで拒否してしまう。逆に、値が複数セグメントに渡る予約語や
 * 大文字小文字・エンコードの扱いを自前で再現すると、ルーティング側の規則が変わったときに
 * 判定側が追随できず「実際には管理画面に解決されるのに共有できてしまう」方向のズレも生じ得る。
 * 解析をパーサに委ねればこの二重管理そのものが無くなる。
 *
 * 拒否対象は「処理モードや対象を切り替えるコンテキスト」「非公開・会員セルフサービス領域」に限定する。
 * キーワード検索・タグ・並び替え・表示件数・期間指定・ユーザー別一覧・エイリアス別一覧のような
 * 公開一覧の絞り込み条件は、匿名でも閲覧できる公開ページであり権限昇格や処理モードの変更に
 * 関わらないため拒否しない。
 *
 * クエリ文字列は一律拒否ではなく、危険なキー（reservedQueryKeys()）だけを拒否する。
 * Why not クエリを一律拒否し続けない: utm_source やキーワード検索のようなクエリを含む公開ページの
 * URL が共有できず実用に耐えない。危険なのは「特定のキーが $Get->get() 経由で処理モードや
 * 権限の見え方を切り替える」ことであり、クエリの存在そのものではない。
 */
class ShareUrlPolicy
{
    /**
     * 共有してよい URL か（絶対 URL を渡す。プレビュー用クエリは呼び出し側で除去済みであること）
     *
     * @param string $url
     * @param string|null $dirOffset CMS のインストール位置（DIR_OFFSET 相当）。null なら定数から取る。
     *                    Why not 常に DIR_OFFSET を直接読む: DIR_OFFSET は定義済み定数でプロセス内から
     *                    差し替えられないため、サブディレクトリ設置時の挙動を検証する手段が無くなる。
     *                    引数で受け取れるようにして、この公開メソッド 1 つだけで判定の全経路を
     *                    テストできるようにしている（内部ヘルパを公開する代わりの境界シーム）。
     * @return bool
     */
    public static function isShareable(string $url, ?string $dirOffset = null): bool
    {
        $parsed = parse_url($url);
        if ($parsed === false) {
            return false;
        }
        // ホストが自サービスのドメインかどうかは呼び出し側の Common::isSafeUrl() が担うため、
        // ここではスキームだけを見る（同じ判定を二重に持つと基準がずれた際に片方だけ更新され漏れる）。
        $scheme = $parsed['scheme'] ?? '';
        if (!in_array($scheme, ['http', 'https'], true)) {
            return false;
        }

        if ($dirOffset === null) {
            $dirOffset = defined('DIR_OFFSET') ? DIR_OFFSET : '';
        }
        $path = self::stripDirOffset($parsed['path'] ?? '', $dirOffset);
        if ($path === '') {
            $path = '/';
        }

        // Why not デコードせずパーサへ渡す: Application は rawurldecode(REQUEST_PATH) を 1 回だけ
        // 適用してから parseAcmsPath() に渡すため、判定側も同じ回数・同じ関数でデコードしないと、
        // パーセントエンコードで予約セグメントを隠したまま「未知のセグメント」として通過させて
        // しまう（%61dmin → admin のような迂回）。
        $decodedPath = rawurldecode($path);
        // Why not 多重デコードを許す: デコード後にもう一度デコードして変化する入力は、今のルーティングが
        // 1 回しかデコードしないという前提が将来変わった場合に判定が追随できなくなる。安全側に倒し、
        // そのような入力は判定不能として拒否する。
        if (rawurldecode($decodedPath) !== $decodedPath) {
            return false;
        }

        // Why not フラグメント（$parsed['fragment']）も判定材料にする: フラグメントはブラウザが
        // サーバに送らないため、Engine::shareUrlFormat() が URL を再構成する時点（保存時・照合時の
        // 両方）で既に失われている。判定に加えても実際の攻撃面は減らず、「#foo 付きの公開ページを
        // 共有できない」という制約をユーザーに課すだけになる。
        if (!self::isShareableQuery($parsed['query'] ?? '')) {
            return false;
        }

        return self::isShareablePath($decodedPath);
    }

    /**
     * 危険なクエリキーを含まないか
     *
     * @param string $query URL のクエリ部分（'' なら無し）。値ではなくキー名だけを見る。
     * @return bool
     */
    private static function isShareableQuery(string $query): bool
    {
        if ($query === '') {
            return true;
        }
        parse_str($query, $queryParams);
        foreach (array_keys($queryParams) as $queryKey) {
            foreach (self::reservedQueryKeys() as $reservedQueryKey) {
                // Why not in_array($queryKey, self::reservedQueryKeys(), true) にしない: PHP の $_GET は
                // クエリキーの大文字小文字を区別するため ?LAYOUT=edit は main.php の
                // $Get->get('layout') には実際には効かない。それでも判定側は拒否側に倒し、
                // 大文字小文字を区別しない比較にする。
                if (strcasecmp((string) $queryKey, $reservedQueryKey) === 0) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * 共有してよいパスか
     *
     * 予約された URL コンテキストへ解決されないパスは、カテゴリーコードやエントリーコードのように
     * 事前に列挙できないものであるため既定で通す（許可リストではなく拒否リスト方式）。
     *
     * @param string $path デコード・正規化済みの URL パス部分（CMS ルートからの相対パス。先頭 / あり）
     * @return bool
     */
    private static function isShareablePath(string $path): bool
    {
        // 共有 URL は URL 一致の文字列比較にも使われるため、制御文字・NUL を保存させる実益がない。
        // デコード後に弾くことで、エンコードで隠された制御文字も対象にする。
        if (preg_match('/[\x00-\x1f\x7f]/', $path) === 1) {
            return false;
        }

        // Why not パーサ任せにしない: '.' / '..' は予約セグメントではないため parseAcmsPath() では
        // 未解決パスとしてそのまま通る。共有 URL は保存値との文字列一致にも使うので、同じページを
        // 指す表記揺れを保存させない意味でもここで弾く。
        foreach (explode('/', $path) as $segment) {
            if ($segment === '.' || $segment === '..') {
                return false;
            }
        }

        $urlContext = parseAcmsPath($path);
        foreach (self::reservedUrlContextKeys() as $key) {
            if ($urlContext->get($key)) {
                return false;
            }
        }

        return true;
    }

    /**
     * 拒否する危険なクエリキー名（大文字小文字は区別しない）
     *
     * 対象は「SUID/IS_PREVIEW/ACMS_SID が定義された状態（プレビュー共有によるなりすまし権限下）で
     * 処理モードや権限の見え方を切り替えるキー」に限定する。ページング・並び替え・キーワード検索の
     * ようなクエリは公開ページの絞り込み条件でしかなく、なりすまし権限の有無で意味が変わらないため
     * 含めない。洗い出しの一次情報は main.php / ACMS/function.php / ACMS/Filter.php の
     * `$Get->get(` `$_GET[` 全数調査（PR 説明・チケットに記録）。
     *
     * - layout: main.php の `$Get->get('layout') === 'edit'` が LAYOUT_EDIT を有効化する。
     *   ガードの sessionWithContribution(BID) は、共有 URL 経由の SUID が常にルート主管理者である
     *   ため常に真になり、レイアウト編集モードへの遷移を防げない。
     * - acms-preview-mode / timestamp / preview-token: Engine::shareUrlFormat() が既に除去するため
     *   通常はここに到達しないが、除去処理が将来変わった場合に備えた多層防御として明示的にも拒否する。
     *
     * Why not trash も拒否リストに入れる: `trash=show` は a-blog cms 自身がリビジョンプレビューの
     * URL に付けている（ACMS_GET_Admin_Entry_Revision_Info の 'url'）。しかも main.php のエントリー
     * 解決（ACMS_Filter::entrySession）は RVID が define される前に走るため、削除依頼
     * （entry_status = trash）状態のエントリーは `trash` クエリが無いとパスから解決できず 404 になる。
     * 拒否すると承認フローの主要ユースケース「リビジョンプレビューを共有する」が丸ごと失敗する一方、
     * RVID が立っていれば同じ trash フィルタは下流でも解除されるため、拒否して得られる防御も薄い。
     *
     * @return list<string>
     */
    public static function reservedQueryKeys(): array
    {
        return [
            'layout',
            'acms-preview-mode',
            'timestamp',
            'preview-token',
        ];
    }

    /**
     * 拒否する URL コンテキストのキー名（parseAcmsPath() が設定するキー）
     *
     * 「処理モードや対象を切り替えるコンテキスト」（管理画面・API・acms-code・プロキシ・
     * メディアダウンロード・URL 埋め込みセッションID）と「非公開・会員セルフサービス領域」
     * （ログイン系・パスワードリセット系・2FA系・プロフィール/メール/パスワード更新・退会）に限定する。
     *
     * ここに挙げないキー（bid / aid / uid / cid / eid / utid / cmid / keyword / tag / field /
     * order / page / limit / span / date）は、匿名でも閲覧できる公開ページの絞り込み条件でしかない。
     *
     * Why not tpl も拒否する: tpl/path/to/template.html を表示してよいかは main.php の
     * $isTplQueryForbidden（ajax_security_level / forbid_tpl_url_context / allow_tpl_path）が
     * 既に持っている判断であり、ここで一律拒否すると同じ判断が二箇所に分かれる。しかも
     * forbid_tpl_url_context のフォールバック既定は off（＝匿名でも到達できる公開 URL）なので、
     * 一律拒否は「誰でも開けるページのプレビューだけ共有できない」という不整合を生む。
     * なりすまし権限で tpl ガードの後方互換例外（sessionWithContribution）を素通りされる問題は、
     * ガードを持つ main.php 側で IS_PREVIEW を除外して塞ぐ。
     *
     * Why not domain も拒否する: DOMAIN_SEGMENT は acmsPath() がリダイレクト先 URL を組む出力側の
     * 構造で、parseAcmsPath() には対応する解析処理が無い（リクエストとして /domain/example.com/ を
     * 受けても URL コンテキストにはならず、ただの未解決パスとして扱われる）。解決されないものを
     * 拒否リストに載せても防御にならない。
     *
     * @return list<string>
     */
    public static function reservedUrlContextKeys(): array
    {
        return [
            'admin',
            'api',
            'acmscode',
            'proxy',
            'media',
            'sid',
            'login',
            'admin-reset-password',
            'admin-reset-password-auth',
            'admin-tfa-recovery',
            'signin',
            'signup',
            'reset-password',
            'reset-password-auth',
            'tfa-recovery',
            'update-profile',
            'update-password',
            'update-email',
            'update-tfa',
            'withdrawal',
        ];
    }

    /**
     * DIR_OFFSET に対応するパス接頭辞を取り除く
     *
     * @param string $path
     * @param string $dirOffset
     * @return string
     */
    private static function stripDirOffset(string $path, string $dirOffset): string
    {
        $offset = rtrim($dirOffset, '/');
        if ($offset === '') {
            return $path;
        }
        $prefix = '/' . $offset;
        if ($path === $prefix) {
            return '';
        }
        // Why not DIR_OFFSET をそのまま substr するだけの前方切り落とし（Application::buildRequestPath
        // と同じ実装）にしない: あちらは常に自インストールのリクエストパスに対して使うため前方一致を
        // 前提にできるが、ここは共有登録時に任意の URL 文字列を受け取るため、実際に前方一致しているかを
        // 確認しないと DIR_OFFSET と文字数が揃った別セグメント（例: /admin/... の "/admin"）を誤って
        // オフセットとして剥がし、直後の予約セグメントを判定から取り逃す恐れがある。
        if (strncmp($path, $prefix . '/', strlen($prefix) + 1) === 0) {
            return substr($path, strlen($prefix));
        }

        return $path;
    }
}
