<?php

namespace Acms\Services\Http;

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriFactoryInterface;
use RuntimeException;

/**
 * SSRF ガードを挟む PSR-18 クライアントのデコレーター。
 *
 * 委譲先のクライアントへ渡す前に取得先を検証し、リダイレクトもホップごとに
 * 検証したうえで自前で追従する。委譲先には自動追従をさせない設定を与えること。
 */
class SsrfGuardedClient implements ClientInterface
{
    /**
     * 追従を許可するリダイレクト回数の既定値
     *
     * Why not: 独自に小さい値を決めないのは、それまで curl の自動追従（CURLOPT_MAXREDIRS の
     * 既定 10）で取得できていたページが、ホップ数だけを理由に取得できなくなるのを避けるため。
     */
    private const DEFAULT_MAX_REDIRECTS = 10;

    /**
     * リダイレクトとみなすステータスコード
     */
    private const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];

    private ClientInterface $inner;

    private UriFactoryInterface $uriFactory;

    private SsrfGuard $guard;

    private int $maxRedirects;

    /**
     * @param ClientInterface $inner 実際に HTTP 取得を行うクライアント（自動リダイレクト追従は無効にしておく）
     * @param UriFactoryInterface $uriFactory リダイレクト先の URI を組み立てるファクトリ
     * @param SsrfGuard|null $guard null なら既定の設定でガードを組み立てる
     * @param int $maxRedirects 追従を許可するリダイレクト回数の上限
     */
    public function __construct(
        ClientInterface $inner,
        UriFactoryInterface $uriFactory,
        ?SsrfGuard $guard = null,
        int $maxRedirects = self::DEFAULT_MAX_REDIRECTS
    ) {
        $this->inner = $inner;
        $this->uriFactory = $uriFactory;
        $this->guard = $guard ?? new SsrfGuard();
        $this->maxRedirects = $maxRedirects;
    }

    /**
     * @param RequestInterface $request
     * @return ResponseInterface
     * @throws RuntimeException 取得を許可できない URI を指している場合
     */
    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $uri = (string) $request->getUri();

        for ($redirectCount = 0; $redirectCount <= $this->maxRedirects; $redirectCount++) {
            $this->assertFetchable($uri);
            $response = $this->inner->sendRequest($request->withUri($this->uriFactory->createUri($uri)));

            $location = $response->getHeaderLine('Location');
            if (in_array($response->getStatusCode(), self::REDIRECT_STATUS_CODES, true) && $location !== '') {
                $uri = $this->resolveRedirectUri($uri, $location);
                continue;
            }

            return $response;
        }

        throw new RuntimeException('リダイレクトの回数が上限を超えています。');
    }

    /**
     * 取得してよい URI か検証する
     *
     * @param string $uri
     * @return void
     * @throws RuntimeException
     */
    private function assertFetchable(string $uri): void
    {
        if (!SsrfGuard::isAllowedHttpScheme($uri)) {
            throw new RuntimeException('取得が許可されていないスキームです。');
        }
        $this->guard->assertSafeUri($uri);
    }

    /**
     * リダイレクト元 URI と Location ヘッダの値から絶対 URI を組み立てる
     *
     * @param string $baseUri
     * @param string $location
     * @return string
     * @throws RuntimeException
     */
    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();
    }
}
