<?php

declare(strict_types=1);

namespace Acms\Services\HtmlPurifier;

/**
 * iframe のホスト whitelist と、ホストごとの capability ポリシー（allow / sandbox）を表す値オブジェクト。
 *
 * config（1行1ホストのパイプ区切り `host[|allow[|sandbox]]`）を正規化して保持し、
 * src からホストを解決して「そのホストに与える固定の allow / sandbox 値」を返す。
 * 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
     * @return string|null 付与する allow 値（無ければ null）
     */
    public function resolveAllow(string $src): ?string
    {
        $entry = $this->resolve($src);
        if ($entry === null || $entry['allow'] === []) {
            return null;
        }
        return implode('; ', $entry['allow']);
    }

    /**
     * src にマッチするホストの sandbox 固定値を返す。
     *
     * @param string $src iframe の src
     * @return string|null 付与する sandbox 値（無ければ null）
     */
    public function resolveSandbox(string $src): ?string
    {
        $entry = $this->resolve($src);
        if ($entry === null || $entry['sandbox'] === []) {
            return null;
        }
        return implode(' ', $entry['sandbox']);
    }

    /**
     * ポリシー内容から決定的に算出する整数リビジョン。
     * 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;
    }
}
