<?php

namespace Packlink\BusinessLogic\Http;

use Exception;
use Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException;
use Logeecom\Infrastructure\Http\Exceptions\HttpBaseException;
use Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException;
use Logeecom\Infrastructure\Http\Exceptions\HttpRequestException;
use Logeecom\Infrastructure\Http\HttpClient;
use Logeecom\Infrastructure\Http\HttpResponse;
use Logeecom\Infrastructure\Logger\Logger;
use Logeecom\Infrastructure\ServiceRegister;
use Packlink\BusinessLogic\Brand\BrandConfigurationService;
use Packlink\BusinessLogic\Configuration;
use Packlink\BusinessLogic\DTO\FrontDtoFactory;
use Packlink\BusinessLogic\Http\DTO\Analytics;
use Packlink\BusinessLogic\Http\DTO\BFF\BffPostsaleResponse;
use Packlink\BusinessLogic\Http\DTO\BFF\BffSessionResponse;
use Packlink\BusinessLogic\Http\DTO\BFF\BffTrackingResponse;
use Packlink\BusinessLogic\Http\DTO\Customs\CustomsInvoice;
use Packlink\BusinessLogic\Http\DTO\Customs\CustomsUnionsSearchRequest;
use Packlink\BusinessLogic\Http\DTO\Draft;
use Packlink\BusinessLogic\Http\DTO\DropOff;
use Packlink\BusinessLogic\Http\DTO\LocationInfo;
use Packlink\BusinessLogic\Http\DTO\ParcelInfo;
use Packlink\BusinessLogic\Http\DTO\PostalCode;
use Packlink\BusinessLogic\Http\DTO\PostalZone;
use Packlink\BusinessLogic\Http\DTO\Shipment;
use Packlink\BusinessLogic\Http\DTO\ShippingService;
use Packlink\BusinessLogic\Http\DTO\ShippingServiceDetails;
use Packlink\BusinessLogic\Http\DTO\ShippingServiceSearch;
use Packlink\BusinessLogic\Http\DTO\Subscription\Subscription;
use Packlink\BusinessLogic\Http\DTO\Tracking;
use Packlink\BusinessLogic\Http\DTO\User;
use Packlink\BusinessLogic\Http\Exceptions\DraftNotCreatedException;
use Packlink\BusinessLogic\IntegrationRegistration\DTO\IntegrationRegistrationPayload;
use Packlink\BusinessLogic\IntegrationRegistration\Exceptions\IntegrationNotRegisteredException;
use Packlink\BusinessLogic\IntegrationRegistration\Interfaces\IntegrationRegistrationDataProviderInterface;
use Packlink\BusinessLogic\Warehouse\Warehouse;

/**
 * Class Proxy. In charge of communication with Packlink API.
 *
 * @package Packlink\BusinessLogic
 */
class Proxy implements \Packlink\BusinessLogic\Http\Interfaces\Proxy
{
    /**
     * Packlink base API URL.
     */
    const BASE_URL = 'https://api.packlink.com/';
    /**
     * Packlink API version
     */
    const API_VERSION = 'v1/';
    /**
     * HTTP Client.
     *
     * @var HttpClient
     */
    private $client;
    /**
     * @var Configuration
     */
    private $configService;
    /**
     * @var IntegrationRegistrationDataProviderInterface
     */
    private $dataProvider;
    /**
     * BFF session id, cached per request lifecycle.
     *
     * @var string|null
     */
    private $bffSessionId = null;

    /**
     * Proxy constructor.
     *
     * @param Configuration $configService Configuration service.
     * @param HttpClient $client System HTTP client.
     */
    public function __construct(
        Configuration $configService,
        HttpClient $client,
        IntegrationRegistrationDataProviderInterface $dataProvider
    ) {
        $this->client = $client;
        $this->configService = $configService;
        $this->dataProvider = $dataProvider;
    }

    /**
     * Returns a list of user parcels information.
     *
     * @return ParcelInfo[] Array of parcels information.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     * @noinspection PhpDocMissingThrowsInspection
     */
    public function getUsersParcelInfo()
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, 'users/parcels');
        $data = $response->decodeBodyToArray() ?: array();

        /** @noinspection PhpIncompatibleReturnTypeInspection */
        /** @noinspection PhpUnhandledExceptionInspection */
        return FrontDtoFactory::getFromBatch(ParcelInfo::CLASS_KEY, $data);
    }

    /**
     * Returns a list of user warehouses.
     *
     * @return Warehouse[] Array of warehouses.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     * @noinspection PhpDocMissingThrowsInspection
     */
    public function getUsersWarehouses()
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, 'clients/warehouses');
        $data = $response->decodeBodyToArray() ?: array();

        /** @noinspection PhpIncompatibleReturnTypeInspection */
        /** @noinspection PhpUnhandledExceptionInspection */
        return FrontDtoFactory::getFromBatch(Warehouse::CLASS_KEY, $data);
    }

    /**
     * Registers an integration (shop) in Packlink.
     *
     * Expected payload structure:
     *  array(
     *      'integration_type' => string, // e.g. 'prestashop_module'
     *      'integration' => array(
     *          'guid' => string,         // Unique integration GUID
     *          'name' => string,         // Human-readable store name
     *      ),
     *      'webhooks' => array(
     *          'http_header_name' => string,   // X-Packlink-Webhook-Secret
     *          'http_header_value' => string,  // Generated secret value for request authentication
     *          'status_update_url' => string,  // Where Packlink sends events
     *      )
     *  )
     *
     * @param IntegrationRegistrationPayload $payload Integration registration payload.
     *
     * @return string Integration ID (UUID)
     * @throws IntegrationNotRegisteredException
     */
    public function registerIntegration(IntegrationRegistrationPayload $payload)
    {
        try {
            $response = $this->call(
                HttpClient::HTTP_METHOD_POST,
                'integrations',
                $payload->toArray()
            );
        } catch (Exception $e) {
            Logger::logError(
                'Error while trying to register the integration.',
                'Core'
            );
            throw new IntegrationNotRegisteredException('Error while trying to register the integration.');
        }

        if (!$response) {
            throw new IntegrationNotRegisteredException('Empty response from Packlink API.');
        }

        $result = $response->decodeBodyToArray();

        if (empty($result['integration_id'])) {
            Logger::logError(
                'Integration ID not returned by Packlink API.',
                'Core',
                array(
                    'Response status' => $response->getStatus(),
                    'Response body' => $response->getBody(),
                )
            );
            throw new IntegrationNotRegisteredException('Integration ID not returned by Packlink API.');
        }

        Logger::logInfo(
            'Integration registered. '
            . 'Packlink response: ' . json_encode($result),
            'Core',
            array('status_update_url' => $payload->getStatusUpdateUrl())
        );

        return $result['integration_id'];
    }

    /**
     * Disconnects an integration from Packlink.
     *
     * @param $integrationId
     *
     * @return bool
     *
     * @throws HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws HttpRequestException
     */
    public function disconnectIntegration($integrationId)
    {
        $result = $this->call(
            HttpClient::HTTP_METHOD_DELETE,
            'integrations/' . urlencode($integrationId)
        );

        if ($result->getStatus() === 204) {
            return true;
        }

        return false;
    }

    /**
     * Attemps to register a new user account on Packlink PRO API.
     *
     * @param array $data
     *
     * @return string Authorization token of the new account if it was created.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function register($data)
    {
        $data['referral'] = array(
            'onboarding_product' => $this->configService->getIntegrationName(),
            'onboarding_sub_product' => 'marketplace',
        );

        $response = $this->call(HttpClient::HTTP_METHOD_POST, 'register', $data);

        $data = $response->decodeBodyToArray();

        return !empty($data['token']) ? $data['token'] : '';
    }

    /**
     * Returns user info.
     *
     * @return User User info.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getUserData()
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, 'clients');

        return User::fromArray($response->decodeBodyToArray());
    }

    /**
     * Subscribes web-hook callback url.
     *
     * @param string $webHookUrl Web-hook URL.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function registerWebHookHandler($webHookUrl)
    {
        $this->call(HttpClient::HTTP_METHOD_POST, 'shipments/callback', array('url' => $webHookUrl));
    }

    /**
     * Returns top ten drop-off locations in postal code area.
     *
     * @param string $serviceId Unique shipping service identifier.
     * @param string $countryCode Country ISO2 code.
     * @param string $postalCode Postal/ZIP code.
     *
     * @return DropOff[] List of all drop off locations near given postal code.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getLocations($serviceId, $countryCode, $postalCode)
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, urlencode("dropoffs/$serviceId/$countryCode/$postalCode"));

        return DropOff::fromBatch($response->decodeBodyToArray() ?: array());
    }

    /**
     * Performs search for locations.
     *
     * @param string $platformCountry Country code to search in.
     * @param string $postalZone Postal zone to search in.
     * @param string $query Query to search for.
     *
     * @return \Packlink\BusinessLogic\Http\DTO\LocationInfo[]
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function searchLocations($platformCountry, $postalZone, $query)
    {
        $brand = $this->getBrandConfigurationService()->get();

        $url = 'locations/postalcodes?' . http_build_query(
                array(
                    'platform' => $brand->platformCode,
                    'platform_country' => $platformCountry,
                    'postalzone' => $postalZone,
                    'q' => $query,
                )
            );

        $response = $this->call(HttpClient::HTTP_METHOD_GET, $url);

        return LocationInfo::fromBatch($response->decodeBodyToArray());
    }

    /**
     * Returns array of PostalCode objects by specified country and specified zip code.
     *
     * @param string $countryCode Two-letter iso code of a country.
     * @param string $zipCode Zip code.
     *
     * @return PostalCode[] PostalCode DTO.
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getPostalCodes($countryCode, $zipCode)
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, urlencode("locations/postalcodes/$countryCode/$zipCode"));

        return PostalCode::fromBatch($response->decodeBodyToArray());
    }

    /**
     * Returns array of PostalZone objects by specified country.
     *
     * @param string $countryCode
     * @param string $lang
     *
     * @return \Packlink\BusinessLogic\Http\DTO\PostalZone[]
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getPostalZones($countryCode, $lang = 'en')
    {
        $brand = $this->getBrandConfigurationService()->get();

        $url = 'locations/postalzones/destinations?' . http_build_query(
                array(
                    'platform' => $brand->platformCode,
                    'platform_country' => $countryCode,
                    'language' => $lang,
                )
            );

        $response = $this->call(HttpClient::HTTP_METHOD_GET, $url);

        $postalZones = PostalZone::fromBatch($response->decodeBodyToArray());

        $postalZones = array_filter(
            $postalZones,
            function ($postalZone) use ($countryCode) {
                /** @var PostalZone $postalZone */
                return $postalZone->isoCode === $countryCode;
            }
        );

        return $postalZones;
    }

    /**
     * Gets available shipping services delivery details for given search data.
     *
     * @param ShippingServiceSearch $params Search parameters.
     *
     * @return ShippingServiceDetails[] Found services with details.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getShippingServicesDeliveryDetails(ShippingServiceSearch $params)
    {
        if (!$params->isValid()) {
            Logger::logDebug('Missing required search parameter(s).', 'Core', $params->toArray());
            throw new HttpRequestException('Missing required search parameter(s).', 400);
        }

        $response = $this->call(HttpClient::HTTP_METHOD_GET, 'services?' . http_build_query($params->toArray()));

        $body = $response->decodeBodyToArray();
        if (empty($body)) {
            return array();
        }

        $shippingDetails = ShippingServiceDetails::fromBatch($body);

        if (!$this->configService->dropOffShippingServicesSupported()) {
            return $this->getShippingServicesDeliveryDetailsWithoutDropOffServices($shippingDetails, $params);
        }

        foreach ($shippingDetails as $shippingDetail) {
            $shippingDetail->departureCountry = $params->fromCountry;
            $shippingDetail->destinationCountry = $params->toCountry;
            $shippingDetail->national = $params->toCountry === $params->fromCountry;
        }

        return $shippingDetails;
    }

    /**
     * Gets details about the service.
     *
     * @param int $id Service Id.
     *
     * @return ShippingService Shipping service.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getShippingServiceDetails($id)
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, "services/available/$id/details");

        return ShippingService::fromArray($response->decodeBodyToArray());
    }

    /**
     * Sends shipment draft to Packlink.
     *
     * @param Draft $draft Shipment draft.
     *
     * @return string Shipment reference for uploaded draft.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     * @throws \Packlink\BusinessLogic\Http\Exceptions\DraftNotCreatedException
     */
    public function sendDraft(Draft $draft)
    {
        $response = $this->call(HttpClient::HTTP_METHOD_POST, 'shipments', $draft->toArray());

        $result = $response->decodeBodyToArray();
        $reference = array_key_exists('reference', $result) ? $result['reference'] : '';

        if (!$reference) {
            Logger::logError(
                'Draft reference not returned by Packlink API.',
                'Core',
                array(
                    'Draft data' => $draft->toArray(),
                    'Response' => $response,
                )
            );

            throw new DraftNotCreatedException('Draft reference not returned by Packlink API.');
        }

        $this->sendAnalytics(Analytics::EVENT_DRAFT_CREATED);

        return $reference;
    }

    /**
     * Returns shipment by its reference identifier.
     *
     * @param string $referenceId Packlink shipment reference identifier.
     *
     * @return Shipment|null Shipment DTO if it exists for given reference number; otherwise, null.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getShipment($referenceId)
    {
        $response = $this->getShipmentData($referenceId);

        return $response !== null ? Shipment::fromArray($response->decodeBodyToArray()) : null;
    }

    /**
     * Returns list of shipment labels for shipment with provided reference.
     *
     * @param string $referenceId Packlink shipment reference identifier.
     *
     * @return string[] Array of shipment labels.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getLabels($referenceId)
    {
        $response = $this->getShipmentData($referenceId, 'labels');

        return $response !== null ? $response->decodeBodyToArray() : array();
    }

    /**
     * Returns tracking information by its reference identifier.
     *
     * @param string $referenceId Packlink shipment reference identifier.
     *
     * @return Tracking[] Tracking DTO.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getTrackingInfo($referenceId)
    {
        $response = $this->getShipmentData($referenceId, 'track');

        return $response !== null ? Tracking::fromBatch($response->decodeBodyToArray()) : array();
    }

    /**
     * Sends the analytics data. Includes current integrated system name and version.
     *
     * @param string $eventName The name of the event to send.
     */
    public function sendAnalytics($eventName)
    {
        $data = new Analytics(
            $eventName,
            $this->configService->getECommerceName(),
            $this->configService->getECommerceVersion(),
            $this->configService->getModuleVersion()
        );

        try {
            $this->call(HttpClient::HTTP_METHOD_POST, 'analytics', $data->toArray());
        } catch (HttpBaseException $e) {
            Logger::logWarning('Could not send analytics data. Exception: ' . $e->getMessage());
        }
    }

    /**
     * Search the known customs unions between two postal codes.
     *
     * @param CustomsUnionsSearchRequest $request
     *
     * @return array
     *
     * @throws HttpAuthenticationException
     * @throws HttpRequestException
     * @throws HttpCommunicationException
     */
    public function getCustomsByPostalCode(CustomsUnionsSearchRequest $request)
    {
        $result = $this->call(HttpClient::HTTP_METHOD_POST, '/customs-unions/search-by-postal-code',
            $request->toArray())
            ->decodeBodyToArray();

        return isset($result['data']) ? $result['data'] : array();
    }

    /**
     * Creates a new customs invoice.
     *
     * @param CustomsInvoice $customsInvoice
     *
     * @return string|null
     *
     * @throws HttpAuthenticationException
     * @throws HttpRequestException
     * @throws HttpCommunicationException
     */
    public function sendCustomsInvoice(CustomsInvoice $customsInvoice)
    {
        $result = $this->call(HttpClient::HTTP_METHOD_POST, '/customs-invoices', $customsInvoice->toArray())
            ->decodeBodyToArray();

        return isset($result['id']) ? $result['id'] : null;
    }

    /**
     * Retrieves customs invoice download url.
     *
     * @param $customsInvoiceId
     *
     * @return string
     *
     * @throws HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws HttpRequestException
     */
    public function getCustomsInvoiceDownloadUrl($customsInvoiceId)
    {
        $result = $this->call(HttpClient::HTTP_METHOD_GET, "/customs-invoices/$customsInvoiceId/download")
            ->decodeBodyToArray();

        return isset($result['url']) ? $result['url'] : '';
    }

    /**
     * @param $accessToken
     *
     * @return string
     * @throws HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws HttpRequestException
     */
    public function getApiKeyWithToken($accessToken)
    {
        try {
            $response = $this->callWithToken(HttpClient::HTTP_METHOD_POST, 'users/api/keys', $accessToken);
        } catch (HttpRequestException $e) {
            $response = $this->callWithToken(HttpClient::HTTP_METHOD_GET, 'users/api/keys', $accessToken);
        }

        $data = json_decode($response->getBody(), true);

        if (isset($data['token'])) {
            return $data['token'];
        }

        throw new HttpAuthenticationException('Could not retrieve API key.');
    }

    /**
     * @param $shippingDetails
     * @param $params
     * @return array
     */
    protected function getShippingServicesDeliveryDetailsWithoutDropOffServices($shippingDetails, $params)
    {
        $newShippingDetails = array();

        foreach ($shippingDetails as $shippingDetail) {
            if ($shippingDetail->destinationDropOff) {
                continue;
            } else {
                $shippingDetail->departureCountry = $params->fromCountry;
                $shippingDetail->destinationCountry = $params->toCountry;
                $shippingDetail->national = $params->toCountry === $params->fromCountry;
                $newShippingDetails[] = $shippingDetail;
            }
        }

        return $newShippingDetails;
    }

    /**
     * Calls shipments endpoint and handles response. Any shipment endpoint can return 404 so this call handles that.
     *
     * @param string $reference Shipment reference number.
     * @param string $endpoint Endpoint to call.
     *
     * @return \Logeecom\Infrastructure\Http\HttpResponse|null Response if API returned it; NULL if 404.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    protected function getShipmentData($reference, $endpoint = '')
    {
        if ($endpoint) {
            $endpoint = '/' . $endpoint;
        }

        try {
            $response = $this->call(HttpClient::HTTP_METHOD_GET, "shipments/{$reference}{$endpoint}");
        } catch (HttpRequestException $e) {
            if ($e->getCode() === 404) {
                return null;
            }

            throw $e;
        }

        return $response;
    }

    /**
     * Makes a HTTP call and returns response.
     *
     * @param string $method HTTP method (GET, POST, PUT, etc.).
     * @param string $endpoint Endpoint resource on remote API.
     * @param array $body Request payload body.
     *
     * @return HttpResponse Response from request.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    protected function call($method, $endpoint, array $body = array())
    {
        if (!$this->ensureIntegrationIsRegistered($endpoint)) {
            throw new HttpAuthenticationException(
                'Integration is not registered.'
            );
        }

        $bodyStringToSend = '';
        if (in_array(strtoupper($method), array(HttpClient::HTTP_METHOD_POST, HttpClient::HTTP_METHOD_PUT), true)) {
            $bodyStringToSend = json_encode($body);
        }

        $response = $this->client->request(
            $method,
            static::BASE_URL . static::API_VERSION . ltrim($endpoint, '/'),
            $this->getRequestHeaders(),
            $bodyStringToSend
        );

        $this->validateResponse($response);

        return $response;
    }

    /**
     * Makes a HTTP call to Packlink API without the API version prefix.
     * Used for endpoints outside the /v1/ namespace (e.g. /pro/subscriptions/).
     *
     * @param string $method HTTP method (GET, POST, PUT, DELETE).
     * @param string $endpoint API endpoint path (without base URL or version).
     * @param array $body Request body (for POST/PUT).
     *
     * @return HttpResponse HTTP response.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    protected function callBase($method, $endpoint, array $body = array())
    {
        if (!$this->ensureIntegrationIsRegistered($endpoint)) {
            throw new HttpAuthenticationException(
                'Integration is not registered.'
            );
        }

        $bodyStringToSend = '';
        if (in_array(strtoupper($method), array(HttpClient::HTTP_METHOD_POST, HttpClient::HTTP_METHOD_PUT), true)) {
            $bodyStringToSend = json_encode($body);
        }

        $response = $this->client->request(
            $method,
            static::BASE_URL . ltrim($endpoint, '/'),
            $this->getRequestHeaders(),
            $bodyStringToSend
        );

        $this->validateResponse($response);

        return $response;
    }

    /**
     * Returns the merchant's active Packlink subscription.
     *
     * @return Subscription Active subscription DTO.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getActiveSubscription()
    {
        $response = $this->callBase(HttpClient::HTTP_METHOD_GET, 'pro/subscriptions/client/active');

        return Subscription::fromArray($response->decodeBodyToArray());
    }

    /**
     * Gets the order_reference for a shipment via the v1 API.
     *
     * @param string $reference Shipment reference.
     *
     * @return string|null Order reference, or null if not found.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getOrderReference($reference)
    {
        $response = $this->call(HttpClient::HTTP_METHOD_GET, 'shipments/' . $reference);
        $data = $response->decodeBodyToArray();

        return isset($data['order_reference']) ? $data['order_reference'] : null;
    }

    /**
     * Gets the public (shareable) tracking page URL for a shipment via the BFF postsale endpoint.
     *
     * Flow: v1 API (order_reference) -> BFF init (session) -> BFF postsale (tracking URL).
     *
     * @param string $reference Shipment reference.
     * @param string $locale    Locale for the tracking page, e.g. 'en-GB'.
     *
     * @return string|null Public tracking URL, or null if unavailable.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getPublicTrackingUrl($reference, $locale = 'en-GB')
    {
        $orderReference = $this->getOrderReference($reference);
        if (!$orderReference) {
            return null;
        }

        $response = $this->callBff("postsale/{$orderReference}/{$reference}?locale={$locale}");
        $postsale = BffPostsaleResponse::fromArray($response->decodeBodyToArray());

        return $postsale->publicTrackingUrl ?: null;
    }

    /**
     * Gets the estimated delivery date from the BFF public tracking endpoint.
     *
     * @param string $publicTrackingUrl Full public tracking URL containing the tracking ref.
     *
     * @return string|null Estimated delivery date, or null if unavailable.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    public function getEstimatedDeliveryDate($publicTrackingUrl)
    {
        $trackingRef = basename(parse_url($publicTrackingUrl, PHP_URL_PATH));
        if (!$trackingRef) {
            return null;
        }

        $response = $this->callBff("tracking/public/{$trackingRef}");
        $tracking = BffTrackingResponse::fromArray($response->decodeBodyToArray());

        return $tracking->estimatedDeliveryDate ?: null;
    }

    /**
     * Makes a HTTP call with Bearer token and returns response.
     *
     * @param string $method HTTP method (GET, POST, PUT, etc.).
     * @param string $endpoint Endpoint resource on remote API.
     * @param array $body Request payload body.
     * @param string $accessToken Bearer token for authentication.
     *
     * @return HttpResponse Response from request.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    protected function callWithToken($method, $endpoint, $accessToken, array $body = array())
    {
        $bodyStringToSend = '';
        if (in_array(strtoupper($method), array(HttpClient::HTTP_METHOD_POST, HttpClient::HTTP_METHOD_PUT), true)) {
            $bodyStringToSend = json_encode($body);
        }

        $headers = array(
            'accept' => 'Accept: application/json',
            'content' => 'Content-Type: application/json',
            'token' => 'Authorization: ' . 'Bearer ' . $accessToken,
            'Module-Version' => 'X-Module-Version: ' . $this->configService->getModuleVersion(),
            'Ecommerce-Name' => 'X-Ecommerce-Name: ' . $this->configService->getECommerceName(),
            'Ecommerce-Version' => 'X-Ecommerce-Version: ' . $this->configService->getECommerceVersion(),
        );

        $response = $this->client->request(
            $method,
            static::BASE_URL . static::API_VERSION . ltrim($endpoint, '/'),
            $headers,
            $bodyStringToSend
        );

        $this->validateResponse($response);

        return $response;
    }

    /**
     * Validates HTTP response.
     *
     * @param HttpResponse $response HTTP response returned from API call.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    protected function validateResponse(HttpResponse $response)
    {
        if (!$response->isSuccessful()) {
            $httpCode = $response->getStatus();
            $error = $message = $response->decodeBodyToArray();
            if (is_array($error)) {
                $message = '';
                if (isset($error['messages']) && is_array($error['messages'])) {
                    $message = implode("\n", array_column($error['messages'], 'message'));
                    // if there is only one message, 'messages' is associative array
                    if (empty($message) && !empty($error['messages']['message'])) {
                        $message = $error['messages']['message'];
                    }
                } elseif (isset($error['message'])) {
                    $message = $error['message'];
                }
            }

            if ($httpCode === HttpClient::HTTP_STATUS_CODE_NOT_FOUND) {
                $message = '404 Not found.';
            }

            Logger::logInfo($message);
            if ($httpCode === HttpClient::HTTP_STATUS_CODE_UNAUTHORIZED) {
                throw new HttpAuthenticationException($message, $httpCode);
            }

            throw new HttpRequestException($message, $httpCode);
        }
    }

    /**
     * Ensures the integration is registered before making an API call.
     * For legacy merchants without a stored integration ID, attempts auto-registration.
     *
     * @param string $endpoint Endpoint of current call to the API.
     *
     * @return bool
     */
    private function ensureIntegrationIsRegistered($endpoint)
    {
        // Pre-auth endpoints proceed regardless of token state (e.g. POST /register).
        if (!$this->isAuthRequired($endpoint)) {
            return true;
        }

        // No token => block. Was previously `return true`, which let unauthenticated
        // requests reach Packlink and is the root cause of the empty-Authorization
        // POST /integrations errors.
        if (!$this->configService->getAuthorizationToken()) {
            return false;
        }

        if ($this->isIntegrationCheckExcluded($endpoint)) {
            return true;
        }

        if ($this->configService->getIntegrationId()) {
            return true;
        }

        try {
            $payload = new IntegrationRegistrationPayload(
                $this->dataProvider->getIntegrationType(),
                $this->dataProvider->getIntegrationGuid(),
                $this->dataProvider->getIntegrationName(),
                'X-Packlink-Webhook-Secret',
                $this->dataProvider->getWebhookSecret(),
                $this->dataProvider->getIntegrationWebhookStatusUpdateUrl()
            );
            $integrationId = $this->registerIntegration($payload);
            if ($integrationId) {
                $this->configService->setIntegrationId($integrationId);

                return true;
            }
        } catch (Exception $e) {
            return false;
        }

        return false;
    }

    /**
     * Checks if current request endpoint requires integration registration check.
     *
     * @param string $endpoint endpoint of current request
     *
     * @return bool
     */
    private function isIntegrationCheckExcluded($endpoint)
    {
        $excluded = array(
            'register',
            'users/api/keys',
            'integrations',
            'integrations/',
            'clients', // Login workflow endpoints
            'users/parcels',
            'clients/warehouses',
            'shipments/callback',
        );

        return in_array($endpoint, $excluded, true);
    }

    /**
     * Whether the endpoint requires an Authorization token. Returns false only for pre-login
     * endpoints (e.g. POST /register, /users/api/keys); every other endpoint requires a token.
     *
     * @param string $endpoint
     *
     * @return bool
     */
    private function isAuthRequired($endpoint): bool
    {
        return !in_array($endpoint, array('register', 'users/api/keys'), true);
    }

    /**
     * Initializes a BFF session (GET /bff/init). Cached per request lifecycle.
     *
     * @return string Session id.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    private function initBffSession()
    {
        if ($this->bffSessionId !== null) {
            return $this->bffSessionId;
        }

        $response = $this->client->request(
            HttpClient::HTTP_METHOD_GET,
            static::BASE_URL . 'bff/init',
            $this->getBffHeaders()
        );
        $this->validateResponse($response);

        $session = BffSessionResponse::fromArray($response->decodeBodyToArray());
        $this->bffSessionId = $session->sessionId;

        return $this->bffSessionId;
    }

    /**
     * Calls a BFF endpoint (no API version prefix) with the session id header.
     *
     * @param string $endpoint BFF endpoint path, e.g. 'tracking/public/{ref}'.
     *
     * @return HttpResponse HTTP response.
     *
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpAuthenticationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpCommunicationException
     * @throws \Logeecom\Infrastructure\Http\Exceptions\HttpRequestException
     */
    private function callBff($endpoint)
    {
        $headers = $this->getBffHeaders();
        $headers['session'] = 'X-Packlink-Session-Id: ' . $this->initBffSession();

        $response = $this->client->request(
            HttpClient::HTTP_METHOD_GET,
            static::BASE_URL . 'bff/' . ltrim($endpoint, '/'),
            $headers
        );
        $this->validateResponse($response);

        return $response;
    }

    /**
     * Returns base headers for BFF API calls.
     *
     * @return array
     */
    private function getBffHeaders()
    {
        return array(
            'token' => 'Authorization: ' . $this->configService->getAuthorizationToken(),
            'accept' => 'Accept: application/json',
            'content-type' => 'Content-Type: application/json',
        );
    }

    /**
     * Returns headers together with authorization entry.
     *
     * @return array Formatted request headers.
     */
    private function getRequestHeaders()
    {
        return array(
            'accept' => 'Accept: application/json',
            'content' => 'Content-Type: application/json',
            'token' => 'Authorization: ' . $this->configService->getAuthorizationToken(),
            'Module-Version' => 'X-Module-Version: ' . $this->configService->getModuleVersion(),
            'Ecommerce-Name' => 'X-Ecommerce-Name: ' . $this->configService->getECommerceName(),
            'Ecommerce-Version' => 'X-Ecommerce-Version: ' . $this->configService->getECommerceVersion(),
        );
    }

    /**
     * @return BrandConfigurationService
     */
    private function getBrandConfigurationService()
    {
        /** @noinspection PhpIncompatibleReturnTypeInspection */
        return ServiceRegister::getService(BrandConfigurationService::CLASS_NAME);
    }
}
