<?php
/**
 * Copyright since 2025 InPost S.A.
 *
 * For the full license information, please view the LICENSE file bundled with the module.
 *
 * @author InPost S.A.
 * @copyright since 2025 InPost S.A.
 * @license MIT
 */

declare(strict_types=1);

namespace InPost\International\Api;

use InPost\International\Api\Exception\ApiErrorException;
use InPost\International\Api\Exception\ApiException;
use InPost\International\Api\Exception\ApiExceptionInterface;
use InPost\International\Api\Exception\ApiProblemException;
use InPost\International\Api\Exception\ClientException;
use InPost\International\Api\Exception\NetworkException;
use InPost\International\Api\Exception\RedirectionException;
use InPost\International\Api\Exception\ServerException;
use InPost\International\Api\Exception\UnexpectedResponseStatusException;
use InPost\International\Api\Pickup\Request\CreatePickupOrderRequest;
use InPost\International\Api\Pickup\Request\CutoffTimeRequest;
use InPost\International\Api\Pickup\Request\PickupOrdersRequest;
use InPost\International\Api\Pickup\Response\CreatePickupOrderResponse;
use InPost\International\Api\Pickup\Response\CutoffTimeResponse;
use InPost\International\Api\Pickup\Response\PickupOrder;
use InPost\International\Api\Pickup\Response\PickupOrderList;
use InPost\International\Api\Point\Model\Point;
use InPost\International\Api\Point\Model\PointList;
use InPost\International\Api\Point\Model\RelativePointList;
use InPost\International\Api\Point\Request\PointsRequest;
use InPost\International\Api\Point\Request\PointsSearchByLocationRequest;
use InPost\International\Api\Shipment\Request\CreateShipmentRequest;
use InPost\International\Api\Shipment\Response\Label;
use InPost\International\Api\Shipment\Response\Shipment;
use InPost\International\Api\Tracking\Response\TrackingDetailsResponse;
use InPost\International\Environment\EnvironmentInterface;
use InPost\International\Environment\ProductionEnvironment;
use InPost\International\Http\Util\UriResolver;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

if (!defined('_PS_VERSION_')) {
    exit;
}

final class ApiClient implements ApiClientInterface
{
    private const ID = 'PrestaShop_INT';
    private const API_VERSION = '2024-06-01';

    /**
     * @var ClientInterface
     */
    private $client;

    /**
     * @var RequestFactoryInterface
     */
    private $requestFactory;

    /**
     * @var StreamFactoryInterface
     */
    private $streamFactory;

    /**
     * @var SerializerInterface
     */
    private $serializer;

    /**
     * @var EnvironmentInterface
     */
    private $environment;

    public function __construct(ClientInterface $client, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, SerializerInterface $serializer, EnvironmentInterface $environment = null)
    {
        $this->client = $client;
        $this->requestFactory = $requestFactory;
        $this->streamFactory = $streamFactory;
        $this->serializer = $serializer;
        $this->environment = $environment ?? new ProductionEnvironment();
    }

    public function getEnvironment(): EnvironmentInterface
    {
        return $this->environment;
    }

    /**
     * {@inheritdoc}
     */
    public function getPoints(PointsRequest $request): PointList
    {
        $uri = '/points';

        if ([] !== $params = $request->toQueryParameters()) {
            $uri .= '?' . self::buildQuery($params);
        }

        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, PointList::class);
    }

    /**
     * {@inheritdoc}
     */
    public function getPointById(string $id): Point
    {
        $uri = sprintf('/points/%s', $id);
        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, Point::class);
    }

    /**
     * {@inheritdoc}
     */
    public function getPointsByLocation(PointsSearchByLocationRequest $request): RelativePointList
    {
        $uri = '/points/search-by-location';

        if ([] !== $params = $request->toQueryParameters()) {
            $uri .= '?' . self::buildQuery($params);
        }

        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, RelativePointList::class);
    }

    /**
     * {@inheritdoc}
     */
    public function createShipment(CreateShipmentRequest $request): Shipment
    {
        $uri = sprintf('/shipments/%s', $request->getShipment()->getType()->value);
        $httpRequest = $this->createRequest('POST', $uri, $request)->withHeader('X-Origin-Metadata', self::ID);
        // as of writing this comment, the API may return 200 (should be 201, according to the documentation)
        $response = $this->sendRequest($httpRequest, 201, 200);

        return $this->deserialize($response, Shipment::class);
    }

    /**
     * {@inheritdoc}
     */
    public function getShipment(string $uuid): Shipment
    {
        $uri = sprintf('/shipments/%s', $uuid);
        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, Shipment::class);
    }

    /**
     * {@inheritdoc}
     */
    public function getShipmentLabel(string $uuid): Label
    {
        $uri = sprintf('/shipments/%s/label', $uuid);
        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, Label::class);
    }

    public function getPickupOrders(PickupOrdersRequest $request): PickupOrderList
    {
        $uri = '/one-time-pickups';

        if ([] !== $params = $request->toQueryParameters()) {
            $uri .= '?' . self::buildQuery($params);
        }

        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, PickupOrderList::class);
    }

    public function createPickupOrder(CreatePickupOrderRequest $request): CreatePickupOrderResponse
    {
        $httpRequest = $this->createRequest('POST', '/one-time-pickups', $request);
        $response = $this->sendRequest($httpRequest, 201);

        return $this->deserialize($response, CreatePickupOrderResponse::class);
    }

    public function getPickupOrder(string $id): PickupOrder
    {
        $uri = sprintf('/one-time-pickups/%s', $id);
        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, PickupOrder::class);
    }

    public function getCutoffPickupTime(CutoffTimeRequest $request): CutoffTimeResponse
    {
        $uri = '/cutoff-time?' . self::buildQuery($request->toQueryParameters());
        $httpRequest = $this->createRequest('GET', $uri);
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, CutoffTimeResponse::class);
    }

    public function getTrackingDetails(string ...$trackingNumbers): TrackingDetailsResponse
    {
        if ([] === $trackingNumbers) {
            throw new \BadMethodCallException('At least one tracking number is required.');
        }

        $uri = '/track/parcels?' . self::buildQuery(['trackingNumbers' => $trackingNumbers]);

        $httpRequest = $this->createRequest('GET', $uri)->withHeader('X-InPost-Event-Version', 'V1');
        $response = $this->sendRequest($httpRequest);

        return $this->deserialize($response, TrackingDetailsResponse::class);
    }

    /**
     * @param array<string, mixed> $params
     */
    private static function buildQuery(array $params): string
    {
        return http_build_query($params, '', '&', PHP_QUERY_RFC3986);
    }

    /**
     * @param mixed $payload
     */
    private function createRequest(string $method, string $uri, $payload = null): RequestInterface
    {
        $uri = UriResolver::resolve($uri, $this->environment->getApiUri());

        $request = $this->requestFactory->createRequest($method, $uri)
            ->withHeader('Accept', 'application/json')
            ->withHeader('X-InPost-Api-Version', self::API_VERSION);

        if (null === $payload) {
            return $request;
        }

        $payload = $this->serializer->serialize($payload, 'json', [
            DateTimeNormalizer::TIMEZONE_KEY => self::TIMEZONE,
        ]);
        $body = $this->streamFactory->createStream($payload);

        return $request
            ->withBody($body)
            ->withHeader('Content-Type', 'application/json');
    }

    private function sendRequest(RequestInterface $request, int $expectedStatusCode = 200, int ...$allowedStatusCodes): ResponseInterface
    {
        try {
            $response = $this->client->sendRequest($request);
        } catch (NetworkExceptionInterface $e) {
            throw new NetworkException($e);
        }

        $statusCode = $response->getStatusCode();

        if (300 <= $statusCode) {
            $this->handleUnsuccessfulResponse($request, $response);
        }

        if ($expectedStatusCode === $statusCode || in_array($statusCode, $allowedStatusCodes, true)) {
            return $response;
        }

        throw UnexpectedResponseStatusException::create($expectedStatusCode, $statusCode);
    }

    /**
     * @template T
     *
     * @param class-string<T> $class
     *
     * @return T
     */
    private function deserialize(ResponseInterface $response, string $class, array $context = [])
    {
        return $this->serializer->deserialize((string) $response->getBody(), $class, 'json', array_merge([
            DateTimeNormalizer::TIMEZONE_KEY => self::TIMEZONE,
        ], $context));
    }

    private function handleUnsuccessfulResponse(RequestInterface $request, ResponseInterface $response): void
    {
        if (null !== $e = $this->decodeUnsuccessfulResponse($request, $response)) {
            throw $e;
        }

        $statusCode = $response->getStatusCode();

        if (500 <= $statusCode) {
            throw new ServerException($request, $response);
        }

        if (400 <= $statusCode) {
            throw new ClientException($request, $response);
        }

        throw new RedirectionException($request, $response);
    }

    private function decodeUnsuccessfulResponse(RequestInterface $request, ResponseInterface $response): ?ApiExceptionInterface
    {
        try {
            $problem = $this->deserialize($response, ApiProblem::class);

            return ApiProblemException::create($problem, $request, $response);
        } catch (ExceptionInterface $e) {
            // could not decode the response as an API problem, try a different approach
        }

        assert($this->serializer instanceof DecoderInterface);

        try {
            $data = $this->serializer->decode((string) $response->getBody(), 'json');
        } catch (ExceptionInterface $e) {
            return null;
        }

        if (!isset($data['message'])) {
            return null;
        }

        $message = $data['message'];

        try {
            // the message may be a JSON object with properties "status", "key", "error"
            $error = $this->serializer->deserialize($message, ApiError::class, 'json');

            throw ApiErrorException::create($error, $request, $response);
        } catch (ExceptionInterface $e) {
            // ignore decoding errors
        }

        if (!is_string($message)) {
            return null;
        }

        $status = $data['status'] ?? null;

        return new ApiException($request, $response, $message, null === $status ? null : (int) $status);
    }
}
