<?php

declare(strict_types=1);

namespace Acms\Services\Url;

use Acms\Services\Facades\Media;
use Uri\Rfc3986\Uri as Rfc3986Uri;
use Uri\WhatWg\Url as WhatWgUrl;
use DOMDocument;

/**
 * 絶対URL / root-relative URL への変換、V2モジュール向けのURL解決を担う。
 */
class UrlResolver
{
    /**
     * @var DeliveryUrlRewriter
     */
    private $deliveryUrlRewriter;

    /**
     * @param DeliveryUrlRewriter $deliveryUrlRewriter
     */
    public function __construct(DeliveryUrlRewriter $deliveryUrlRewriter)
    {
        $this->deliveryUrlRewriter = $deliveryUrlRewriter;
    }

    /**
     * URL を完全な absolute URL（scheme + host + path）に変換する。
     *
     * グローバル状態に依存しない pure 関数（$baseUrl を省略した場合のみ BASE_URL を読む）。
     *
     * - 既に scheme 付き / protocol-relative の場合はそのまま返す。
     * - ルート相対 `/foo` は base の origin（scheme + host + port）と結合する。
     *   base のサブパス（`/site/`）は無視される。
     * - ドキュメント相対 `foo` は base 全体（origin + path）と結合する。
     *
     * @param string $path 入力パス
     * @param string $offset URL の前置オフセット（DIR_OFFSET 相当）
     * @param ?string $baseUrl 解決の基準とする base URL。null の場合は BASE_URL を使用
     */
    public function toAbsoluteUrl(
        string $path,
        string $offset = '',
        ?string $baseUrl = null,
    ): string {
        if ($path === '') {
            return '';
        }
        if ($this->isAbsoluteUri($path)) {
            return $path;
        }
        $baseUrl ??= BASE_URL;
        $offset = trim($offset, '/');
        $baseUri = Rfc3986Uri::parse($baseUrl);

        if (str_starts_with($path, '/')) {
            // ルート相対 → base の origin のみ
            $resolvedBase = $this->extractOrigin($baseUri);
        } else {
            // ドキュメント相対 → base の origin + path（query は RFC 3986 §5.2.2 に従い破棄）
            $resolvedBase = $this->extractOrigin($baseUri) . rtrim($baseUri?->getPath() ?? '', '/');
        }
        $path = ltrim($path, '/');

        return $offset !== ''
            ? "{$resolvedBase}/{$offset}/{$path}"
            : "{$resolvedBase}/{$path}";
    }

    /**
     * URL を root-relative URL（`/path` 形式、scheme/host を含まない）に変換する。
     *
     * V1 / V2 の HTML テンプレート出力で「ブラウザが現在のホストで解決する」前提の
     * URL を生成するときに使う。グローバル状態に依存しない pure 関数（$baseUrl を
     * 省略した場合のみ BASE_URL を読む）。
     *
     * - 既に scheme 付き / protocol-relative の場合はそのまま返す。
     * - ルート相対 `/foo` はそのまま返す（既に root-relative なので変換不要）。
     * - ドキュメント相対 `foo` は base の path+query 部分のみと結合する。
     *
     * @param string $path 入力パス
     * @param string $offset URL の前置オフセット（DIR_OFFSET 相当）
     * @param ?string $baseUrl 解決の基準とする base URL。null の場合は BASE_URL を使用
     */
    public function toRootRelativeUrl(
        string $path,
        string $offset = '',
        ?string $baseUrl = null,
    ): string {
        if ($path === '') {
            return '';
        }
        if ($this->isAbsoluteUri($path)) {
            return $path;
        }
        if (str_starts_with($path, '/')) {
            // 既にルート相対なのでそのまま返す
            return $path;
        }
        $baseUrl ??= BASE_URL;
        $offset = trim($offset, '/');
        $path = ltrim($path, '/');

        // base の path 部分のみ取り出す（query は RFC 3986 §5.2.2 に従い破棄）
        $baseUri = Rfc3986Uri::parse($baseUrl);
        $resolvedBase = rtrim($baseUri?->getPath() ?? '', '/');

        return $offset !== ''
            ? "{$resolvedBase}/{$offset}/{$path}"
            : "{$resolvedBase}/{$path}";
    }

    /**
     * URI から origin（scheme + host + port）を抽出する。
     */
    private function extractOrigin(?Rfc3986Uri $uri): string
    {
        if ($uri === null) {
            return '';
        }
        $origin = ($uri->getScheme() ?? '') . '://' . ($uri->getHost() ?? '');
        if ($uri->getPort() !== null) {
            $origin .= ':' . $uri->getPort();
        }
        return $origin;
    }

    /**
     * V2モジュール、V2APIビルド時に、URLを絶対URLに変換
     * それ以外はそのままのURLを返す
     *
     * @param string $path
     * @param string $offset
     * @return string
     */
    public function resolveUrl($path, $offset = ''): string
    {
        if (isApiBuildOrV2Module()) {
            $encoded = Media::urlencode($path);
            // API ビルド時は完全絶対URL、V2 モジュール内のみ（API 非ビルド）は root-relative
            $newPath = isApiBuild()
                ? $this->toAbsoluteUrl($encoded, $offset)
                : $this->toRootRelativeUrl($encoded, $offset);
            return $this->deliveryUrlRewriter->replaceDeliveryUrl($newPath);
        }
        return Media::urlencode($this->deliveryUrlRewriter->replaceDeliveryUrl($path));
    }

    /**
     * 単一の相対URLを絶対URL（フルURL）に変換する
     *
     * - 空文字、`http(s)://`、プロトコル相対 (`//`)、`data:` 始まりの URL はそのまま返す
     * - `/` 始まりのルート相対パスは baseUrl のスキーム + ホスト (+ ポート) を前置する
     * - それ以外の相対パスは baseUrl 全体を前置する
     *
     * @param string $url
     * @param string $baseUrl
     * @return string
     */
    public function convertRelativeUrlToAbsolute(string $url, string $baseUrl): string
    {
        if ($url === '') {
            return '';
        }
        if ($this->isAbsoluteUri($url)) {
            return $url;
        }
        $baseUri = Rfc3986Uri::parse($baseUrl);
        $base = ($baseUri?->getScheme() ?? 'https') . '://' . ($baseUri?->getHost() ?? '');
        if ($baseUri?->getPort() !== null) {
            $base .= ':' . $baseUri->getPort();
        }
        if ($url[0] === '/') {
            return $base . $url;
        }
        return rtrim($baseUrl, '/') . '/' . $url;
    }

    /**
     * HTML内のアセット参照（画像・動画・スタイルシート等）を絶対URLに変換する。
     *
     * APIビルド／V2モジュールなどヘッドレス出力向けの正規化。クロスドメインで配信
     * される API レスポンスでは、フロント（Next.js 等）が別ドメインから取得する都合上、
     * 画像・動画等は CMS ドメインの絶対URLになっている必要がある。
     *
     * リンク（<a>/<area>）は意図的に**一切変換しない**。これはフロント側のルーター
     * （Next.js の <Link> など）の解釈に任せるため、および `<a href="section/">` の
     * ようなドキュメント相対が CMS のサブパスを引きずって `/blog/section/` のように
     * 出力されるのを避けるため。
     *
     * - 対象: <img>/<video>/<source>/<audio>/<track>/<link>（および <img>/<source> の srcset）
     * - 非対象（一切触らない）: <a>/<area>
     * - 非対象（CSRF・実行コンテキストへの影響回避）: <form>/<script>/<iframe>
     * - スキーム付き URL（mailto:, tel:, javascript:, data:, blob: など）、
     *   プロトコル相対（//host/...）、フラグメントのみ（#hash）、空文字は変換しない。
     * - base が origin（scheme + host）を含む場合のみ絶対URL化し、path のみの
     *   base のときはルート相対のまま保持する。
     */
    public function convertAssetUrlsToAbsolute(string $html, string $baseUrl): string
    {
        $assetTags = [
            ['tag' => 'img', 'attr' => 'src'],
            ['tag' => 'video', 'attr' => 'src'],
            ['tag' => 'video', 'attr' => 'poster'],
            ['tag' => 'source', 'attr' => 'src'],
            ['tag' => 'audio', 'attr' => 'src'],
            ['tag' => 'track', 'attr' => 'src'],
            ['tag' => 'link', 'attr' => 'href'],
        ];
        $assetSrcsetTags = [
            ['tag' => 'img', 'attr' => 'srcset'],
            ['tag' => 'source', 'attr' => 'srcset'],
        ];

        libxml_use_internal_errors(true);
        $doc = new DOMDocument('1.0', 'UTF-8');
        $doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

        foreach ($assetTags as $entry) {
            $elements = $doc->getElementsByTagName($entry['tag']);
            foreach ($elements as $element) {
                $attrValue = $element->getAttribute($entry['attr']);
                $resolved = $this->resolveAgainstBase($attrValue, $baseUrl);
                if ($resolved !== $attrValue) {
                    $element->setAttribute($entry['attr'], $resolved);
                }
            }
        }
        foreach ($assetSrcsetTags as $entry) {
            $elements = $doc->getElementsByTagName($entry['tag']);
            foreach ($elements as $element) {
                if (!$element->hasAttribute($entry['attr'])) {
                    continue;
                }
                $attrValue = $element->getAttribute($entry['attr']);
                $resolved = $this->resolveSrcset($attrValue, $baseUrl);
                if ($resolved !== $attrValue) {
                    $element->setAttribute($entry['attr'], $resolved);
                }
            }
        }

        $innerHTML = '';
        foreach ($doc->childNodes as $node) {
            $innerHTML .= $doc->saveHTML($node);
        }
        // 不要なXML宣言を除去
        $innerHTML = str_ireplace('<?xml encoding="UTF-8">', '', $innerHTML);
        libxml_clear_errors();

        return $innerHTML;
    }

    /**
     * `srcset` 属性値内の各 URL を base URL に対して解決する。
     *
     * HTML Living Standard §4.8.4.3.2 "parse a srcset attribute" のアルゴリズムに準拠する。
     * URL は「ASCII whitespace でない連続」として読み取り、カンマでは分割しないため、
     * データURL内のカンマも誤分割されずに URL の一部として扱われる。
     *
     * @param string $srcset  srcset 属性値（例: "/a.jpg 1x, /b.jpg 2x"）
     * @param string $baseUrl 解決の基準となる URL
     * @return string         各 URL を解決した srcset 文字列
     */
    private function resolveSrcset(string $srcset, string $baseUrl): string
    {
        if (trim($srcset) === '') {
            return $srcset;
        }
        $position = 0;
        $length = strlen($srcset);
        $candidates = [];

        while ($position < $length) {
            // §4.1: 候補境界では ASCII whitespace と U+002C COMMA をまとめてスキップ
            while ($position < $length && (ctype_space($srcset[$position]) || $srcset[$position] === ',')) {
                $position++;
            }
            if ($position >= $length) {
                break;
            }
            // §4.3: 空白でない連続を URL として読む（カンマも非空白なので URL の一部）
            $urlStart = $position;
            while ($position < $length && !ctype_space($srcset[$position])) {
                $position++;
            }
            $url = substr($srcset, $urlStart, $position - $urlStart);
            $descriptor = '';

            if (str_ends_with($url, ',')) {
                // §4.5: URL 末尾がカンマなら剥がして、記述子なしの候補として確定
                $url = rtrim($url, ',');
                if ($url === '') {
                    continue;
                }
            } else {
                // §4.6: 次のカンマまでを記述子として読む
                while ($position < $length && ctype_space($srcset[$position])) {
                    $position++;
                }
                $descStart = $position;
                while ($position < $length && $srcset[$position] !== ',') {
                    $position++;
                }
                $descriptor = trim(substr($srcset, $descStart, $position - $descStart));
                if ($position < $length) {
                    $position++; // カンマを消費
                }
            }
            $resolvedUrl = $this->resolveAgainstBase($url, $baseUrl);
            $candidates[] = $descriptor === '' ? $resolvedUrl : $resolvedUrl . ' ' . $descriptor;
        }

        return implode(', ', $candidates);
    }

    private function resolveAgainstBase(string $url, string $baseUrl): string
    {
        // 空文字・フラグメント（#anchor）・プロトコル相対URL（//host/...）は
        // base に対して解決する必要がないのでそのまま返す
        if ($url === '' || $url[0] === '#' || str_starts_with($url, '//')) {
            return $url;
        }

        // RFC 3986 §3.1 に基づく scheme の検出。
        // `^[a-z][a-z0-9+.\-]*:` は「英字で始まり、英数字・+・.・- が続いたあとに : が来る」パターンで、
        // http: / https: / mailto: / tel: / javascript: / data: / blob: / ftp: などすべての
        // scheme 付き URI にマッチする。マッチした場合は既に絶対参照なので変換不要。
        if (preg_match('#^[a-z][a-z0-9+.\-]*:#i', $url) === 1) {
            return $url;
        }

        // ── ケース①: base が絶対URL（http:// または https:// で始まる）──────────────
        // WhatWgUrl::parse($url, $base) はブラウザと同じ URL 解決アルゴリズム（WHATWG URL Standard）で動作する。
        // 第2引数に base を渡すことで、$url が相対パス（例: ../images/foo.jpg）でも
        // base を起点に正しく絶対化してくれる。
        // 旧実装で使っていた Rfc3986Uri::resolve() は日本語などの非 ASCII 文字を含む
        // URL で null を返す問題があったため、WHATWG パーサーに切り替えている。
        $baseWhatWg = WhatWgUrl::parse($baseUrl);
        if ($baseWhatWg !== null) {
            $resolved = WhatWgUrl::parse($url, $baseWhatWg);
            return $resolved !== null ? $resolved->toAsciiString() : $url;
        }

        // ── ケース②: base がルート相対パス（/blog/ など、host を持たない）──────────
        // WHATWG パーサーは base に host が必須なので、/blog/ をそのまま渡してもパースできない。
        // そこで仮の origin「http://acms.internal」を先頭に付けて
        //   /blog/  →  http://acms.internal/blog/
        // という形で一時的に絶対 URL に変換してから解決し、
        // 解決後に仮 origin 部分を取り除いてパスだけを返す。
        // 例: $url = '../images/foo.jpg', $baseUrl = '/blog/entry/'
        //     → 仮 base: http://acms.internal/blog/entry/
        //     → 解決結果: http://acms.internal/blog/images/foo.jpg
        //     → 仮 origin 除去後: /blog/images/foo.jpg
        $synthOrigin = 'http://acms.internal';
        $synthBaseStr = $synthOrigin . (str_starts_with($baseUrl, '/') ? $baseUrl : '/' . $baseUrl);
        $synthBase = WhatWgUrl::parse($synthBaseStr);
        if ($synthBase === null) {
            return $url;
        }
        $resolved = WhatWgUrl::parse($url, $synthBase);
        if ($resolved === null) {
            return $url;
        }
        $resolvedStr = $resolved->toAsciiString();
        // 仮 origin を取り除いてパス部分だけ返す
        if (str_starts_with($resolvedStr, $synthOrigin)) {
            return substr($resolvedStr, strlen($synthOrigin));
        }
        return $resolvedStr;
    }

    /**
     * URL が "absolute URI" として自己解決可能かを判定する。
     *
     * 以下のいずれかなら true：
     * - scheme 付き URI（`http(s):`, `mailto:`, `tel:`, `data:`, `blob:`,
     *   `javascript:`, `ftp:` など）
     * - protocol-relative（`//host/...`）
     *
     * `/foo` `foo` `../foo` `#anchor` `?q=1` `''` のような **base に対して
     * 解決が必要な参照** は false。「ルート相対 `/foo` も解決済み扱いにしたい」
     * のような V1 互換の判定が必要な場合は、呼び出し側で
     * `str_starts_with($path, '/')` を別途チェックする。
     */
    private function isAbsoluteUri(string $url): bool
    {
        // protocol-relative（`//host/...`）は absolute 扱い
        if (str_starts_with($url, '//')) {
            return true;
        }
        // RFC 3986 に従って scheme を抽出。何らかの scheme があれば absolute
        $uri = Rfc3986Uri::parse($url);
        if ($uri !== null) {
            return $uri->getScheme() !== null;
        }
        // 日本語パスを含む絶対URLへのフォールバック: scheme はASCIIのみなので正規表現で判定
        return preg_match('#^[a-z][a-z0-9+.\-]*://#i', $url) === 1;
    }
}
