<?php

declare(strict_types=1);

namespace Acms\Services\HtmlPurifier;

/**
 * iframe のホスト whitelist と、ホストごとの capability 許可リスト（allow / sandbox）を表す値オブジェクト。
 *
 * config（1行1ホストのパイプ区切り `host[|allow[|sandbox]]`）を正規化して保持し、
 * src からホストを解決して「そのホストで編集者が要求できる capability の上限（許可リスト）」を持つ。
 * 実際に付与される値は、編集者が iframe に書いた allow / sandbox のうち、この許可リストに
 * 含まれるトークンだけを残したもの（フィルタ）で、{@see IframePolicyTransform} を通じて付与される。
 * 許可リストに無いトークンは常に落ち、編集者が何も書かなければ何も付与されない（最小権限）。
 */
final class IframePolicy
{
    /**
     * キャッシュ世代。属性定義・ポリシー解釈の実装を変更したときに増やすと
     * HTML.DefinitionRev が変わり、HTMLPurifier のキャッシュが再生成される。
     */
    private const CACHE_VERSION = 1;

    /**
     * 許可する Permissions-Policy 機能トークン（allow 属性）。
     * config で指定された未知トークンはここで弾く（ポリシー外の付与を防ぐ）。
     *
     * @var string[]
     */
    private const ALLOWED_FEATURES = [
        'accelerometer',
        'autoplay',
        'camera',
        'clipboard-read',
        'clipboard-write',
        'display-capture',
        'encrypted-media',
        'fullscreen',
        'geolocation',
        'gyroscope',
        'magnetometer',
        'microphone',
        'midi',
        'payment',
        'picture-in-picture',
        'screen-wake-lock',
        'web-share',
        'xr-spatial-tracking',
    ];

    /**
     * 許可する sandbox トークン。config で指定された未知トークンはここで弾く。
     *
     * @var string[]
     */
    private const ALLOWED_SANDBOX_TOKENS = [
        'allow-downloads',
        'allow-forms',
        'allow-modals',
        'allow-orientation-lock',
        'allow-pointer-lock',
        'allow-popups',
        'allow-popups-to-escape-sandbox',
        'allow-presentation',
        'allow-same-origin',
        'allow-scripts',
        'allow-storage-access-by-user-activation',
        'allow-top-navigation',
        'allow-top-navigation-by-user-activation',
        'allow-top-navigation-to-custom-protocols',
    ];

    /**
     * 正規化済みのホストポリシー。
     *
     * @var list<array{host: string, allow: list<string>, sandbox: list<string>}>
     */
    private array $entries;

    /**
     * @param list<array{host: string, allow: list<string>, sandbox: list<string>}> $entries
     */
    public function __construct(array $entries)
    {
        $this->entries = $entries;
    }

    /**
     * config の `html_purifier_iframe_allowed_hosts` からポリシーを生成する。
     *
     * @return self
     */
    public static function fromConfig(): self
    {
        return self::fromLines(configArray('html_purifier_iframe_allowed_hosts'));
    }

    /**
     * 1行1ホストのパイプ区切り定義からポリシーを生成する。
     *
     * 書式: `host[|allow[|sandbox]]`
     *  - allow:   セミコロン区切りの Permissions-Policy 機能トークン（例: `fullscreen; encrypted-media`）
     *  - sandbox: スペース区切りの sandbox トークン（例: `allow-scripts allow-same-origin`）
     *
     * @param array<array-key, mixed> $lines
     * @return self
     */
    public static function fromLines(array $lines): self
    {
        $entries = [];
        foreach ($lines as $line) {
            if (!is_string($line)) {
                continue;
            }
            $parts = explode('|', $line);
            $host = trim($parts[0]);
            if ($host === '') {
                continue;
            }
            $entries[] = [
                'host' => $host,
                'allow' => self::filterTokens($parts[1] ?? '', ';', self::ALLOWED_FEATURES),
                'sandbox' => self::filterTokens($parts[2] ?? '', ' ', self::ALLOWED_SANDBOX_TOKENS),
            ];
        }
        return new self($entries);
    }

    /**
     * iframe の許可（ホストが1つ以上ある）か。
     *
     * @return bool
     */
    public function isEnabled(): bool
    {
        return $this->entries !== [];
    }

    /**
     * 許可ホスト名の一覧（URI.SafeIframeRegexp 生成に用いる）。
     *
     * @return list<string>
     */
    public function hosts(): array
    {
        return array_map(static fn(array $entry): string => $entry['host'], $this->entries);
    }

    /**
     * src にマッチするホストの許可リストで、編集者が要求した allow をフィルタする。
     *
     * @param string $src iframe の src
     * @param string $rawAllow 編集者が iframe に書いた allow の生値（セミコロン区切り）
     * @return string|null 付与する allow 値（要求が無い／全て許可リスト外なら null）
     */
    public function resolveAllow(string $src, string $rawAllow): ?string
    {
        $entry = $this->resolve($src);
        if ($entry === null) {
            return null;
        }
        $tokens = self::filterTokens($rawAllow, ';', $entry['allow']);
        return $tokens === [] ? null : implode('; ', $tokens);
    }

    /**
     * src にマッチするホストの許可リストで、編集者が要求した sandbox をフィルタする。
     *
     * @param string $src iframe の src
     * @param string $rawSandbox 編集者が iframe に書いた sandbox の生値（スペース区切り）
     * @return string|null 付与する sandbox 値（要求が無い／全て許可リスト外なら null）
     */
    public function resolveSandbox(string $src, string $rawSandbox): ?string
    {
        $entry = $this->resolve($src);
        if ($entry === null) {
            return null;
        }
        $tokens = self::filterTokens($rawSandbox, ' ', $entry['sandbox']);
        return $tokens === [] ? null : implode(' ', $tokens);
    }

    /**
     * ポリシー内容から決定的に算出する整数リビジョン。
     * HTML.DefinitionRev に用い、ポリシー変更時にキャッシュを再生成させる。
     *
     * @return int
     */
    public function revision(): int
    {
        $json = json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        // 32-bit 環境でも常に正値になるよう最上位ビットを落とす（HTML.DefinitionRev は差分が出れば足りる）
        return crc32(self::CACHE_VERSION . '|' . ($json === false ? '' : $json)) & 0x7fffffff;
    }

    /**
     * src にマッチする最初のホストポリシーを返す。
     *
     * @param string $src
     * @return array{host: string, allow: list<string>, sandbox: list<string>}|null
     */
    private function resolve(string $src): ?array
    {
        foreach ($this->entries as $entry) {
            if (SafeIframeRegexp::matches($src, $entry['host'])) {
                return $entry;
            }
        }
        return null;
    }

    /**
     * 区切り文字で分割したトークンのうち、許可リストに含まれるものだけを返す。
     *
     * @param string $raw
     * @param non-empty-string $delimiter
     * @param string[] $allowedTokens
     * @return list<string>
     */
    private static function filterTokens(string $raw, string $delimiter, array $allowedTokens): array
    {
        $result = [];
        foreach (explode($delimiter, $raw) as $token) {
            $token = trim($token);
            if ($token !== '' && in_array($token, $allowedTokens, true) && !in_array($token, $result, true)) {
                $result[] = $token;
            }
        }
        return $result;
    }
}
