<?php

declare(strict_types=1);

namespace App\ApiClient;

use DateTimeImmutable;
use DateTimeZone;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * The UI's gateway to the api container.
 *
 * Auto-attaches the BFF auth headers (`Authorization: Bearer <service-token>`,
 * `X-Acting-User-Id` when a user is in the session, `Accept: application/json`,
 * a stable user agent). Retries connection errors and 5xx once. Maps every
 * error into a typed exception so callers don't parse Guzzle exceptions.
 *
 * Subclients (`AuthClient`, `AdminClient`) wrap this instance and provide
 * one method per endpoint group; they're the public surface that
 * controllers call.
 */
final class ApiClient
{
    public const USER_AGENT = 'irdb-ui/0.1';

    public function __construct(
        private readonly ClientInterface $http,
        private readonly string $serviceToken,
        private readonly ApiHealth $health,
        private readonly LoggerInterface $logger = new NullLogger(),
    ) {
    }

    /**
     * @param array<string, mixed>      $options Guzzle request options (json, headers, query).
     * @param int|null                  $actingUserId From session; null for unauthenticated calls (auth endpoints).
     * @return array<string, mixed>     Decoded JSON body.
     */
    public function request(string $method, string $path, array $options = [], ?int $actingUserId = null): array
    {
        $headers = $options['headers'] ?? [];
        if (!is_array($headers)) {
            $headers = [];
        }
        $headers['Authorization'] = 'Bearer ' . $this->serviceToken;
        $headers['Accept'] = 'application/json';
        $headers['User-Agent'] = self::USER_AGENT;
        if ($actingUserId !== null) {
            $headers['X-Acting-User-Id'] = (string) $actingUserId;
        }
        $options['headers'] = $headers;
        // We handle 4xx/5xx ourselves; let Guzzle return responses always.
        $options['http_errors'] = false;

        $attempts = 0;
        $maxAttempts = 2;
        $lastException = null;
        while ($attempts < $maxAttempts) {
            ++$attempts;
            try {
                $response = $this->http->request($method, $path, $options);
            } catch (ConnectException $e) {
                $lastException = $e;
                $this->logger->warning('api connection error', ['attempt' => $attempts, 'error' => $e->getMessage()]);
                if ($attempts >= $maxAttempts) {
                    $this->health->recordFailure();

                    throw new ApiUnreachableException('api unreachable: ' . $e->getMessage(), 0, null, $e);
                }

                continue;
            } catch (RequestException $e) {
                // RequestException covers 4xx/5xx but with http_errors=false
                // it shouldn't fire on status; retain a defensive branch.
                $response = $e->getResponse();
                if ($response === null) {
                    $this->health->recordFailure();

                    throw new ApiUnreachableException('api request failed: ' . $e->getMessage(), 0, null, $e);
                }
            } catch (GuzzleException $e) {
                $this->health->recordFailure();

                throw new ApiUnreachableException('api request failed: ' . $e->getMessage(), 0, null, $e);
            }

            $status = $response->getStatusCode();
            if ($status >= 500 && $attempts < $maxAttempts) {
                $this->logger->warning('api 5xx, retrying', ['attempt' => $attempts, 'status' => $status]);

                continue;
            }

            $this->health->recordSuccess(new DateTimeImmutable('now', new DateTimeZone('UTC')));

            return $this->parseOrThrow($response);
        }

        // unreachable, but make the type-checker happy
        $this->health->recordFailure();

        throw new ApiUnreachableException('api unreachable', 0, null, $lastException);
    }

    /**
     * @return array<string, mixed>
     */
    private function parseOrThrow(ResponseInterface $response): array
    {
        $status = $response->getStatusCode();
        $body = (string) $response->getBody();
        $decoded = $body === '' ? [] : json_decode($body, true);
        if (!is_array($decoded)) {
            $decoded = [];
        }
        $apiError = isset($decoded['error']) && is_string($decoded['error']) ? $decoded['error'] : null;

        if ($status >= 200 && $status < 300) {
            return $decoded;
        }
        if ($status === 401 || $status === 403) {
            throw new ApiAuthException(
                sprintf('api returned %d (%s)', $status, $apiError ?? 'auth error'),
                $status,
                $apiError,
            );
        }
        if ($status === 404) {
            throw new ApiNotFoundException(
                sprintf('api returned 404 (%s)', $apiError ?? 'not_found'),
                $status,
                $apiError,
            );
        }
        if ($status === 400 || $status === 422) {
            $details = [];
            if (isset($decoded['details']) && is_array($decoded['details'])) {
                foreach ($decoded['details'] as $field => $msg) {
                    if (is_string($field) && (is_string($msg) || is_int($msg) || is_float($msg))) {
                        $details[$field] = (string) $msg;
                    }
                }
            }

            throw new ApiValidationException(
                sprintf('api validation failed (%s)', $apiError ?? 'validation_failed'),
                $status,
                $apiError,
                $details,
            );
        }
        if ($status >= 500) {
            throw new ApiServerException(
                sprintf('api returned %d (%s)', $status, $apiError ?? 'server error'),
                $status,
                $apiError,
            );
        }

        throw new ApiException(
            sprintf('api returned %d (%s)', $status, $apiError ?? 'unexpected error'),
            $status,
            $apiError,
        );
    }
}
