<?php
/**
 * DX Despatch Manager API Client
 *
 * @category    PortalPeople
 * @package     DX
 * @author      Portal People Development Team
 * @copyright   Copyright (c) 2026 Portal People (https://portalpeople.co.uk)
 */

namespace PortalPeople\dx\components;

use Yii;
use yii\base\Component;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

/**
 * DxClient
 *
 * Wraps the DX Despatch Manager REST API.
 *
 * Token caching: once authenticated, the access token is stored in the Yii2
 * cache (key `dx_access_token`) until its expiry so that we never re-auth on
 * every request (a hard go-live requirement from DX).
 */
class DxClient extends Component
{
    /** @var string DX API base URL (e.g. https://despatch-api.dxdelivery.com) */
    public string $apiUrl = 'https://despatch-api.dxdelivery.com';

    /** @var string DX account code */
    public string $accountCode = '';

    /** @var string DX API username */
    public string $username = '';

    /** @var string DX API password */
    public string $password = '';

    /** @var bool Enable debug logging */
    public bool $debug = false;

    /** @var int HTTP timeout in seconds */
    public int $timeout = 30;

    /** @var Client|null */
    private ?Client $client = null;

    /** @var string|null Cached access token */
    private ?string $accessToken = null;

    // -----------------------------------------------------------------------
    // Initialisation
    // -----------------------------------------------------------------------

    public function init(): void
    {
        parent::init();
        $this->client = new Client([
            'base_uri' => rtrim($this->apiUrl, '/') . '/',
            'timeout'  => $this->timeout,
            'headers'  => ['Content-Type' => 'application/json'],
        ]);
    }

    // -----------------------------------------------------------------------
    // Authentication
    // -----------------------------------------------------------------------

    /**
     * Return a valid access token, fetching one if necessary.
     * Tokens are cached in Yii's default cache component.
     */
    private function getAccessToken(): string
    {
        if ($this->accessToken !== null) {
            return $this->accessToken;
        }

        $cacheKey = 'dx_access_token_' . md5($this->accountCode . $this->username);

        if (Yii::$app->has('cache')) {
            $cached = Yii::$app->cache->get($cacheKey);
            if ($cached !== false) {
                $this->accessToken = $cached;
                return $this->accessToken;
            }
        }

        $response = $this->client->post('Session/CreateToken', [
            'headers' => [
                'accountCode' => $this->accountCode,
                'username'    => $this->username,
                'password'    => $this->password,
            ],
            'body' => '',
        ]);

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

        $this->accessToken = $data['accessToken']['key'];
        $expiry = strtotime($data['accessToken']['expiry']) - time() - 60;

        if (Yii::$app->has('cache') && $expiry > 0) {
            Yii::$app->cache->set($cacheKey, $this->accessToken, $expiry);
        }

        return $this->accessToken;
    }

    /**
     * Return Guzzle request options pre-populated with the auth token header.
     */
    private function authOptions(array $extra = []): array
    {
        return array_merge_recursive([
            'headers' => ['Token' => $this->getAccessToken()],
        ], $extra);
    }

    /**
     * Invalidate the cached token (e.g. on 401).
     */
    private function invalidateToken(): void
    {
        $this->accessToken = null;
        $cacheKey = 'dx_access_token_' . md5($this->accountCode . $this->username);
        if (Yii::$app->has('cache')) {
            Yii::$app->cache->delete($cacheKey);
        }
    }

    // -----------------------------------------------------------------------
    // Internal HTTP helper
    // -----------------------------------------------------------------------

    /**
     * Execute a POST request with automatic token refresh on 401.
     *
     * @param string $endpoint  Relative URL, e.g. 'Consignment/CreateConsignment'
     * @param array  $body      JSON body as associative array
     * @return array            Decoded response body
     * @throws \Exception on failure
     */
    private function post(string $endpoint, array $body = []): array
    {
        try {
            $response = $this->client->post($endpoint, $this->authOptions(['json' => $body]));
        } catch (\GuzzleHttp\Exception\ClientException $e) {
            if ($e->getResponse()->getStatusCode() === 401) {
                $this->invalidateToken();
                $response = $this->client->post($endpoint, $this->authOptions(['json' => $body]));
            } else {
                throw $e;
            }
        }

        $raw = (string) $response->getBody();

        if ($this->debug) {
            Yii::info("[DxClient] POST {$endpoint} | response: {$raw}", 'dx');
        }

        return json_decode($raw, true) ?? [];
    }

    // -----------------------------------------------------------------------
    // Consignment endpoints
    // -----------------------------------------------------------------------

    /**
     * Create a new consignment.
     *
     * @param array $data  Consignment payload (see DX API spec)
     * @return array       { service: {...}, trackingNumbers: [...] }
     */
    public function createConsignment(array $data): array
    {
        return $this->post('Consignment/CreateConsignment', $data);
    }

    /**
     * Get consignment details by tracking number.
     */
    public function getConsignment(string $trackingNumber): array
    {
        return $this->post('Consignment/GetConsignment', ['TrackingNumber' => $trackingNumber]);
    }

    /**
     * Get individual item details by tracking number.
     */
    public function getItem(string $trackingNumber): array
    {
        return $this->post('Consignment/GetItem', ['TrackingNumber' => $trackingNumber]);
    }

    /**
     * Cancel an item. Each item must be cancelled individually.
     * Must be called before the despatch date.
     */
    public function cancelItem(string $trackingNumber): array
    {
        return $this->post('Consignment/CancelItem', ['TrackingNumber' => $trackingNumber]);
    }

    // -----------------------------------------------------------------------
    // Label endpoints
    // -----------------------------------------------------------------------

    /**
     * Retrieve a PDF label as a Base64-encoded string.
     *
     * @param string $trackingNumber
     * @param bool   $printWholeConsignment
     * @param string $paperSize  e.g. 'inch4x4', 'A4'
     * @param int    $a4StartingLocation
     * @return array  { labelData: '<base64 PDF>' }
     */
    public function printLabelPdf(
        string $trackingNumber,
        bool $printWholeConsignment = true,
        string $paperSize = 'inch4x4',
        int $a4StartingLocation = 1
    ): array {
        return $this->post('Label/PDF', [
            'trackingNumber'         => $trackingNumber,
            'printWholeConsignment'  => $printWholeConsignment,
            'pdfPaperSize'           => $paperSize,
            'pdfA4StartingLocation'  => $a4StartingLocation,
        ]);
    }

    /**
     * Retrieve a ZPL label.
     *
     * @param string $trackingNumber
     * @param bool   $printWholeConsignment
     * @return array  { labelData: '<ZPL string>' }
     */
    public function printLabelZpl(string $trackingNumber, bool $printWholeConsignment = true): array
    {
        return $this->post('Label/ZPL', [
            'trackingNumber'        => $trackingNumber,
            'printWholeConsignment' => $printWholeConsignment,
        ]);
    }

    // -----------------------------------------------------------------------
    // Services endpoint
    // -----------------------------------------------------------------------

    /**
     * Get available services for a delivery postcode.
     *
     * @param string $deliveryPostcode
     * @param string $deliveryCountryCode  Default 'GB'
     * @param string $serviceType         'Delivery' | 'Collection'
     * @return array  Array of Service objects
     */
    public function getServices(
        string $deliveryPostcode,
        string $deliveryCountryCode = 'GB',
        string $serviceType = 'Delivery'
    ): array {
        return $this->post('Services/GetServices', [
            'accountCode'          => $this->accountCode,
            'deliveryPostcode'     => $deliveryPostcode,
            'deliveryCountryCode'  => $deliveryCountryCode,
            'serviceType'          => $serviceType,
        ]);
    }

    // -----------------------------------------------------------------------
    // Tracking endpoints
    // -----------------------------------------------------------------------

    /**
     * Get tracking events for a single item.
     */
    public function trackingGetItem(string $trackingNumber): array
    {
        return $this->post('Tracking/GetItem', ['TrackingNumber' => $trackingNumber]);
    }

    /**
     * Get tracking events for a whole consignment.
     */
    public function trackingGetConsignment(string $trackingNumber): array
    {
        return $this->post('Tracking/GetConsignment', ['TrackingNumber' => $trackingNumber]);
    }

    /**
     * Search tracking by a partial query string.
     */
    public function trackingSearch(string $query): array
    {
        return $this->post('Tracking/Search', ['SearchQuery' => $query]);
    }

    /**
     * Incremental tracking update poll.
     *
     * @param int $fromTrackingId  Last processed tracking ID (0 to start from beginning)
     * @return array
     */
    public function trackingGetUpdates(int $fromTrackingId = 0): array
    {
        return $this->post('Tracking/GetUpdates', ['FromTrackingID' => $fromTrackingId]);
    }

    // -----------------------------------------------------------------------
    // Session management
    // -----------------------------------------------------------------------

    /**
     * Logout and invalidate the session token.
     */
    public function logout(): array
    {
        try {
            $result = $this->post('Session/Session_Logout', []);
        } finally {
            $this->invalidateToken();
        }
        return $result ?? [];
    }

    // -----------------------------------------------------------------------
    // Utility / test
    // -----------------------------------------------------------------------

    /**
     * Test the connection to the DX API by fetching the API version.
     * This endpoint is unauthenticated so it is safe to call before setup.
     *
     * @return array { success: bool, message: string }
     */
    public function testConnection(): array
    {
        try {
            $response = $this->client->get('Version/Index');
            $data = json_decode((string) $response->getBody(), true);
            return ['success' => true, 'message' => 'Connected. Version: ' . ($data['version'] ?? 'unknown')];
        } catch (\Exception $e) {
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }
}
