<?php

namespace Acms\Services\Htmx;

use Acms\Services\Common\HookFactory;

class Helper
{
    /**
     * ページ単位のオプトアウト宣言 <meta name="acms-htmx" content="disable"> の検出パターン。
     * 属性順の入れ替えに対応するため、meta タグを抽出したうえで name / content を個別に照合する。
     */
    private const OPT_OUT_META_TAG = '/<meta\b[^>]*>/i';
    private const OPT_OUT_ATTR_NAME = '/\bname\s*=\s*(?:"acms-htmx"|\'acms-htmx\'|acms-htmx(?=[\s\/>]))/i';
    private const OPT_OUT_ATTR_CONTENT = '/\bcontent\s*=\s*(?:"disable"|\'disable\'|disable(?=[\s\/>]))/i';

    /**
     * テンプレートに書かれた meta[name="htmx-config"] の検出パターン。
     */
    private const TEMPLATE_CONFIG_META = '/<meta\b[^>]*\bname\s*=\s*(?:"htmx-config"|\'htmx-config\'|htmx-config(?=[\s\/>]))[^>]*>/i';
    private const TEMPLATE_CONFIG_CONTENT = '/\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s\/>]+))/i';

    /**
     * DOM解析ではなく部分文字列で判定する。誤検出時のコストが「htmxを余分に読み込む」だけで
     * 無害な一方、全リクエストで走る軽量な真偽判定にDOMDocumentは不釣り合いに重いため。
     *
     * @param string $html レンダリング済みの出力 HTML
     * @param string $strategy config('htmx_load_strategy') の値（static | dynamic | off）
     * @return bool
     */
    public function shouldLoadHtmxStatically(string $html, string $strategy): bool
    {
        if ($strategy !== 'static') {
            return false;
        }
        if ($html === '') {
            return false;
        }
        if ($this->isOptedOut($html)) {
            return false;
        }
        foreach ($this->marks() as $mark) {
            if (strpos($html, $mark) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * オプトアウト宣言 <meta name="acms-htmx" content="disable"> をサーバー側で注入するか判定する。
     *
     * off 戦略は静的読み込みをしないだけでなく、バンドルJSによる遅延読み込みも止める必要がある。
     * built-in/dispatch.js の遅延読み込みは「戦略が dynamic のとき」ではなく「window.htmx が
     * 無いとき」に動くため、CMS が script タグを出さない off ではむしろ発動してしまう。
     * PHP から ACMS.Config へ値を渡す経路が無いので、テンプレート制作者が書くのと同じ
     * オプトアウト宣言を注入して JS 側の判定に伝える。
     *
     * @param string $html レンダリング済みの出力 HTML
     * @param string $strategy config('htmx_load_strategy') の値（static | dynamic | off）
     * @return bool
     */
    public function shouldInjectOptOutMeta(string $html, string $strategy): bool
    {
        if ($strategy !== 'off') {
            return false;
        }
        return !$this->isOptedOut($html);
    }

    /**
     * @param string $html レンダリング済みの出力 HTML
     * @return bool
     */
    private function isOptedOut(string $html): bool
    {
        // 大半のページは 'acms-htmx' を含まないため、正規表現を走らせる前に部分文字列で打ち切る
        if ($html === '' || strpos($html, 'acms-htmx') === false) {
            return false;
        }
        if (preg_match_all(self::OPT_OUT_META_TAG, $html, $matches) === 0) {
            return false;
        }
        foreach ($matches[0] as $tag) {
            if (preg_match(self::OPT_OUT_ATTR_NAME, $tag) !== 1) {
                continue;
            }
            if (preg_match(self::OPT_OUT_ATTR_CONTENT, $tag) === 1) {
                return true;
            }
        }
        return false;
    }

    /**
     * @return string[]
     */
    private function marks(): array
    {
        $marks = configArray('htmx_marks');
        return $marks ? $marks : ['acms-htmx', 'hx-get', 'hx-post'];
    }

    /**
     * @param string $tpl レンダリング済みの出力 HTML
     * @return void
     */
    public function emitPushUrlHeader(string $tpl): void
    {
        $header = $this->resolvePushUrlHeader($tpl);
        if ($header !== null) {
            header($header);
        }
    }

    /**
     * @param string $tpl レンダリング済みの出力 HTML
     * @return string|null
     */
    public function resolvePushUrlHeader(string $tpl): ?string
    {
        if ($tpl === '' || strpos($tpl, config('htmx_ss_push_url_mark', 'data-acms-hx-push-url')) === false) {
            return null;
        }
        $displayUrl = acmsLink([
            'tpl' => '',
        ], true, true, false, false);
        return "HX-Push-Url: {$displayUrl}";
    }

    /**
     * @param string $html レンダリング済みの出力 HTML（テンプレート側の設定を取り込むために渡す）
     * @return array<string, mixed>
     */
    public function config(string $html = ''): array
    {
        return $this->buildConfig(config('htmx_config', '{}'), $this->extractTemplateConfig($html));
    }

    /**
     * @param string $json config('htmx_config', '{}') の値
     * @param array<string, mixed> $templateConfig テンプレートの meta[name="htmx-config"] の内容
     * @return array<string, mixed>
     */
    public function buildConfig(string $json, array $templateConfig = []): array
    {
        $userConfig = json_decode($json, true);
        if (!is_array($userConfig)) {
            $userConfig = [];
        }
        // テンプレートは案件固有の指定なのでシステム設定より強い。フック（プログラムによる最終調整）
        // はさらに後、allowEval の false 強制は常に最後で上書き不可
        $config = array_merge([
            'historyCacheSize' => -1,
            'refreshOnHistoryMiss' => true,
        ], $userConfig, $templateConfig);

        $this->dispatchConfigHook($config);

        $config['allowEval'] = false; // 上書き不可

        return $config;
    }

    /**
     * テンプレートに書かれた meta[name="htmx-config"] の content を連想配列として取り出す。
     *
     * htmx 本体は meta を querySelector で 1 件だけ読むため、こちらも先頭の 1 件のみを対象にする。
     *
     * @param string $html レンダリング済みの出力 HTML
     * @return array<string, mixed> meta が無い・content が不正な JSON の場合は空配列
     */
    public function extractTemplateConfig(string $html): array
    {
        $content = $this->findTemplateConfigContent($html);
        if ($content === null) {
            return [];
        }
        $decoded = json_decode(html_entity_decode($content, ENT_QUOTES, 'UTF-8'), true);
        if (!is_array($decoded)) {
            return [];
        }
        /** @var array<string, mixed> $decoded */
        return $decoded;
    }

    /**
     * テンプレートの meta[name="htmx-config"] の content をマージ済みの設定へ書き換える。
     *
     * 差し替えではなく書き換えなのは、htmx が先頭の meta しか読まないため。コア側が別途 meta を
     * 追加しても、テンプレート側の meta が先に現れる限りサーバーの設定（allowEval の false 強制を
     * 含む）が無視されてしまう。
     *
     * @param string $html レンダリング済みの出力 HTML
     * @param array<string, mixed> $config マージ済みの設定
     * @return string|null 書き換え後の HTML。テンプレートに meta が無ければ null
     */
    public function rewriteTemplateConfigMeta(string $html, array $config): ?string
    {
        if ($html === '' || strpos($html, 'htmx-config') === false) {
            return null;
        }
        $json = json_encode($config);
        if ($json === false) {
            return null;
        }
        $encoded = htmlspecialchars($json, ENT_QUOTES, 'UTF-8');
        $count = 0;
        $result = preg_replace_callback(
            self::TEMPLATE_CONFIG_META,
            fn (array $matches): string => $this->replaceContentAttribute($matches[0], $encoded),
            $html,
            1,
            $count
        );
        if ($result === null || $count === 0) {
            return null;
        }
        return $result;
    }

    /**
     * @param string $html レンダリング済みの出力 HTML
     * @return string|null meta が無い・content 属性が無い場合は null
     */
    private function findTemplateConfigContent(string $html): ?string
    {
        if ($html === '' || strpos($html, 'htmx-config') === false) {
            return null;
        }
        if (preg_match(self::TEMPLATE_CONFIG_META, $html, $metaMatch) !== 1) {
            return null;
        }
        if (preg_match(self::TEMPLATE_CONFIG_CONTENT, $metaMatch[0], $contentMatch) !== 1) {
            return null;
        }
        // 二重引用符・単引用符・引用符なしのいずれかにマッチするため、空でない方を採る
        foreach ([1, 2, 3] as $group) {
            if (isset($contentMatch[$group]) && $contentMatch[$group] !== '') {
                return $contentMatch[$group];
            }
        }
        return '';
    }

    /**
     * @param string $tag meta タグ 1 つ分の文字列
     * @param string $encoded HTML エスケープ済みの JSON
     * @return string
     */
    private function replaceContentAttribute(string $tag, string $encoded): string
    {
        // preg_replace の置換文字列では JSON 中の \ や $ が展開されうるため callback で連結する
        if (preg_match(self::TEMPLATE_CONFIG_CONTENT, $tag) === 1) {
            $replaced = preg_replace_callback(
                self::TEMPLATE_CONFIG_CONTENT,
                fn (): string => "content='" . $encoded . "'",
                $tag,
                1
            );
            return $replaced !== null ? $replaced : $tag;
        }
        $replaced = preg_replace_callback(
            '/\s*(\/?)>$/',
            fn (array $matches): string => " content='" . $encoded . "'" . $matches[1] . '>',
            $tag,
            1
        );
        return $replaced !== null ? $replaced : $tag;
    }

    /**
     * @param array $config
     * @return void
     */
    private function dispatchConfigHook(array &$config): void
    {
        if (!defined('HOOK_ENABLE') || !HOOK_ENABLE) {
            return;
        }
        HookFactory::singleton()->call('extendsHtmxConfig', [&$config]);
    }
}
