<?php

use Acms\Services\Validator\Signin as SigninValidator;
use Acms\Services\Facades\Common;
use Acms\Services\Facades\Login;

class ACMS_POST_Member_ResetPassword extends ACMS_POST_Member
{
    use Acms\Services\Login\Traits\CreateAuthUrl;

    /**
     * 正常なルートからのPOSTかどうかをチェック
     *
     * @inheritDoc
     */
    protected function isValidPostRoute(): bool
    {
        return Login::canLoginPage(BID, RESET_PASSWORD_SEGMENT);
    }

    /**
     * トークンのキーを取得
     *
     * @return string
     */
    protected function getTokenKey(): string
    {
        return 'reset-password';
    }

    /**
     * トークンのタイプを取得
     *
     * @return string
     */
    protected function getTokenType(): string
    {
        return 'reset-password';
    }

    /**
     * 認証メールの件名テンプレートを取得
     *
     * @return string
     */
    protected function getSubjectMailTemplate(): string
    {
        return findTemplate(config('mail_reset_password_tpl_subject'));
    }

    /**
     * 認証メールの本文テンプレートを取得
     *
     * @return string
     */
    protected function getBodyMailTemplate(): string
    {
        return findTemplate(config('mail_reset_password_tpl_body'));
    }

    /**
     *　認証メールの本文（HTML）テンプレートを取得
     *
     * @return string
     */
    protected function getBodyHtmlMailTemplate(): string
    {
        return findTemplate(config('mail_reset_password_tpl_body_html'));
    }

    /**
     * 認証メールの送信元アドレスを取得
     *
     * @return string
     */
    protected function getFromAddress(): string
    {
        return config('mail_reset_password_from');
    }

    /**
     * 認証メールのBCCアドレスを取得
     *
     * @return string
     */
    protected function getBccAddress(): string
    {
        return implode(', ', configArray('mail_reset_password_bcc'));
    }

    /**
     * パスワードを使った認証かチェック
     *
     * @return bool
     */
    protected function passwordAuth(): bool
    {
        return config('email-auth-signin') !== 'on';
    }

    /**
     * Main
     *
     * @return Field_Validation
     */
    public function post(): Field_Validation
    {
        $startedAt = microtime(true);
        $this->Post->set('login', ['mail', 'To']);
        $this->Post->set('To', '&mail;');
        $inputField = $this->extract('login');
        if (!$this->passwordAuth()) {
            $inputField->setMethod('resetPassword', 'operable', false);
        }
        $this->validate($inputField);

        // 列挙対策: メールアドレスの存在・認証済みの成否は「実際に送信するか」の内部判定と
        // 管理者向けログにだけ使い、一般ユーザーへの応答には出さない。
        // 検証結果を成功側に上書きすることで、登録有無で応答が変わらないようにする。
        $mailExists = $inputField->isValid('mail', 'exist');
        $mailConfirmed = $inputField->isValid('mail', 'confirmed');
        $inputField->setValidator('mail', 'exist', true);
        $inputField->setValidator('mail', 'confirmed', true);

        // 送信した一般ユーザーには見せないが、管理者が原因を特定できるよう内部ログには残す
        if (!$mailExists) {
            AcmsLogger::notice('存在しないメールアドレスが指定されたため、パスワード再設定処理を中断しました', [
                'email' => $inputField->get('mail'),
            ]);
        }
        if (!$mailConfirmed) {
            AcmsLogger::notice('有効化されていないメールアドレスのため、パスワード再設定処理を中断しました', [
                'email' => $inputField->get('mail'),
            ]);
        }

        if (!$this->Post->isValidAll()) {
            // 残る不備は入力形式・状態のみ（メールの存在有無は明かさない）
            if (!$inputField->isValid('login', 'sessionAlready')) {
                AcmsLogger::notice('すでにログイン中のため、パスワード再設定処理を中断しました', [
                    'email' => $inputField->get('mail'),
                ]);
            }
            if (!$inputField->isValid('mail', 'required')) {
                AcmsLogger::info('メールアドレスが指定されていないため、パスワード再設定処理を中断しました');
            }
            $this->equalizeResponseTime($startedAt);
            return $this->Post;
        }

        // 以降は登録有無に関わらず応答を「送信しました」に統一する。送信可能な場合のみ実際に送信する。
        if ($mailExists && $mailConfirmed) {
            $token = $this->createToken();
            $lifetime = intval(config('password_reset_url_lifetime', 30)) * 60;
            // exist/confirmed の検索は user_mail の DB 照合順序（大文字小文字非依存が一般的）に
            // 従うが、token_data の一致判定は文字列の完全一致のため、大文字小文字だけが異なる
            // 入力を同一宛先として無効化できるよう、ここで正規化してから token_data に載せる。
            $data = [
                'email' => mb_strtolower(trim($inputField->get('mail'))),
            ];
            // 再発行時は同一宛先の既存トークンを無効化し、複数リンクが同時に有効になるのを防ぐ。
            // token_data は saveToken() と同一エンコードで完全一致させる必要がある。
            $tokenData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if ($tokenData !== false) {
                Login::invalidateAuthTokens($this->getTokenKey(), $this->getTokenType(), $tokenData);
            }
            $authUrl = $this->getAuthUrl($token, $data, $lifetime);

            // メール送信（成否に関わらず応答は変えず、内部ログにのみ残す）
            try {
                $isSend = $this->send($inputField->get('mail'), $inputField, $authUrl);
                if ($isSend) {
                    AcmsLogger::info('パスワード再設定メールを送信しました', $data);
                } else {
                    AcmsLogger::warning('パスワード再設定メールの送信に失敗しました（応答は成功と同一）', $data);
                }
            } catch (Exception $e) {
                AcmsLogger::warning('パスワード再設定メールの送信に失敗しました（応答は成功と同一）', Common::exceptionArray($e, [
                    'email' => $inputField->get('mail'),
                ]));
            }
        }
        // 送信できない場合（存在しない・未認証）の理由は上で管理者向けにログ済み。
        // 一般ユーザーへの応答は送信時と同一に保つ。
        $this->equalizeResponseTime($startedAt);
        return $this->Post;
    }

    /**
     * 応答時間を一定の下限まで引き伸ばし、処理経路の違いによるタイミング差を隠す（列挙対策）。
     *
     * @param float $startedAt post() 開始時刻（microtime(true)）
     * @return void
     */
    private function equalizeResponseTime(float $startedAt): void
    {
        $floorSeconds = 0.3;
        $elapsed = microtime(true) - $startedAt;
        if ($elapsed < $floorSeconds) {
            usleep((int) (($floorSeconds - $elapsed) * 1000000));
        }
    }

    /**
     * 認証URLを取得
     *
     * @param string $token
     * @param array $data
     * @param int $lifetime
     * @return string
     */
    protected function getAuthUrl(string $token, array $data, int $lifetime): string
    {
        return $this->createAuthUrl([
            'bid' => BID,
            'reset-password-auth' => true,
        ], $token, $data, $lifetime);
    }

    /**
     * バリデーション
     *
     * @param Field_Validation $inputField
     * @return void
     */
    protected function validate(Field_Validation $inputField): void
    {
        if (!Login::canMemberSignin()) {
            $inputField->setMethod('resetPassword', 'operable', false);
            httpStatusCode('403 Forbidden');
        }
        $inputField->setMethod('login', 'sessionAlready', !Login::isLoggedIn());
        $inputField->setMethod('mail', 'required');
        $inputField->setMethod('mail', 'exist');
        $inputField->setMethod('mail', 'confirmed');
        $inputField->validate(new SigninValidator());
    }
}
