<?php

namespace Acms\Modules\Get\Helpers;

use Acms\Services\Facades\Cache;
use Acms\Services\Facades\Http;
use Acms\Services\Http\SsrfGuard;
use Acms\Services\Storage\Contracts\Filesystem as FilesystemInterface;
use Acms\Services\Storage\Filesystem;

class Json2TplHelper
{
    /**
     * レスポンスボディの許容最大サイズ(byte)の既定値。.env の JSON2TPL_MAX_CONTENT_LENGTH で変更できる
     */
    private const DEFAULT_MAX_CONTENT_LENGTH = 5 * 1024 * 1024;

    /**
     * 追跡を許可するリダイレクト回数の上限の既定値。.env の JSON2TPL_MAX_REDIRECTS で変更できる
     */
    private const DEFAULT_MAX_REDIRECTS = 5;

    /**
     * 許可する Content-Type(メディアタイプ部分のみで比較)
     */
    private const ALLOWED_CONTENT_TYPES = [
        'application/json',
        'text/json',
        'text/plain',
        'application/javascript',
        'text/javascript',
    ];

    /**
     * ローカル取得を許可するディレクトリ一覧
     *
     * @var string[]
     */
    private array $allowedLocalDirs;

    /**
     * @var FilesystemInterface
     */
    private FilesystemInterface $filesystem;

    /**
     * 取得先が内部／予約済みアドレスを指していないか検証するガード
     */
    private SsrfGuard $ssrfGuard;

    /**
     * HTTP(S) URI を取得するフェッチャー
     *
     * @var callable(string): array{statusCode: int, contentType: ?string, location: ?string, body: string}
     */
    private $httpFetcher;

    /**
     * レスポンスボディの許容最大サイズ(byte)
     */
    private int $maxContentLength;

    /**
     * 追跡を許可するリダイレクト回数の上限
     */
    private int $maxRedirects;

    /**
     * @param string[]|null $allowedLocalDirs ローカル取得を許可するディレクトリ一覧。null の場合 THEMES_DIR と .env の JSON2TPL_LOCAL_WHITE_LIST から解決する
     * @param FilesystemInterface|null $filesystem
     * @param callable(string): string[]|null $dnsResolver ホスト名から IP アドレス一覧を解決するリゾルバ(テスト時の差し替え用)。null の場合 DNS 解決を行う既定実装を使う
     * @param callable(string): array{statusCode: int, contentType: ?string, location: ?string, body: string}|null $httpFetcher HTTP(S) URI を取得するフェッチャー(テスト時の差し替え用)。null の場合 curl による既定実装を使う
     * @param int|null $maxContentLength レスポンスボディの許容最大サイズ(byte)。null の場合 .env の JSON2TPL_MAX_CONTENT_LENGTH から解決する
     * @param int|null $maxRedirects 追跡を許可するリダイレクト回数の上限。null の場合 .env の JSON2TPL_MAX_REDIRECTS から解決する
     * @param string[]|null $ssrfWhiteList SSRFガードを免除する IP アドレス/CIDR 一覧。null の場合 .env の JSON2TPL_SSRF_WHITE_LIST から解決する
     */
    public function __construct(
        ?array $allowedLocalDirs = null,
        ?FilesystemInterface $filesystem = null,
        ?callable $dnsResolver = null,
        ?callable $httpFetcher = null,
        ?int $maxContentLength = null,
        ?int $maxRedirects = null,
        ?array $ssrfWhiteList = null
    ) {
        $this->filesystem = $filesystem ?? new Filesystem();
        $this->allowedLocalDirs = $allowedLocalDirs ?? self::resolveAllowedLocalDirsFromEnv();
        $this->maxContentLength = $maxContentLength ?? self::resolveMaxContentLengthFromEnv();
        $this->maxRedirects = $maxRedirects ?? self::resolveMaxRedirectsFromEnv();
        $this->ssrfGuard = new SsrfGuard($ssrfWhiteList ?? self::resolveSsrfWhiteListFromEnv(), $dnsResolver);
        $this->httpFetcher = $httpFetcher ?? [$this, 'fetchHttpByCurl'];
    }

    /**
     * .env の JSON2TPL_SSRF_WHITE_LIST(カンマ区切り、IPアドレスまたはCIDR表記)から
     * SSRFガードを免除するIPアドレス/CIDR一覧を組み立てる
     *
     * ACMS_SSRF_WHITE_LIST(全経路共通の許可リスト)も併せて読み、両方の和を返す。
     *
     * @return string[]
     */
    private static function resolveSsrfWhiteListFromEnv(): array
    {
        return array_values(array_unique(array_merge(
            SsrfGuard::parseWhiteList(env(SsrfGuard::DEFAULT_WHITE_LIST_ENV_KEY, '')),
            SsrfGuard::parseWhiteList(env('JSON2TPL_SSRF_WHITE_LIST', ''))
        )));
    }

    /**
     * .env の JSON2TPL_MAX_CONTENT_LENGTH からレスポンスボディの許容最大サイズ(byte)を解決する
     *
     * @return int
     */
    private static function resolveMaxContentLengthFromEnv(): int
    {
        $value = env('JSON2TPL_MAX_CONTENT_LENGTH', '');
        if (!ctype_digit($value)) {
            return self::DEFAULT_MAX_CONTENT_LENGTH;
        }
        $intValue = (int) $value;
        return $intValue > 0 ? $intValue : self::DEFAULT_MAX_CONTENT_LENGTH;
    }

    /**
     * .env の JSON2TPL_MAX_REDIRECTS から追跡を許可するリダイレクト回数の上限を解決する
     *
     * @return int
     */
    private static function resolveMaxRedirectsFromEnv(): int
    {
        $value = env('JSON2TPL_MAX_REDIRECTS', '');
        if (!ctype_digit($value)) {
            return self::DEFAULT_MAX_REDIRECTS;
        }
        return (int) $value;
    }

    /**
     * .env の JSON2TPL_LOCAL_WHITE_LIST(カンマ区切り)から追加の許可ディレクトリを組み立てる
     *
     * ACMS_ValidatorBody::json2TplSource() が保存時バリデーションで同じ許可
     * ディレクトリ一覧を参照するため public にしている。
     *
     * @return string[]
     */
    public static function resolveAllowedLocalDirsFromEnv(): array
    {
        $dirs = [THEMES_DIR];
        $whiteListStr = env('JSON2TPL_LOCAL_WHITE_LIST', '');
        if ($whiteListStr === '') {
            return $dirs;
        }
        foreach (explode(',', $whiteListStr) as $dir) {
            $dir = trim($dir);
            if ($dir !== '') {
                $dirs[] = $dir;
            }
        }
        return $dirs;
    }

    /**
     * 添え字が0から連続する数値(=配列とみなせる)ときにtrue
     *
     * @param array $ary
     * @return boolean
     */
    public function isVector($ary)
    {
        return array_values($ary) === $ary;
    }

    /**
     * urlからコンテンツの取得
     *
     * @param string $uri
     *
     * @return string
     */
    public function getContents($uri)
    {
        try {
            $contents = $this->getRawContents($uri);
            if ($contents === '') {
                throw new \RuntimeException('Empty contents.');
            }
            $charset = mb_detect_encoding($contents, 'UTF-8, EUC-JP, SJIS-win, SJIS');
            if ($charset && 'UTF-8' !== $charset) {
                $contents = mb_convert_encoding($contents, 'UTF-8', $charset);
            }
            if ($contents === false) {
                throw new \RuntimeException('Failed to convert encoding.');
            }
            return $contents;
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * スキームに応じてローカル/HTTP(S)取得に振り分ける
     *
     * @param string $uri
     * @return string
     */
    private function getRawContents(string $uri): string
    {
        $scheme = \Uri\Rfc3986\Uri::parse($uri)?->getScheme();
        if ($scheme === null || $scheme === '') {
            return $this->getLocalContents($uri);
        }
        if (!in_array(strtolower($scheme), ['http', 'https'], true)) {
            throw new \RuntimeException('許可されていないスキームです。');
        }
        return $this->getHttpContents($uri);
    }

    /**
     * 許可ディレクトリ配下のローカルファイルを読み込む
     *
     * @param string $uri
     * @return string
     */
    private function getLocalContents(string $uri): string
    {
        foreach ($this->allowedLocalDirs as $dir) {
            if ($this->filesystem->validateDirectoryTraversalPath($uri, $dir)) {
                $contents = $this->filesystem->get($uri, $dir);
                return $contents === false ? '' : $contents;
            }
        }
        throw new \RuntimeException('許可されていないローカルパスです。');
    }

    /**
     * HTTP(S) URI からコンテンツを取得する
     *
     * リダイレクトは自動追従(CURLOPT_FOLLOWLOCATION)せず、ホップごとに
     * assertSafeRemoteUri() を再適用することで、安全なURLから内部アドレスへ
     * 誘導するリダイレクトSSRFを防ぐ。
     *
     * @param string $uri
     * @return string
     */
    private function getHttpContents(string $uri): string
    {
        for ($redirectCount = 0; $redirectCount <= $this->maxRedirects; $redirectCount++) {
            $this->assertSafeRemoteUri($uri);
            $response = ($this->httpFetcher)($uri);

            $location = $response['location'];
            if (in_array($response['statusCode'], [301, 302, 303, 307, 308], true) && $location !== null && $location !== '') {
                $uri = $this->resolveRedirectUri($uri, $location);
                continue;
            }

            $this->assertSafeContentType($response['contentType']);
            $body = $response['body'];
            if (strlen($body) > $this->maxContentLength) {
                throw new \RuntimeException('レスポンスのサイズが上限を超えています。');
            }
            return $body;
        }
        throw new \RuntimeException('リダイレクトの回数が上限を超えています。');
    }

    /**
     * リダイレクト元URIとLocationヘッダの値から絶対URIを組み立てる
     *
     * @param string $baseUri
     * @param string $location
     * @return string
     */
    private function resolveRedirectUri(string $baseUri, string $location): string
    {
        $base = \Uri\Rfc3986\Uri::parse($baseUri);
        $resolved = $base === null ? \Uri\Rfc3986\Uri::parse($location) : \Uri\Rfc3986\Uri::parse($location, $base);
        if ($resolved === null) {
            throw new \RuntimeException('不正なリダイレクト先です。');
        }
        return $resolved->toString();
    }

    /**
     * Content-Type がJSON/テキスト系として許可されたものか検証する
     *
     * @param string|null $contentType
     * @return void
     */
    private function assertSafeContentType(?string $contentType): void
    {
        if ($contentType === null || $contentType === '') {
            return;
        }
        $mime = strtolower(trim(explode(';', $contentType)[0]));
        if (!in_array($mime, self::ALLOWED_CONTENT_TYPES, true)) {
            throw new \RuntimeException("許可されていないContent-Typeです: {$contentType}");
        }
    }

    /**
     * curl で HTTP(S) コンテンツを取得する既定のフェッチャー
     *
     * @param string $uri
     * @return array{statusCode: int, contentType: ?string, location: ?string, body: string}
     */
    private function fetchHttpByCurl(string $uri): array
    {
        $ch = curl_init($uri);
        if ($ch === false) {
            throw new \RuntimeException('curlの初期化に失敗しました。');
        }
        // CurlHandle はスコープを抜ける際に自動でクローズされるため、
        // curl_close() は明示的に呼ばない(PHP 8.5 で非推奨化された)。
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        // リダイレクトは resolveRedirectUri() 側でホップ毎に検証するため自動追従させない
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
        curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        curl_setopt($ch, CURLOPT_RANGE, '0-' . $this->maxContentLength);
        Http::setCurlProxy($ch);
        $body = curl_exec($ch);
        if (!is_string($body)) {
            throw new \RuntimeException('コンテンツの取得に失敗しました: ' . curl_error($ch));
        }
        $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
        $location = curl_getinfo($ch, CURLINFO_REDIRECT_URL);
        return [
            'statusCode' => curl_getinfo($ch, CURLINFO_HTTP_CODE),
            'contentType' => ($contentType !== false && $contentType !== '') ? $contentType : null,
            'location' => ($location !== false && $location !== '') ? $location : null,
            'body' => $body,
        ];
    }

    /**
     * SSRF対策として、URIのホストが内部/プライベートアドレスを指していないか検証する
     *
     * @param string $uri
     * @return void
     */
    private function assertSafeRemoteUri(string $uri): void
    {
        $this->ssrfGuard->assertSafeUri($uri);
    }

    /**
     * キャッシュの取得
     *
     * @param string $uri
     *
     * @return string|bool
     */
    public function getJsonCache($uri)
    {
        $id = $this->getCacheId($uri);
        $cache = Cache::module();
        $cacheItem = $cache->getItem($id);
        if ($cacheItem->isHit()) {
            return (string) $cacheItem->get();
        }
        return false;
    }

    /**
     * キャッシュの保存
     *
     * @param string $uri
     * @param string $contents
     * @param int $expire
     */
    public function saveCache(string $uri, string $contents, int $expire = 0)
    {
        $id = $this->getCacheId($uri);
        $cache = Cache::module();
        $cache->put($id, $contents, $expire);
    }

    /**
     * キャッシュidの取得
     *
     * @param string $uri
     * @return string
     */
    private function getCacheId($uri)
    {
        return md5($uri);
    }
}
