<?php

namespace Acms\Services\Login;

use Acms\Services\Facades\Application;
use Acms\Services\Facades\Image;
use Acms\Services\Facades\Config;
use Acms\Services\Facades\LocalStorage;
use Acms\Services\Facades\PublicStorage;
use Acms\Services\Facades\Session;
use Acms\Services\Facades\Login;
use Acms\Services\Facades\Preview;
use Acms\Services\Login\Exceptions\BadRequestException;
use Acms\Services\Login\Exceptions\ExpiredException;
use DB;
use SQL;
use ACMS_RAM;
use Field;
use Uri\WhatWg\Url as WhatWgUrl;

class Helper
{
    /**
     * 認証系ページの定数をセット
     *
     * @param \Field $queryParameter
     * @return void
     */
    public function setConstantsAuthSystemPage(Field $queryParameter): void
    {
        define('IS_SYSTEM_LOGIN_PAGE', (int)$queryParameter->get('login'));
        define('IS_SYSTEM_ADMIN_RESET_PASSWORD_PAGE', (int)$queryParameter->get('admin-reset-password'));
        define('IS_SYSTEM_ADMIN_RESET_PASSWORD_AUTH_PAGE', (int)$queryParameter->get('admin-reset-password-auth'));
        define('IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE', (int)$queryParameter->get('admin-tfa-recovery'));

        define('IS_SYSTEM_SIGNIN_PAGE', (int)$queryParameter->get('signin'));
        define('IS_SYSTEM_SIGNUP_PAGE', (int)$queryParameter->get('signup'));
        define('IS_SYSTEM_RESET_PASSWORD_PAGE', (int)$queryParameter->get('reset-password'));
        define('IS_SYSTEM_RESET_PASSWORD_AUTH_PAGE', (int)$queryParameter->get('reset-password-auth'));
        define('IS_SYSTEM_TFA_RECOVERY_PAGE', (int)$queryParameter->get('tfa-recovery'));

        define('IS_UPDATE_PROFILE_PAGE', (int)$queryParameter->get('update-profile'));
        define('IS_UPDATE_PASSWORD_PAGE', (int)$queryParameter->get('update-password'));
        define('IS_UPDATE_EMAIL_PAGE', (int)$queryParameter->get('update-email'));
        define('IS_UPDATE_TFA_PAGE', (int)$queryParameter->get('update-tfa'));
        define('IS_WITHDRAWAL_PAGE', (int)$queryParameter->get('withdrawal'));
        define('IS_REVISION_PREVIEW_PAGE', $queryParameter->get('tpl') === 'ajax/revision/preview.html' ? 1 : 0);

        if (
            IS_SYSTEM_LOGIN_PAGE ||
            IS_SYSTEM_ADMIN_RESET_PASSWORD_PAGE ||
            IS_SYSTEM_ADMIN_RESET_PASSWORD_AUTH_PAGE ||
            IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE ||
            IS_SYSTEM_SIGNIN_PAGE ||
            IS_SYSTEM_SIGNUP_PAGE ||
            IS_SYSTEM_RESET_PASSWORD_PAGE ||
            IS_SYSTEM_RESET_PASSWORD_AUTH_PAGE ||
            IS_SYSTEM_TFA_RECOVERY_PAGE
        ) {
            define('IS_AUTH_SYSTEM_PAGE', 1);
        } else {
            define('IS_AUTH_SYSTEM_PAGE', 0);
        }

        if (
            IS_UPDATE_PROFILE_PAGE ||
            IS_UPDATE_PASSWORD_PAGE ||
            IS_UPDATE_EMAIL_PAGE ||
            IS_UPDATE_TFA_PAGE ||
            IS_WITHDRAWAL_PAGE
        ) {
            setConfig('cache', 'off');
        }
    }

    /**
     * ログイン・サインインページへリダイレクト
     * @param 'login'|'signin' $type
     * @return never
     */
    public function redirectToAuthPage(string $type = 'login'): void
    {
        if (!is_ajax()) {
            // ajaxリクエストの場合はリダイレクト先としては扱わない
            $path = rtrim('/' . DIR_OFFSET, '/') . REQUEST_PATH;
            if (pathinfo($path, PATHINFO_EXTENSION) === '') {
                $path = rtrim($path, '/') . '/';
            }
            if (QUERY) {
                $path = $path . '?' . QUERY;
            }
            $phpSession = Session::handle();
            $phpSession->set('acms-login-redirect', $path);
            $phpSession->save();
        }

        $loginUrlData = [
            'bid' => BID,
        ];
        if ($type === 'signin') {
            $loginUrlData['signin'] = true;
        } else {
            $loginUrlData['login'] = true;
        }
        $loginUrl = acmsLink($loginUrlData);
        redirect($loginUrl);
    }

    /**
     * ログイン判定後の処理
     *
     * @return void
     */
    public function postLoginProcessing(): void
    {
        // ログアウトしていたら、ログイン中に追加されるCookieを削除（ログイン判定には使用しない）。
        // logout() 側で明示的なログアウト時には既に削除済みだが、セッションの自然失効など
        // logout() を経由しない未ログイン化パターンも一元的に拾うためのフェイルセーフとして
        // ここでも判定する。
        if (!$this->isLoggedIn()) {
            Login::removeExtraLoggedInCookie();
        }

        //----------------------------------------------
        // ログアウト時のみ表示できるページで、ログイン指定場合
        if ($this->isLoggedIn() && IS_AUTH_SYSTEM_PAGE) {
            httpStatusCode('303 Login With Session');
            header(PROTOCOL . ' ' . httpStatusCode());
            redirect(acmsLink([
                'bid' => BID,
            ]));
        }

        $isAuthRequiredPage = $this->isAuthRequiredPage();

        //--------------
        // session fail
        if ($isAuthRequiredPage && !$this->isLoggedIn()) {
            httpStatusCode('403 Forbidden');
            setConfig('cache', 'off');

            if (config('login_auto_redirect') === 'on') {
                $this->redirectToAuthPage();
            }
        }

        //--------------------------------------------------
        // 読者ユーザーの場合、特定の管理画面以外はアクセスさせない
        if ($this->isLoggedIn() && $isAuthRequiredPage && isSessionSubscriber()) {
            if (!in_array(ADMIN, configArray('subscriber_access_admin_page'), true)) {
                httpStatusCode('403 Forbidden');
                setConfig('cache', 'off');
            }
        }
    }

    /**
     * 現在のページが認証が必要なページかどうか判定
     *
     * @return bool
     */
    public function isAuthRequiredPage(): bool
    {
        if (!!ADMIN && Preview::isPreviewShareAdmin(ADMIN) === false) {
            return true;
        }
        if (IS_REVISION_PREVIEW_PAGE) {
            return true;
        }
        return false;
    }

    /**
     * 現在アクセス中のセッションのユーザーID（SUID）を取得する。
     *
     * SUID はグローバル定数のためテストから直接差し替えられない。isLoggedIn() 等は
     * SUID を直接参照せずこのメソッドを経由することで、テスト時にサブクラス化して
     * オーバーライドし、任意のユーザーIDでログイン状態をシミュレートできるようにする。
     *
     * @return int|null
     */
    public function sessionUserId(): ?int
    {
        return defined('SUID') ? SUID : null;
    }

    /**
     * 指定したユーザーが，現在アクセス中のセッションのユーザーとしてログイン中かどうか判定する
     *
     * @param int|null $userId 判定対象のユーザーID（省略時はアクセス中のユーザーを判定する）
     * @return bool
     */
    public function isLoggedIn(?int $userId = null): bool
    {
        $sessionUserId = $this->sessionUserId();
        if (is_null($sessionUserId) || $sessionUserId < 1) {
            return false;
        }
        if (func_num_args() === 0) {
            $userId = $sessionUserId;
        }
        return $userId === $sessionUserId;
    }

    /**
     * 管理者ログイン用: 現在のIPアドレスからアクセス可能か判断
     *
     * @return bool true: アクセス可能, false: アクセス不可能
     */
    public function canAccessAdminLoginFromCurrentIp(): bool
    {
        return $this->canAccessFromCurrentIp('login_white_hosts', 'login_black_hosts');
    }

    /**
     * 一般サインイン用: 現在のIPアドレスからアクセス可能か判断
     *
     * @return bool true: アクセス可能, false: アクセス不可能
     */
    public function canAccessSigninFromCurrentIp(): bool
    {
        return $this->canAccessFromCurrentIp('signin_white_hosts', 'signin_black_hosts');
    }

    /**
     * IPアドレスチェックの共通ロジック
     *
     * @param string $whiteListName
     * @param string $blackListName
     * @return bool
     */
    private function canAccessFromCurrentIp(string $whiteListName, string $blackListName): bool
    {
        $config = Config::loadBlogConfigSet(BID);

        $isAccessible = true;
        if ($hosts = $config->getArray($whiteListName)) {
            $isAccessible = false;
            foreach ($hosts as $ipband) {
                if (in_ipband(REMOTE_ADDR, $ipband)) {
                    $isAccessible = true;
                    break;
                }
            }
        }
        if ($isAccessible) {
            foreach ($config->getArray($blackListName) as $ipband) {
                if (in_ipband(REMOTE_ADDR, $ipband)) {
                    $isAccessible = false;
                    break;
                }
            }
        }
        return $isAccessible;
    }

    /**
     * 指定されたパスがブログトップからのパスで余分なパスがないかどうかを判定
     *
     * @param int $bid
     * @param string $segment
     * @return boolean
     */
    public function isExactPath(int $bid, string $segment): bool
    {
        $blogCode = ACMS_RAM::blogCode($bid) ?? '';
        return $this->matchExactPath($blogCode, $segment, REQUEST_PATH);
    }

    /**
     * ブログコードとセグメントから生成したパスが、リクエストパスと一致するかを判定
     *
     * @param string $blogCode
     * @param string $segment
     * @param string $requestPath
     * @return boolean
     */
    public function matchExactPath(string $blogCode, string $segment, string $requestPath): bool
    {
        $correctPath = implode('/', [$blogCode, $segment]);
        $correctPath = trim($correctPath, '/');
        $pattern = '/' . $correctPath;

        return $pattern === $requestPath;
    }

    /**
     * 認証前のログインページを表示できるかどうかを判定
     *
     * deny_blog_login / fix_login_url による、認証ページの露出面を減らすためのアクセス制御。
     * false の場合に 404 を表示するのは仕様であり、正規URLへのリダイレクトで代替してはいけない。
     * リダイレクトすると隠している正規URLを教えることになり制限の意味がなくなるうえ、
     * deny_blog_login はURLの形ではなくブログの属性で拒否するため無限リダイレクトになる。
     * シークレット遮断（resolveSecretRedirectTarget()）の「認証画面へ誘導」とは目的が逆で、
     * こちらは「認証ページが存在しないことにする」ための判定。
     *
     * @param int $bid
     * @param string $segment
     * @return boolean
     */
    public function canLoginPage(int $bid, string $segment): bool
    {
        if (config('deny_blog_login') === 'on' && $bid !== RBID) {
            return false;
        }
        return $this->isValidAuthenticatedPath($bid, $segment);
    }

    /**
     * ログイン済みユーザーのページ（マイページ・管理画面）で正しいパスかどうかを判定
     * deny_blog_loginはログインページの制御なので、ログイン済み操作には適用しない
     *
     * @param int $bid
     * @param string $segment
     * @return boolean
     */
    public function isValidAuthenticatedPath(int $bid, string $segment): bool
    {
        if (config('fix_login_url') === 'on' && !$this->isExactPath($bid, $segment)) {
            return false;
        }
        return true;
    }

    /**
     * 会員サインインが可能かどうかを判定
     *
     * @return bool
     */
    public function canMemberSignin(): bool
    {
        if (defined('ACMS_POST') && ACMS_POST) {
            if (strpos(ACMS_POST, 'Member_Admin') === false) {
                return config('member_login_enable') === 'on';
            }
            return true;
        }
        return config('member_login_enable') === 'on';
    }

    /**
     * シークレットコンテンツ遮断のリダイレクト先を解決する（副作用なし・テスト対象）
     *
     * グローバル定数・設定への依存はすべて呼び出し側（resolveAuthSystemRequest()）で
     * 解決して引数で受け取り、本メソッドは受け取った値だけで判定する。これにより
     * private な shouldRedirectToSignin() を含む判定分岐を、この public メソッド経由で
     * 網羅的にテストできる（グローバル状態をスタブせずに済む）。
     *
     * @param string|null $blogStatus 現在のブログステータス（ACMS_RAM::blogStatus(BID)）
     * @param string|null $categoryStatus 現在のカテゴリーステータス（CID 未定義なら null）
     * @param bool $requiresAuthentication 未認証アクセスの遮断が必要か（requiresAuthenticationForSecretContent()）
     * @param bool $preferSignin ログイン画面のリダイレクト設定がサインイン（redirect_login_page === 'signin'）
     * @param bool $isAuthRequiredPage 認証が必要なページ（管理画面・リビジョンプレビュー）
     * @param bool $canMemberSignin 会員サインイン機能が有効
     * @param bool $isMemberAuthSystemPage 会員向け認証系ページ（サインイン/登録/パスワードリセット/2FA復旧）か
     * @param bool $isMemberAuthPageFeatureAvailable 会員向け認証系ページの機能が利用可能か（isMemberAuthPageFeatureAvailable()）
     * @param bool $canAccessSigninFromCurrentIp 現在のIPアドレスから会員サインインページへのアクセスが許可されているか
     *   （canAccessSigninFromCurrentIp()）。誘導先が signin の場合に確認する。
     * @param bool $canAccessAdminLoginFromCurrentIp 現在のIPアドレスから管理者ログインページへのアクセスが
     *   許可されているか（canAccessAdminLoginFromCurrentIp()）。誘導先が login の場合に確認する。
     *   signin_white_hosts と login_white_hosts は管理画面上も「一般サインイン制限」「管理ログイン制限」として
     *   独立した設定のため、誘導先に対応する方の制限だけを確認する。許可されていない場合は一切リダイレクトしない。
     *   リダイレクトすると隠しているはずの認証ページの正規URLを制限対象IPに開示してしまうため
     *   （canLoginPage() が同じ理由で 404 を維持しているのと同じ設計方針）
     * @return 'login'|'signin'|null 'signin': 会員サインイン画面へ, 'login': 管理者ログイン画面へ, null: 遮断不要
     */
    public function resolveSecretRedirectTarget(
        ?string $blogStatus,
        ?string $categoryStatus,
        bool $requiresAuthentication,
        bool $preferSignin,
        bool $isAuthRequiredPage,
        bool $canMemberSignin,
        bool $isMemberAuthSystemPage,
        bool $isMemberAuthPageFeatureAvailable,
        bool $canAccessSigninFromCurrentIp,
        bool $canAccessAdminLoginFromCurrentIp
    ): ?string {
        $isSecret = $blogStatus === 'secret' || $categoryStatus === 'secret';
        if (!$isSecret) {
            return null;
        }
        // シークレットブログの会員向け認証系ページに、そのページの機能が無効な状態でアクセスした場合
        // （会員機能自体が無効・サインアップ停止・2FA無効）、404 ではなく利用可能な認証画面へ誘導する。
        // 会員サインイン自体が無効なら管理者ログイン画面へ、有効なら会員サインイン画面へ。
        if ($isMemberAuthSystemPage && !$isMemberAuthPageFeatureAvailable) {
            $target = $canMemberSignin ? 'signin' : 'login';
        } elseif (!$requiresAuthentication) {
            // ログイン済み・認証系ページ・プレビュー共有など（リダイレクトループを防ぐ）
            return null;
        } else {
            $target = $this->shouldRedirectToSignin($preferSignin, $isAuthRequiredPage, $canMemberSignin) ? 'signin' : 'login';
        }

        // 誘導先に対応するIP制限を確認する。制限されている場合は一切リダイレクトしない。
        if ($target === 'signin') {
            return $canAccessSigninFromCurrentIp ? 'signin' : null;
        }
        return $canAccessAdminLoginFromCurrentIp ? 'login' : null;
    }

    /**
     * シークレットコンテンツ遮断時に、会員サインイン画面へ誘導すべきかを判定する
     *
     * ログイン画面のリダイレクト設定が「サインイン」で、かつ認証必須ページであるか、
     * または会員サインイン機能が有効な場合にサインイン画面へ誘導する。それ以外は管理者ログイン画面へ。
     * resolveSecretRedirectTarget() 経由でテストされる。
     *
     * @param bool $preferSignin ログイン画面のリダイレクト設定がサインイン
     * @param bool $isAuthRequiredPage 認証が必要なページ
     * @param bool $canMemberSignin 会員サインイン機能が有効
     * @return bool true: 会員サインイン画面へ, false: 管理者ログイン画面へ
     */
    private function shouldRedirectToSignin(bool $preferSignin, bool $isAuthRequiredPage, bool $canMemberSignin): bool
    {
        if (!$preferSignin) {
            return false;
        }
        return $isAuthRequiredPage || $canMemberSignin;
    }

    /**
     * 管理者向けの認証系ページ（ログイン/パスワードリセット/パスワードリセット認証/2FA復旧）かどうかを判定
     */
    public function isAdminAuthSystemPage(): bool
    {
        foreach (
            [
                'IS_SYSTEM_LOGIN_PAGE',
                'IS_SYSTEM_ADMIN_RESET_PASSWORD_PAGE',
                'IS_SYSTEM_ADMIN_RESET_PASSWORD_AUTH_PAGE',
                'IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE',
            ] as $pageConstant
        ) {
            if (defined($pageConstant) && constant($pageConstant)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 会員向けの認証系ページ（サインイン/登録/パスワードリセット/パスワードリセット認証/2FA復旧）かどうかを判定
     *
     * @return bool
     */
    public function isMemberAuthSystemPage(): bool
    {
        foreach (
            [
                'IS_SYSTEM_SIGNIN_PAGE',
                'IS_SYSTEM_SIGNUP_PAGE',
                'IS_SYSTEM_RESET_PASSWORD_PAGE',
                'IS_SYSTEM_RESET_PASSWORD_AUTH_PAGE',
                'IS_SYSTEM_TFA_RECOVERY_PAGE',
            ] as $pageConstant
        ) {
            if (defined($pageConstant) && constant($pageConstant)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 現在の会員向け認証系ページの機能が利用可能かどうかを判定
     *
     * 会員機能自体の有効性に加え、ページ固有の機能スイッチ
     * （サインアップ: subscribe、2FA復旧: two_factor_auth）を確認する。
     * URL 制約（fix_login_url / deny_blog_login による canLoginPage()）は含めない。
     * URL 制約違反は認証画面へ誘導せず 404 のままにする（正規URLの秘匿を維持し、
     * 同一ページへのリダイレクトループを防ぐ）ため。
     *
     * @return bool
     */
    private function isMemberAuthPageFeatureAvailable(): bool
    {
        if (!$this->canMemberSignin()) {
            return false;
        }
        if (defined('IS_SYSTEM_SIGNUP_PAGE') && IS_SYSTEM_SIGNUP_PAGE) {
            return $this->isSignupFeatureEnabled();
        }
        if (defined('IS_SYSTEM_TFA_RECOVERY_PAGE') && IS_SYSTEM_TFA_RECOVERY_PAGE) {
            return $this->isTwoFactorAuthEnabled();
        }
        return true;
    }

    /**
     * 読者登録（サインアップ）機能が有効かどうかを判定
     *
     * isMemberAuthPageFeatureAvailable() と resolveAuthSystemRequest() の
     * IS_SYSTEM_SIGNUP_PAGE 分岐の両方から参照する単一の判定源。
     *
     * @return bool
     */
    private function isSignupFeatureEnabled(): bool
    {
        return config('subscribe') === 'on';
    }

    /**
     * 二要素認証機能が有効かどうかを判定
     *
     * isMemberAuthPageFeatureAvailable() と resolveAuthSystemRequest() の
     * IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE / IS_SYSTEM_TFA_RECOVERY_PAGE 分岐の
     * 両方から参照する単一の判定源。
     *
     * @return bool
     */
    private function isTwoFactorAuthEnabled(): bool
    {
        return config('two_factor_auth') === 'on';
    }

    /**
     * 認証系URLの解決結果（テンプレート表示 or ログイン画面へのリダイレクト）を取得する
     *
     * 副作用を持たず、呼び出し側（main.php）が戻り値（AuthSystemResolution）に従って
     * テンプレート表示またはリダイレクトを行う。シークレットブログ/カテゴリーの未認証アクセス遮断は
     * 認証系画面の表示判定より先に行い、未ログイン時はログイン画面へ誘導する。
     *
     * @return AuthSystemResolution
     */
    public function resolveAuthSystemRequest(): AuthSystemResolution
    {
        /**
         * シークレットブログ・シークレットカテゴリー
         * （認証系画面の表示判定より先に判定する。未ログイン時はサインイン/ログイン画面へ誘導し、
         * 機能が無効な会員向け認証系ページも 404 ではなく利用可能な認証画面へ誘導する）
         * リダイレクトという副作用は呼び出し側に委ね、ここでは誘導先だけを解決結果として返す。
         */
        /** @var int|null $categoryId */
        $categoryId = defined('CID') ? CID : null;
        $secretRedirect = $this->resolveSecretRedirectTarget(
            blogStatus: ACMS_RAM::blogStatus(BID),
            categoryStatus: $categoryId !== null ? ACMS_RAM::categoryStatus($categoryId) : null,
            requiresAuthentication: $this->requiresAuthenticationForSecretContent(ADMIN),
            preferSignin: config('redirect_login_page') === 'signin',
            isAuthRequiredPage: $this->isAuthRequiredPage(),
            canMemberSignin: $this->canMemberSignin(),
            isMemberAuthSystemPage: $this->isMemberAuthSystemPage(),
            isMemberAuthPageFeatureAvailable: $this->isMemberAuthPageFeatureAvailable(),
            canAccessSigninFromCurrentIp: $this->canAccessSigninFromCurrentIp(),
            canAccessAdminLoginFromCurrentIp: $this->canAccessAdminLoginFromCurrentIp(),
        );
        if ($secretRedirect !== null) {
            return AuthSystemResolution::redirect($secretRedirect);
        }

        /**
         * ログアウト時の管理ユーザー専用認証系画面
         */
        if (
            (IS_SYSTEM_LOGIN_PAGE || IS_SYSTEM_ADMIN_RESET_PASSWORD_PAGE || IS_SYSTEM_ADMIN_RESET_PASSWORD_AUTH_PAGE || IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE)
            && !$this->canAccessAdminLoginFromCurrentIp()
        ) {
            // 管理者用の認証ページで、かつ現在のIPアドレスから管理画面へのアクセスが許可されていない → 404でアクセス拒否
            return AuthSystemResolution::template(tplConfig('tpl_404'));
        }
        if (IS_SYSTEM_LOGIN_PAGE) {
            // 管理者ログインページ
            return AuthSystemResolution::template(
                $this->canLoginPage(BID, LOGIN_SEGMENT) ? tplConfig('tpl_login') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_ADMIN_RESET_PASSWORD_PAGE) {
            // 管理者パスワードリセットページ
            return AuthSystemResolution::template(
                $this->canLoginPage(BID, ADMIN_RESET_PASSWORD_SEGMENT) ? tplConfig('tpl_admin-reset-password') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_ADMIN_RESET_PASSWORD_AUTH_PAGE) {
            // 管理者パスワードリセット認証ページ
            return AuthSystemResolution::template(
                $this->canLoginPage(BID, ADMIN_RESET_PASSWORD_AUTH_SEGMENT) ? tplConfig('tpl_admin-reset-password-auth') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_ADMIN_TFA_RECOVERY_PAGE) {
            // 管理者2FA復旧ページ（2FA有効時のみ表示、無効なら404）
            if ($this->canLoginPage(BID, ADMIN_TFA_RECOVERY_SEGMENT) && $this->isTwoFactorAuthEnabled()) {
                return AuthSystemResolution::template(tplConfig('tpl_admin-tfa-recovery'));
            }
            return AuthSystemResolution::template(tplConfig('tpl_404'));
        }

        /**
         * ログアウト時の一般ユーザー専用認証系画面
         */
        if (
            $this->isMemberAuthSystemPage()
            && !$this->canAccessSigninFromCurrentIp()
        ) {
            // 一般ユーザー用の認証ページで、かつ現在のIPアドレスからサインインが許可されていない → 404でアクセス拒否
            return AuthSystemResolution::template(tplConfig('tpl_404'));
        }
        if (IS_SYSTEM_SIGNIN_PAGE) {
            // 一般ユーザーサインインページ（メンバーサインイン許可時のみ表示、それ以外は404）
            return AuthSystemResolution::template(
                $this->canMemberSignin() && $this->canLoginPage(BID, SIGNIN_SEGMENT)
                    ? tplConfig('tpl_signin') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_SIGNUP_PAGE) {
            // 一般ユーザーサインアップページ（メンバーサインイン許可かつサインアップ有効時のみ表示）
            $canSingnup = $this->canMemberSignin() && $this->isSignupFeatureEnabled() && $this->canLoginPage(BID, SIGNUP_SEGMENT);
            return AuthSystemResolution::template($canSingnup ? tplConfig('tpl_signup') : tplConfig('tpl_404'));
        }
        if (IS_SYSTEM_RESET_PASSWORD_PAGE) {
            // 一般ユーザーパスワードリセットページ（メンバーサインイン許可時のみ表示、それ以外は404）
            return AuthSystemResolution::template(
                $this->canMemberSignin() && $this->canLoginPage(BID, RESET_PASSWORD_SEGMENT)
                    ? tplConfig('tpl_reset-password') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_RESET_PASSWORD_AUTH_PAGE) {
            // 一般ユーザーパスワードリセット認証ページ（メンバーサインイン許可時のみ表示、それ以外は404）
            return AuthSystemResolution::template(
                $this->canMemberSignin() && $this->canLoginPage(BID, RESET_PASSWORD_AUTH_SEGMENT)
                    ? tplConfig('tpl_reset-password-auth') : tplConfig('tpl_404')
            );
        }
        if (IS_SYSTEM_TFA_RECOVERY_PAGE) {
            // 一般ユーザー2FA復旧ページ（メンバーサインイン許可かつ2FA有効時のみ表示）
            $canTfaRecoveryPage = $this->canMemberSignin() && $this->canLoginPage(BID, TFA_RECOVERY_SEGMENT) && $this->isTwoFactorAuthEnabled();
            return AuthSystemResolution::template($canTfaRecoveryPage ? tplConfig('tpl_tfa-recovery') : tplConfig('tpl_404'));
        }

        /**
         * ログイン時の認証画面
         * （シークレットブログ/カテゴリーの未認証アクセスは冒頭のシークレット判定で
         * サインイン/ログイン画面へ誘導済みのため、ここに到達するのは認証不要と判定されたアクセスのみ）
         */
        if (IS_UPDATE_PROFILE_PAGE) {
            // プロフィール更新ページ（ログイン中=SUIDありの場合のみ表示、ログアウト中は404）
            return AuthSystemResolution::template($this->isLoggedIn() && $this->isExactPath(BID, PROFILE_UPDATE_SEGMENT) && BID === SBID ? tplConfig('tpl_update-profile') : tplConfig('tpl_404'));
        }
        if (IS_UPDATE_PASSWORD_PAGE) {
            // パスワード更新ページ（ログイン中=SUIDありの場合のみ表示、ログアウト中は404）
            return AuthSystemResolution::template($this->isLoggedIn() && $this->isExactPath(BID, PASSWORD_UPDATE_SEGMENT) && BID === SBID ? tplConfig('tpl_update-password') : tplConfig('tpl_404'));
        }
        if (IS_UPDATE_EMAIL_PAGE) {
            // メールアドレス更新ページ（ログイン中=SUIDありの場合のみ表示、ログアウト中は404）
            return AuthSystemResolution::template($this->isLoggedIn() && $this->isExactPath(BID, EMAIL_UPDATE_SEGMENT) && BID === SBID ? tplConfig('tpl_update-email') : tplConfig('tpl_404'));
        }
        if (IS_UPDATE_TFA_PAGE) {
            // 2FA更新ページ（ログイン中=SUIDありの場合のみ表示、ログアウト中は404）
            return AuthSystemResolution::template($this->isLoggedIn() && $this->isExactPath(BID, TFA_UPDATE_SEGMENT) && BID === SBID ? tplConfig('tpl_update-fta') : tplConfig('tpl_404'));
        }
        if (IS_WITHDRAWAL_PAGE) {
            // 退会ページ（ログイン中=SUIDありの場合のみ表示、ログアウト中は404）
            return AuthSystemResolution::template($this->isLoggedIn() && $this->isExactPath(BID, WITHDRAWAL_SEGMENT) && BID === SBID ? tplConfig('tpl_withdrawal') : tplConfig('tpl_404'));
        }

        // 認証系URLに該当しない場合は none（通常のテンプレート処理へ）
        return AuthSystemResolution::none();
    }

    /**
     * 登録ユーザーを検索
     *
     * @param string $email
     * @return int
     */
    public function findUser($email, $bid)
    {
        $SQL = SQL::newSelect('user');
        $SQL->setSelect('user_id');
        $SQL->addWhereOpr('user_mail', $email);
        $SQL->addWhereOpr('user_blog_id', $bid);
        $SQL->setLimit(1);
        return intval(DB::query($SQL->get(dsn()), 'one'));
    }

    /**
     * @param \Field_Validation $user
     * @param bool $subscribeLoginAnywhere
     *
     * @return int $uid
     */
    public function createUser($user, $subscribeLoginAnywhere)
    {
        $uid = DB::query(SQL::nextval('user_id', dsn()), 'seq');
        $auth = config('subscribe_auth', 'subscriber');

        $SQL = SQL::newSelect('user');
        $SQL->setSelect('user_sort');
        $SQL->setOrder('user_sort', 'DESC');
        $SQL->addWhereOpr('user_blog_id', BID);
        $sort = intval(DB::query($SQL->get(dsn()), 'one')) + 1;

        $SQL = SQL::newInsert('user');
        $SQL->addInsert('user_id', $uid);
        $SQL->addInsert('user_sort', $sort);
        $SQL->addInsert('user_blog_id', BID);
        $SQL->addInsert('user_status', 'pseudo');
        $SQL->addInsert('user_name', $user->get('name'));
        $SQL->addInsert('user_mail', $user->get('mail'));
        $SQL->addInsert('user_mail_mobile', $user->get('mail_mobile'));
        if ($user->get('mail_magazine') === 'off') {
            $SQL->addInsert('user_mail_magazine', 'off');
        }
        if ($user->get('mail_mobile_magazine') === 'off') {
            $SQL->addInsert('user_mail_mobile_magazine', 'off');
        }
        $SQL->addInsert('user_code', $user->get('code'));
        $SQL->addInsert('user_url', $user->get('url'));
        $SQL->addInsert('user_auth', $auth);
        $SQL->addInsert('user_indexing', 'on');
        $SQL->addInsert('user_pass', acmsUserPasswordHash($user->get('pass')));
        $SQL->addInsert('user_pass_generation', PASSWORD_ALGORITHM_GENERATION);
        if ($subscribeLoginAnywhere) {
            $SQL->addInsert('user_login_anywhere', 'on');
        }
        $SQL->addInsert('user_generated_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        DB::query($SQL->get(dsn()), 'exec');

        return $uid;
    }

    /**
     * 新しいユーザーをOAuth認証から作成
     *
     * @param array $data OAuth認証データ
     */
    public function addUserFromOauth($data): int
    {
        $SQL = SQL::newSelect('user');
        $SQL->setSelect('user_id');
        $SQL->addWhereOpr('user_mail', $data['email']);
        if (DB::query($SQL->get(dsn()), 'one')) {
            throw new \RuntimeException('すでに登録済みのメールアドレスです');
        }

        $SQL = SQL::newSelect('user');
        $SQL->setSelect('user_sort');
        $SQL->setOrder('user_sort', 'DESC');
        $SQL->addWhereOpr('user_blog_id', $data['bid']);
        $sort = intval(DB::query($SQL->get(dsn()), 'one')) + 1;
        $uid = DB::query(SQL::nextval('user_id', dsn()), 'seq');

        $SQL = SQL::newInsert('user');
        $SQL->addInsert('user_id', $uid);
        $SQL->addInsert('user_sort', $sort);
        $SQL->addInsert('user_generated_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        $SQL->addInsert('user_blog_id', $data['bid']);
        $SQL->addInsert('user_code', $data['code']);
        $SQL->addInsert('user_status', config('subscribe_init_status', 'open'));
        $SQL->addInsert('user_name', $data['name']);
        $randomGenerator = Application::make('crypto.random');
        assert($randomGenerator instanceof \Acms\Services\Crypto\RandomGenerator);
        $SQL->addInsert('user_pass', $randomGenerator->genPass(16));
        $SQL->addInsert($data['oauthType'], $data['sub']);
        $SQL->addInsert('user_mail', $data['email']);
        $SQL->addInsert('user_mail_magazine', 'off');
        $SQL->addInsert('user_mail_mobile_magazine', 'off');
        $SQL->addInsert('user_icon', $data['icon']);
        $SQL->addInsert('user_auth', config('subscribe_auth', 'subscriber'));
        $SQL->addInsert('user_indexing', 'on');
        $SQL->addInsert('user_login_anywhere', 'off');
        $SQL->addInsert('user_login_expire', '9999-12-31');
        $SQL->addInsert('user_updated_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        DB::query($SQL->get(dsn()), 'exec');

        return $uid;
    }

    /**
     * @param int $uid
     * @param \Field_Validation $user
     * @param bool $subscribeLoginAnywhere
     */
    public function updateUser($uid, $user, $subscribeLoginAnywhere = false)
    {
        $SQL = SQL::newUpdate('user');
        $SQL->addUpdate('user_name', $user->get('name'));
        $SQL->addUpdate('user_mail_mobile', $user->get('mail_mobile'));
        $SQL->addUpdate('user_code', $user->get('code'));
        $SQL->addUpdate('user_url', $user->get('url'));
        $SQL->addUpdate('user_pass', acmsUserPasswordHash($user->get('pass')));
        $SQL->addUpdate('user_generated_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        $SQL->addWhereOpr('user_id', $uid);
        if ($subscribeLoginAnywhere) {
            $SQL->addUpdate('user_login_anywhere', 'on');
        }
        DB::query($SQL->get(dsn()), 'exec');
        ACMS_RAM::user($uid, null);
    }

    /**
     * @param array $context
     * @param int $lifetime
     * @return string
     */
    public function createTimedLinkParams($context, $lifetime)
    {
        $randomGenerator = Application::make('crypto.random');
        assert($randomGenerator instanceof \Acms\Services\Crypto\RandomGenerator);
        $salt = $randomGenerator->genPass(32); // 事前共有鍵
        $context['expire'] = REQUEST_TIME + $lifetime; // 有効期限
        $context = acmsSerialize($context);
        $saltRepository = Application::make('crypto.salt');
        assert($saltRepository instanceof \Acms\Services\Crypto\SaltRepository);
        $prk = hash_hmac('sha256', $saltRepository->getCurrentSalt(), $salt);
        $derivedKey = hash_hmac('sha256', $prk, $context);
        $params = http_build_query([
            'key' => $derivedKey,
            'salt' => $salt,
            'context' => $context,
        ]);
        return $params;
    }

    /**
     * @param string $key
     * @param string $salt
     * @param string $context
     * @return array
     * @throws BadRequestException
     * @throws ExpiredException
     */
    public function validateTimedLinkParams($key, $salt, $context)
    {
        $saltRepository = Application::make('crypto.salt');
        assert($saltRepository instanceof \Acms\Services\Crypto\SaltRepository);
        $prk = hash_hmac('sha256', $saltRepository->getCurrentSalt(), $salt);
        $saltRepository = Application::make('crypto.salt');
        assert($saltRepository instanceof \Acms\Services\Crypto\SaltRepository);
        $prk2 = hash_hmac('sha256', $saltRepository->getPreviousSalt(), $salt);
        $derivedKey = hash_hmac('sha256', $prk, $context);
        $derivedKey2 = hash_hmac('sha256', $prk2, $context);
        if (!hash_equals($key, $derivedKey) && !hash_equals($key, $derivedKey2)) {
            throw new BadRequestException('Bad request.');
        }
        $context = acmsUnserialize($context);
        if (!isset($context['expire'])) {
            throw new BadRequestException('Bad request.');
        }
        if (REQUEST_TIME > $context['expire']) {
            throw new ExpiredException('Expired.');
        }
        return $context;
    }

    /**
     * 認証トークンを1件だけ原子的に消費する（単一使用の担保）
     *
     * token_value 一致で該当トークンを削除し、削除できた（1件以上消えた）場合のみ
     * 消費成功とみなす。DELETE の affected rows を楽観ロックとして使うことで、
     * 同一リンクの二重送信・使用後の再利用を後続処理の前に弾ける。
     *
     * @param string $key トークンのキー
     * @param string $type トークンのタイプ
     * @param string $token 平文トークン（内部で sha256 化して突合する）
     * @return bool 消費できたら true、既に使用済み・不一致なら false
     */
    public function consumeSingleUseToken(string $key, string $type, string $token): bool
    {
        if ($key === '' || $type === '') {
            return false;
        }
        $sql = SQL::newDelete('token');
        $sql->addWhereOpr('token_key', $key);
        $sql->addWhereOpr('token_type', $type);
        $sql->addWhereOpr('token_value', hash('sha256', $token));
        DB::query($sql->get(dsn()), 'exec');

        return DB::affected_rows() > 0;
    }

    /**
     * 同一データ（宛先）に発行済みの認証トークンを無効化する
     *
     * 再発行時に過去のリンクを失効させ、複数リンクが同時に有効な状態を防ぐ。
     * token_data は保存時と同一の JSON 文字列で完全一致させる。
     *
     * @param string $key トークンのキー
     * @param string $type トークンのタイプ
     * @param string $tokenData saveToken と同形式の token_data 文字列
     * @return void
     */
    public function invalidateAuthTokens(string $key, string $type, string $tokenData): void
    {
        if ($key === '' || $type === '') {
            return;
        }
        $sql = SQL::newDelete('token');
        $sql->addWhereOpr('token_key', $key);
        $sql->addWhereOpr('token_type', $type);
        $sql->addWhereOpr('token_data', $tokenData);
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * 有効期限切れの認証トークンを削除する
     *
     * @return void
     */
    public function purgeExpiredAuthTokens(): void
    {
        $sql = SQL::newDelete('token');
        $sql->addWhereOpr('token_expire', date('Y-m-d H:i:s', REQUEST_TIME), '<');
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * ユーザーを有効化
     *
     * @param int $uid
     * @return bool
     */
    public function subscriberActivation($uid)
    {
        // enable account
        $sql = SQL::newUpdate('user');
        $sql->addUpdate('user_status', config('subscribe_init_status', 'open'));
        $sql->addUpdate('user_login_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        $sql->addWhereOpr('user_id', $uid);
        DB::query($sql->get(dsn()), 'exec');
        ACMS_RAM::user($uid, null);

        return true;
    }

    /**
     * ユーザーのログインを確定する。
     *
     * ログイン状態の記録（最終ログイン日時の更新＋パスワード再設定トークンのクリア）とログイン
     * セッションの生成を常にセットで実行し、経路ごとの処理漏れ（最終ログイン日時の更新漏れなど）を
     * 防ぐ。全ログイン確定経路（パスワード / 2段階認証 / メール認証 / SNS）はこのメソッドを通す。
     * ログインイベントを伴わずセッションだけを再発行したい場合は generateSession() を直接使う。
     *
     * @param int $userId
     * @return void
     */
    public function authenticateUser(int $userId): void
    {
        $this->recordLogin($userId);
        $this->createAuthSession($userId);
    }

    /**
     * ログイン時に旧世代のパスワードハッシュを最新世代へ再ハッシュすべきか判定する（純粋関数）。
     *
     * 現行世代が目標世代より古い場合のみ再ハッシュが必要と判定する。第3世代（bcrypt）同士の
     * コスト見直し（password_needs_rehash()）は対象に含めない。グローバル状態に触れず引数だけで
     * 判定するため単体テスト可能。
     *
     * @param int $currentGeneration 現在保存されているハッシュ世代（getPasswordGeneration() 由来）
     * @param int $targetGeneration 目標世代（通常は定数 PASSWORD_ALGORITHM_GENERATION）
     * @return bool
     */
    public function needsPasswordRehash(int $currentGeneration, int $targetGeneration): bool
    {
        return $currentGeneration < $targetGeneration;
    }

    /**
     * ログイン認証に成功した平文パスワードを使い、旧世代のハッシュを最新世代へ再ハッシュして永続化する。
     *
     * 認証成立（セッション生成・最終ログイン日時更新）そのものとは別の関心事（パスワードの保存形式
     * 移行）であるため authenticateUser() には含めず独立させている。平文パスワードが手に入る
     * パスワード認証経路（管理・会員で共通の ACMS_POST_Member_Signin::post()）からのみ呼ぶ。平文を
     * 持たない経路（2段階認証 / メール認証 / SNS）はそもそも呼ばれない。現行世代が最新世代
     * （PASSWORD_ALGORITHM_GENERATION）以上なら何もしない。recordLogin() と同じく該当ユーザー行のみを
     * 単一 UPDATE で更新する。平文はここでハッシュ化するだけで、ログ等には残さない。
     *
     * @param int $userId
     * @param string $plainPassword 認証に成功した平文パスワード（呼び出し側で非空を保証する）
     * @param int $currentGeneration 現在のハッシュ世代（getPasswordGeneration() 由来）
     * @return void
     */
    public function rehashPasswordOnAuth(int $userId, string $plainPassword, int $currentGeneration): void
    {
        if (!$this->needsPasswordRehash($currentGeneration, PASSWORD_ALGORITHM_GENERATION)) {
            return;
        }
        $sql = SQL::newUpdate('user');
        $sql->addUpdate('user_pass', acmsUserPasswordHash($plainPassword));
        $sql->addUpdate('user_pass_generation', PASSWORD_ALGORITHM_GENERATION);
        $sql->addWhereOpr('user_id', $userId);
        DB::query($sql->get(dsn()), 'exec');
        ACMS_RAM::user($userId, null); // 更新後の値を読み直せるようキャッシュを破棄
    }

    /**
     * ログイン成立時のユーザー状態を記録する。
     *
     * 最終ログイン日時（user_login_datetime）の更新と、保留中のパスワード再設定トークン
     * （user_pass_reset）のクリアを行う authenticateUser() の内部ステップ。従来は各ハンドラが
     * 個別に UPDATE していて経路ごとに処理漏れ（SNS 認証での日時未更新など）が生じていたため、
     * authenticateUser() 経由に集約している。
     *
     * @param int $userId
     * @return void
     */
    private function recordLogin(int $userId): void
    {
        $sql = SQL::newUpdate('user');
        $sql->addUpdate('user_login_datetime', date('Y-m-d H:i:s', REQUEST_TIME));
        $sql->addUpdate('user_pass_reset', '');
        $sql->addWhereOpr('user_id', $userId);
        DB::query($sql->get(dsn()), 'exec');
        ACMS_RAM::user($userId, null); // 更新後の値を読み直せるようキャッシュを破棄
    }

    /**
     * ログインセッションを生成する。
     *
     * グローバル関数 generateSession() への薄いラッパー。generateSession() は cookie 発行や
     * REMOTE_ADDR 参照などの副作用を持つため、テストではサブクラスでこのメソッドを override して
     * 副作用を回避するための seam として機能する（authenticateUser() 本体をテスト可能に保つ）。
     *
     * @param int $userId
     * @return void
     */
    protected function createAuthSession(int $userId): void
    {
        generateSession($userId);
    }

    /**
     * 一般サインインできる権限を取得
     *
     * @return string[]
     */
    public function getSinginAuth()
    {
        if (config('signin_page_auth') === 'contributor') {
            return ['contributor', 'subscriber'];
        }
        return ['subscriber'];
    }

    /**
     * 管理ログインできる権限を取得
     *
     * @return string[]
     */
    public function getAdminLoginAuth()
    {
        if (config('signin_page_auth') === 'contributor') {
            return ['administrator', 'editor'];
        }
        return ['administrator', 'editor', 'contributor'];
    }

    /**
     * ログアウト時のリダイレクト先URLを取得
     *
     * @param int $userId
     * @return string
     */
    public function getLogoutRedirectUrl(int $userId): string
    {
        if ($userId < 1) {
            throw new \InvalidArgumentException('Invalid user id.');
        }

        $logoutRedirectPage = config('logout_redirect_page', 'top');

        if ($logoutRedirectPage === 'top') {
            return acmsLink([
                'bid' => BID,
            ]);
        }

        if ($logoutRedirectPage === 'auth') {
            $auth = ACMS_RAM::userAuth($userId);
            if (in_array($auth, $this->getSinginAuth(), true)) {
                return acmsLink([
                    'bid' => BID,
                    'signin' => true,
                ]);
            };

            return acmsLink([
                'bid' => BID,
                'login' => true,
            ]);
        }

        return acmsLink([
            'bid' => BID,
        ]);
    }

    /**
     * ログイン許可端末のチェック
     *
     * @param array $user
     * @return bool
     */
    public function checkAllowedDevice(array $user): bool
    {
        if (
            1
            && $user['user_auth'] !== 'administrator'
            && isset($user['user_login_terminal_restriction'])
            && $user['user_login_terminal_restriction'] === 'on'
        ) {
            $cookie =& Field::singleton('cookie');
            if ($cookie->get('acms_config_login_terminal_restriction') !== sha1('permission' . UA)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 画像URIから画像を生成
     *
     * @param string $imageUri 画像URL
     * @return string 画像パス
     */
    public function userIconFromUri(string $imageUri): string
    {
        $imgPath = '';
        try {
            $rsrc = file_get_contents($imageUri);
            $imgPath = PublicStorage::archivesDir() . uniqueString() . '.jpg';
            PublicStorage::makeDirectory(dirname(ARCHIVES_DIR . $imgPath));
            if ($rsrc) {
                PublicStorage::put(ARCHIVES_DIR . $imgPath, $rsrc);
                $resizePath = PublicStorage::archivesDir() . 'square64-' . uniqueString() . '.jpg';
                Image::copyImage(ARCHIVES_DIR . $imgPath, ARCHIVES_DIR . $resizePath, 64, 64, 64);
                PublicStorage::remove(ARCHIVES_DIR . $imgPath);
                $imgPath = $resizePath;
            }
        } catch (\Exception $e) {
            // ToDo: ログ仕込み
        }
        return $imgPath;
    }

    /**
     * ログインリダイレクト処理
     *
     * @param array $user
     * @param ?string $fieldRedirectUrl
     */
    public function loginRedirect(array $user, $fieldRedirectUrl = null)
    {
        $redirectBid = BID;
        $bid = intval($user['user_blog_id']);
        if (('on' === $user['user_login_anywhere'] || roleAvailableUser()) && !isBlogAncestor(BID, $bid, true)) {
            $redirectBid = $bid;
        }

        // セッションに保存されたリダイレクト先
        $phpSession = Session::handle();
        $sessionRedirectUrl = $phpSession->get('acms-login-redirect');
        if ($sessionRedirectUrl) {
            $phpSession->delete('acms-login-redirect');
            $phpSession->save();
            redirect($sessionRedirectUrl);
        }

        // リダイレクト指定（パス指定であること）
        if ($fieldRedirectUrl && !preg_match('@^https?://@', $fieldRedirectUrl)) {
            if (preg_match('/^(.[^?]+)(.*)$/', $fieldRedirectUrl, $matches)) {
                $path = $matches[1];
                $query_hash = $matches[2];
            } else {
                $path = $fieldRedirectUrl;
                $query_hash = '';
            }
            $path = ltrim($path, '/');
            $url = (SSL_ENABLE ? 'https' : 'http') . '://'
                . HTTP_HOST . '/'
                . $path
                . (!empty($query_hash) ? $query_hash : '');

            // WHATWG パーサで host を ASCII IDN に正規化して抽出することで、
            // 日本語パスを含む同一ドメイン URL でもリダイレクトが正常に動作し、
            // かつパーサのレニエンシーを利用したオープンリダイレクト攻撃を防ぐ
            $redirect_host = WhatWgUrl::parse($url)?->getAsciiHost();
            if (strtolower(HTTP_HOST) === $redirect_host) {
                $url = htmlspecialchars_decode($url);
                redirect($url);
            }
        }

        // 現在のURLにログイン
        if (config('login_auto_redirect') === 'on') {
            $path = rtrim(DIR_OFFSET, '/') . REQUEST_PATH;
            $path = preg_replace('@' . LOGIN_SEGMENT . '$@', '', $path);
            $path = preg_replace('@' . SIGNIN_SEGMENT . '$@', '', $path);
            $query_hash = $_SERVER['QUERY_STRING'];
            $path = ltrim($path ?? '', '/');
            $url = (SSL_ENABLE ? 'https' : 'http') . '://'
                . HTTP_HOST . '/'
                . $path
                . (!empty($query_hash) ? '?' . $query_hash : '');
            redirect($url);
        }

        // 管理ページ内にリダイレクト
        $admin = config('login_admin_path');
        if ($admin && $user['user_auth'] !== 'subscriber') {
            $url = acmsLink([
                'protocol' => SSL_ENABLE ? 'https' : 'http',
                'bid' => $redirectBid,
                'admin' => $admin,
            ], false);
            redirect($url);
        }

        // 通常のブログのトップページにリダイレクト
        $url = acmsLink([
            'protocol' => SSL_ENABLE ? 'https' : 'http',
            'bid' => $redirectBid,
            'query' => [],
        ]);
        redirect($url);
    }

    /**
     * ユーザーアイコンのサイズを変更
     *
     * @param string $squarePath
     * @return string
     */
    public function resizeUserIcon(string $squarePath): ?string
    {
        if (empty($squarePath)) {
            return null;
        }
        $path = normalSizeImagePath($squarePath);
        $size = intval(config('user_icon_size', 255));
        $iconPath = trim(dirname($path), '/') . '/square-' . LocalStorage::mbBasename($path);
        Image::copyImage(ARCHIVES_DIR . $squarePath, ARCHIVES_DIR . $iconPath, $size, $size, $size);

        return $iconPath;
    }

    /**
     * ログインしている場合、権限のCookieを追加
     *
     * @param int $uid
     * @return void
     */
    public function addExtraLoggedInCookie(int $uid): void
    {
        if (config('extra_logged_in_cookie') !== 'on') {
            return;
        }
        $name = config('extra_logged_in_cookie_name', 'acms-logged-in');
        acmsSetCookie($name, ACMS_RAM::userAuth($uid));
    }

    /**
     * ログインしていない時、追加されるCookieを削除
     *
     * @return void
     */
    public function removeExtraLoggedInCookie(): void
    {
        if (config('extra_logged_in_cookie') !== 'on') {
            return;
        }
        $name = config('extra_logged_in_cookie_name', 'acms-logged-in');
        $cookie = Application::getCookieParameter();
        if ($cookie->get($name)) {
            acmsSetCookie($name, null, REQUEST_TIME - 1);
        }
    }

    /**
     * ログインセッションに付随するクライアント情報を更新
     *
     * @param int $uid
     * @return void
     */
    public function updateSessionClientInfo(int $uid): void
    {
        $session = Session::handle();
        $sessionId = $session->getSessionId();

        $sql = SQL::newDelete('user_session');
        $sql->addWhereOpr('user_session_uid', $uid);
        if ($host = getCookieHost()) {
            $sql->addWhereOpr('user_session_host', $host);
        }
        DB::query($sql->get(dsn()), 'exec');

        $sql = SQL::newInsert('user_session');
        $sql->addInsert('user_session_uid', $uid);
        if ($host = getCookieHost()) {
            $sql->addInsert('user_session_host', $host);
        }
        $sql->addInsert('user_session_address', REMOTE_ADDR);
        $sql->addInsert('user_session_id', $sessionId);
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * シークレットコンテンツへのアクセスに認証が必要か判定
     *
     * シークレットブログ/カテゴリーにアクセスする際、
     * 以下のいずれかに該当する場合は認証（ログイン/サインイン）が必要:
     * - プレビュー共有URLではない
     * - ログインしていない
     * - 認証系ページの場合
     * - 拡張機能による特殊な認証をおこなるためのページの場合
     *
     * @param string $admin 管理画面パス
     * @return bool true: 認証が必要, false: アクセス可能（認証不要）
     */
    private function requiresAuthenticationForSecretContent(string $admin): bool
    {
        if (Preview::isPreviewShareAdmin($admin)) {
            // プレビュー共有URLの場合は認証不要
            return false;
        }

        if ($this->isLoggedIn()) {
            // ログイン済みの場合は認証不要
            return false;
        }
        if (defined('IS_AUTH_SYSTEM_PAGE') && IS_AUTH_SYSTEM_PAGE) {
            // 認証系ページの場合は認証不要
            return false;
        }

        if (defined('IS_OTHER_LOGIN') && IS_OTHER_LOGIN) {
            // 拡張機能による特殊な認証をおこなるためのページの場合は認証不要
            return false;
        }

        // 上記のいずれにも該当しない場合は認証が必要
        return true;
    }
}
