<?php

declare(strict_types=1);

namespace Acms\Services\License;

use Acms\Services\License\Exceptions\LicenseResponseException;

/**
 * アクティベーションサーバーからのライセンス応答 JSON を検証する。
 *
 * Application::loadLicense() は license.php を require した「後」に LICENSE_HASH の照合を行うため、
 * require より前（= license.php を書き出す時点）の値検証が唯一の防御層になる。
 * このクラスはグローバル状態（AcmsLogger 等）に触れない純粋な検証本体として切り出し、
 * ログ記録は呼び出し側（Application）に閉じる。
 *
 * PHP ソース注入は LicenseFileContentBuilder の var_export() 化で完全に閉じているため、ここでの検証は
 * 「注入の第2防衛線」と「明らかに壊れた応答の早期検出」を目的とし、業務ルールの強制ではない。
 * 業務ルールを強制すると、サーバー側の値が想定と1つ違うだけで全ユーザーのアクティベーションが止まる。
 * コードベースが実際に消費している値の集合を追跡できない項目については拒否ではなく既存の
 * グレースフルデグレード（例: edition の switch の default 分岐）に揃える。
 *
 * 応答の実形式はアクティベーションサーバー（mypage の Api\LicenseActivation::activation）の実装で確認済み。
 * expire は 'Y-m-d H:i:s'、blog_limit / option.* は plans の int カラム、payment は cancel / delete が
 * 応答前に例外になるため active / unpaid の2値、plan は plans.name の表示名（日本語）。
 * 固定用の特徴付けテストは LicenseResponseValidatorTest::actualServerResponseProvider にある。
 */
final class LicenseResponseValidator
{
    /**
     * @var list<string>
     *
     * 導出根拠: ablogcms/php/ACMS/GET/Admin/Dashboard/LicenseStatus.php の switch (LICENSE_EDITION) が
     * 'enterprise' / 'professional' / 'standard' の3値を分岐条件にし、default で standard 相当に
     * グレースフルデグレードしている。ablogcms/php/ACMS/function.php の isProfessional() (1158行目付近) /
     * isEnterprise() (1168行目付近) も 'professional' / 'enterprise' のみを比較する。
     * ここでの判定も同じ挙動に揃え、ホワイトリスト外の値は拒否せず standard にフォールバックする
     * （将来サーバー側に新エディションが追加された場合に既存 CMS を一斉停止させないため）。
     */
    private const ALLOWED_EDITIONS = ['standard', 'professional', 'enterprise'];

    /**
     * @var list<string>
     *
     * 導出根拠: ablogcms/themes/system/admin/checklist.html および
     * ablogcms/themes/system/admin/_layouts/top.html の %{SUBSCRIPTION_PAYMENT} が
     * `/eq/active` と `/eq/unpaid` の2値をテンプレート分岐条件にしている。
     * ablogcms/php/ACMS/function.php:6100 の
     * '%{SUBSCRIPTION_PAYMENT}' => ... LICENSE_PAYMENT ? LICENSE_PAYMENT : '' が falsy（空文字含む）を
     * 許容しているため、空文字も許可値に加える。
     */
    private const ALLOWED_PAYMENTS = ['active', 'unpaid', ''];

    /**
     * domain は LICENSE_DOMAIN として「登録済みライセンスドメイン（親）」に使われ、実アクセス host が
     * そのサブドメインでよい設計（ablogcms/php/main.php:1321 の substr によるサフィックス一致判定、
     * ablogcms/php/Services/Blog/Helper.php の strpos 判定）のため、要求時に送った DOMAIN との完全一致は
     * 強制しない。ここでは PHP ソース注入に使われる文字（引用符・バックスラッシュ等）を
     * 除いた文字種のみ検証する。
     */
    private const DOMAIN_PATTERN = '/\A[A-Za-z0-9._\-:]{1,255}\z/';

    /**
     * edition の文字種検証。ホワイトリスト判定の前段として、危険な文字列でないことだけを確認する。
     */
    private const EDITION_PATTERN = '/\A[a-z0-9_-]{1,32}\z/';

    /**
     * plan には分岐条件として消費されている値の集合が存在しない（class docblock 参照）ため、
     * 人間可読なプラン名（日本語・空白・ピリオド等）を広く許可しつつ、PHP ソース注入および
     * %{SUBSCRIPTION_PLAN} 経由の HTML 注入に使われる文字だけを除外する。
     * ACMS/function.php の setGlobalVars() は %{...} 変数を json_decode('"' . $val . '"') に通して
     * バックスラッシュエスケープを展開してから str_replace で生置換するため、バックスラッシュは
     * 特に必須で除外する。
     */
    private const PLAN_PATTERN = '/\A[^<>"\'\\\\{}\x00\r\n]{1,255}\z/u';

    /**
     * UNLIMITED_NUMBER_OF_USERS（Application::__construct で定義される定数）と同じ意味の値だが、
     * このクラスをグローバル定数から独立させるためにここで複製している。
     */
    private const MAX_BLOG_LIMIT = 2147483647;

    /**
     * @param mixed $json json_decode() の戻り値。int/array/string/null 等、object 以外も想定して受け取る
     * @return array{
     *     domain: string,
     *     hash: string,
     *     blogLimit: int,
     *     expire: string,
     *     plan: string,
     *     payment: string,
     *     edition: string,
     *     optionSubdomain: int|null,
     *     optionPlusdomain: int
     * }
     */
    public static function validate($json): array
    {
        // json_decode('123') のように object 以外が渡ってくると property_exists() が TypeError になるため、
        // メソッドの型で縛らずここで先に判定する（この判定が無かったことが今回の不具合の一因）。
        // json_decode() が返す object は常に stdClass なので instanceof で判定し、以降の呼び出しを
        // stdClass として静的解析に伝える（is_object() では object としてしか narrow されない）。
        if (!($json instanceof \stdClass)) {
            throw new LicenseResponseException('license response is not an object');
        }

        $option = self::validateOption($json);

        return [
            'domain' => self::validateDomain($json),
            'hash' => self::validateHash($json),
            'blogLimit' => self::validateBlogLimit($json),
            'expire' => self::validateExpire($json),
            'plan' => self::validatePlan($json),
            'payment' => self::validatePayment($json),
            'edition' => self::validateEdition($json),
            'optionSubdomain' => $option['subdomain'],
            'optionPlusdomain' => $option['plusdomain'],
        ];
    }

    private static function validateDomain(\stdClass $json): string
    {
        if (!property_exists($json, 'domain')) {
            throw new LicenseResponseException('domain is missing');
        }
        $domain = $json->domain;
        if (!is_string($domain) || preg_match(self::DOMAIN_PATTERN, $domain) !== 1) {
            throw new LicenseResponseException('domain is not a valid string');
        }
        return $domain;
    }

    private static function validateHash(\stdClass $json): string
    {
        if (!property_exists($json, 'hash')) {
            throw new LicenseResponseException('hash is missing');
        }
        $hash = $json->hash;
        if (!is_string($hash) || $hash === '') {
            throw new LicenseResponseException('hash is not a valid string');
        }
        return $hash;
    }

    private static function validateBlogLimit(\stdClass $json): int
    {
        if (!property_exists($json, 'blog_limit')) {
            throw new LicenseResponseException('blog_limit is missing');
        }
        $blogLimit = self::toNonNegativeInt($json->blog_limit);
        if ($blogLimit === null || $blogLimit > self::MAX_BLOG_LIMIT) {
            throw new LicenseResponseException('blog_limit is not a valid integer');
        }
        return $blogLimit;
    }

    /**
     * 旧実装は引用符なしの数値連結だったため、サーバーが数値文字列（例: "10"）を返しても
     * 正しい PHP として動作していた。int 型のみを要求すると、これまで動いていた応答形式を
     * 拒否してしまう可能性があるため、数値文字列も受理して int にキャストする。
     * licenseHasUnlimitedUsers()（ACMS/function.php）が LICENSE_BLOG_LIMIT を strict 比較するため、
     * 型を int に統一することは必須。
     *
     * @param mixed $value
     */
    private static function toNonNegativeInt($value): ?int
    {
        if (is_int($value)) {
            return $value >= 0 ? $value : null;
        }
        if (is_string($value) && preg_match('/\A\d{1,10}\z/', $value) === 1) {
            return (int) $value;
        }
        return null;
    }

    private static function validateExpire(\stdClass $json): string
    {
        if (!property_exists($json, 'expire')) {
            throw new LicenseResponseException('expire is missing');
        }
        $expire = $json->expire;
        if ($expire === null) {
            // 旧実装は $json->expire を直接文字列連結していたため、null は空文字として書き出されていた
            // (var_export(null, true) の 'NULL' に変えると main.php の is_null(LICENSE_EXPIRE) 分岐が
            // 新たに真になり、期限チェックが完全にスキップされて安全側でなくなる)。旧挙動を維持する。
            return '';
        }
        if (!is_string($expire)) {
            throw new LicenseResponseException('expire is not a string');
        }
        if ($expire === '') {
            return '';
        }
        // main.php:1323 は LICENSE_EXPIRE を date('Y-m-d H:i:s', ...) と比較し、
        // LicenseStatus.php:40 は strtotime(LICENSE_EXPIRE) で表示に使っている。つまり消費側は
        // 'Y-m-d' 固定を前提にしておらず、時刻付き・ISO8601 等も読める。日付として解釈可能かどうかだけを
        // 確認し、$expire の値自体は書き換えずそのまま書き出す。
        if (strtotime($expire) === false) {
            throw new LicenseResponseException('expire is not a recognizable date string');
        }
        return $expire;
    }

    private static function validatePlan(\stdClass $json): string
    {
        if (!property_exists($json, 'plan')) {
            throw new LicenseResponseException('plan is missing');
        }
        $plan = $json->plan;
        if (!is_string($plan) || preg_match(self::PLAN_PATTERN, $plan) !== 1) {
            throw new LicenseResponseException('plan contains a disallowed character or is not a string');
        }
        return $plan;
    }

    private static function validatePayment(\stdClass $json): string
    {
        if (!property_exists($json, 'payment')) {
            throw new LicenseResponseException('payment is missing');
        }
        $payment = $json->payment;
        if (!is_string($payment) || !in_array($payment, self::ALLOWED_PAYMENTS, true)) {
            throw new LicenseResponseException('payment is not an allowed value');
        }
        return $payment;
    }

    private static function validateEdition(\stdClass $json): string
    {
        if (!property_exists($json, 'edition')) {
            throw new LicenseResponseException('edition is missing');
        }
        $edition = $json->edition;
        if ($edition === null) {
            // plans.edition は nullable なので、応答が null を返しうる。旧実装は edition を
            // 引用符付きで連結していたため null は空文字として書き出され、switch の default 分岐で
            // standard 相当に縮退していた。null を拒否に変えると従来動いていたライセンスが
            // アクティベートできなくなるため、旧挙動と同じ縮退にそろえる。
            return 'standard';
        }
        if (!is_string($edition) || preg_match(self::EDITION_PATTERN, $edition) !== 1) {
            throw new LicenseResponseException('edition contains a disallowed character or is not a string');
        }
        if (!in_array($edition, self::ALLOWED_EDITIONS, true)) {
            // switch (LICENSE_EDITION)（LicenseStatus.php）の default 分岐と同じ挙動に揃え、
            // 未知の edition は拒否せず standard 相当にグレースフルデグレードする
            return 'standard';
        }
        return $edition;
    }

    /**
     * @return array{subdomain: int|null, plusdomain: int}
     */
    private static function validateOption(\stdClass $json): array
    {
        // option 欠落は現状 $json->option->subdomain のプロパティアクセスで PHP の警告・エラーになるため、
        // ここで明示的な例外にする
        if (!property_exists($json, 'option') || !is_object($json->option)) {
            throw new LicenseResponseException('option is missing');
        }
        $option = $json->option;

        if (!property_exists($option, 'subdomain')) {
            throw new LicenseResponseException('option.subdomain is missing');
        }
        $subdomain = $option->subdomain;
        // 旧実装は引用符なし連結だったため、true/false は文字列化された "1"/"" が埋め込まれて
        // 動作していた（false は define() の第2引数が空になり構文エラーになるはずなので、
        // false 自体は来ていなかったと推測できるが、true は "1" として正常動作していた可能性がある）。
        // true/false を 1/0 に正規化して受理する。
        if ($subdomain === true) {
            $subdomain = 1;
        } elseif ($subdomain === false) {
            $subdomain = 0;
        }
        if ($subdomain !== null && $subdomain !== 0 && $subdomain !== 1) {
            throw new LicenseResponseException('option.subdomain is not an allowed value');
        }

        if (!property_exists($option, 'plusdomain')) {
            throw new LicenseResponseException('option.plusdomain is missing');
        }
        $plusdomain = self::toNonNegativeInt($option->plusdomain);
        if ($plusdomain === null) {
            throw new LicenseResponseException('option.plusdomain is not a valid integer');
        }

        return [
            'subdomain' => $subdomain,
            'plusdomain' => $plusdomain,
        ];
    }
}
