<?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 yii\httpclient\Client;

/**
 * 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([
            'baseUrl' => rtrim($this->apiUrl, '/'),
        ]);
    }

    // -----------------------------------------------------------------------
    // 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->createRequest()
            ->setMethod('POST')
            ->setUrl('Session/CreateToken')
            ->addHeaders([
                'Content-Type' => 'application/json',
                'accountCode'  => $this->accountCode,
                'username'     => $this->username,
                'password'     => $this->password,
            ])
            ->send();

        $data = json_decode($response->content, 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;
    }

    /**
     * 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
    {
        $response = $this->client->createRequest()
            ->setMethod('POST')
            ->setUrl($endpoint)
            ->addHeaders(['Content-Type' => 'application/json', 'Token' => $this->getAccessToken()])
            ->setFormat(Client::FORMAT_JSON)
            ->setData($body)
            ->send();

        if ($response->statusCode === 401) {
            $this->invalidateToken();
            $response = $this->client->createRequest()
                ->setMethod('POST')
                ->setUrl($endpoint)
                ->addHeaders(['Content-Type' => 'application/json', 'Token' => $this->getAccessToken()])
                ->setFormat(Client::FORMAT_JSON)
                ->setData($body)
                ->send();
        }

        $raw = $response->content;

        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
    {
        $data = $this->post('Consignment/CreateConsignment', $data);
        return $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->createRequest()
                ->setMethod('GET')
                ->setUrl('Version/Index')
                ->send();
            $data = json_decode($response->content, true);
            $data = is_array($data) ? $data['version'] : $response->content;
            return $response->isOk ? ['success' => true, 'message' => 'Connected. Version: ' . ($data ?? 'unknown')] : ['success' => false, 'message' => 'Failed to connect.'];
        } catch (\Exception $e) {
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }
}

/**
 * Palletforce REST API Client
 *
 * @category    PortalPeople
 * @package     Palletforce
 * @author      Portal People Development Team
 * @copyright   Copyright (c) 2026 Portal People (https://portalpeople.co.uk)
 */

namespace PortalPeople\palletforce\components;

use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
use yii\httpclient\Client;

/**
 * PalletforceClient
 *
 * Wraps the Palletforce Alliance REST API.
 * All methods use HTTP POST with a JSON body containing `accessKey`.
 *
 * Rate limit: maximum one call every 30 minutes. Error code -6 is returned when
 * the limit is reached.
 *
 * Base URLs and API keys are issued by Palletforce IT after testing sign-off.
 */
class PalletforceClient extends Component
{
    // -------------------------------------------------------------------------
    // API endpoint path constants (appended to $apiUrl)
    // -------------------------------------------------------------------------
    const ENDPOINT_UPLOAD_MANIFEST        = '/UploadManifest';
    const ENDPOINT_ACCEPT_CONSIGNMENTS    = '/AcceptConsignments';
    const ENDPOINT_DELETE_CONSIGNMENT     = '/DeleteConsignment';
    const ENDPOINT_DOWNLOAD_MANIFEST      = '/DownloadManifest';
    const ENDPOINT_DOWNLOAD_SHIPPING_INFO = '/DownloadShippingInformation';
    const ENDPOINT_DOWNLOAD_TRACKING      = '/DownloadTrackingData';
    const ENDPOINT_DOWNLOAD_ETA           = '/DownloadETATimes';
    const ENDPOINT_DOWNLOAD_HUB_BUILDING  = '/DownloadHubBuilding';
    const ENDPOINT_PRINT_LABELS           = '/PrintLabels';

    // -------------------------------------------------------------------------
    // Configuration properties
    // -------------------------------------------------------------------------

    /** @var string Palletforce REST API base URL. */
    public $apiUrl;

    /** @var string Unique access key provided by Palletforce. Sent in every request body. */
    public $accessKey;

    /** @var string Customer account number issued by Palletforce. */
    public $accountNumber;

    /** @var string Requesting depot code (3 chars). */
    public $depotCode;

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

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

    // -------------------------------------------------------------------------
    // Internal state
    // -------------------------------------------------------------------------

    /** @var Client */
    private $_http;

    // -------------------------------------------------------------------------
    // Lifecycle
    // -------------------------------------------------------------------------

    /** @inheritdoc */
    public function init()
    {
        parent::init();

        if (empty($this->apiUrl)) {
            throw new InvalidConfigException('Palletforce API URL must be configured.');
        }

        if (empty($this->accessKey)) {
            throw new InvalidConfigException('Palletforce accessKey must be configured.');
        }

        $this->_http = new Client([
            'baseUrl' => rtrim($this->apiUrl, '/'),
        ]);
    }

    // =========================================================================
    // Public API methods
    // =========================================================================

    /**
     * Upload / create a consignment (UploadManifest).
     *
     * The `accessKey`, `accountNumber` and `depotCode` are automatically
     * injected into the consignment if not already present.
     *
     * @param array  $consignment  Single consignment array as per API spec.
     * @param string $uniqueRef    Caller-supplied unique transaction reference.
     * @param array  $collectionAddress  Collection address fields.
     * @param array  $deliveryAddress    Delivery address fields.
     * @return array API response.
     */
    public function uploadManifest(
        array $consignment,
        string $uniqueRef,
        array $collectionAddress,
        array $deliveryAddress
    ): array {
        // Inject account number and depot if not set by caller
        if (empty($consignment['CustomerAccountNumber'])) {
            $consignment['CustomerAccountNumber'] = $this->accountNumber;
        }
        if (empty($consignment['requestingDepot'])) {
            $consignment['requestingDepot'] = $this->depotCode;
        }

        $payload = [
            'accessKey'               => $this->accessKey,
            'uniqueTransactionNumber' => $uniqueRef,
            'collectionAddress'       => $collectionAddress,
            'deliveryAddress'         => $deliveryAddress,
            'consignments'            => [$consignment],
        ];

        return $this->post(self::ENDPOINT_UPLOAD_MANIFEST, $payload);
    }

    /**
     * Accept one or more pending consignments.
     *
     * @param string[] $trackingCodes
     * @param string   $uniqueRef
     * @return array
     */
    public function acceptConsignments(array $trackingCodes, string $uniqueRef): array
    {
        return $this->post(self::ENDPOINT_ACCEPT_CONSIGNMENTS, [
            'accessKey'               => $this->accessKey,
            'trackingCodes'           => $trackingCodes,
            'uniqueTransactionNumber' => $uniqueRef,
        ]);
    }

    /**
     * Delete a consignment before acceptance.
     *
     * @param string $trackingCode
     * @param string $uniqueRef
     * @return array
     */
    public function deleteConsignment(string $trackingCode, string $uniqueRef): array
    {
        return $this->post(self::ENDPOINT_DELETE_CONSIGNMENT, [
            'accessKey'               => $this->accessKey,
            'trackingCode'            => $trackingCode,
            'uniqueTransactionNumber' => $uniqueRef,
        ]);
    }

    /**
     * Download consignments from Alliance.
     *
     * Supply one or more of: manifestDate, trackingCode, serviceCommitmentDate,
     * lastUpdated.
     *
     * @param array $criteria  e.g. ['manifestDate' => '20260318']
     * @return array
     */
    public function downloadManifest(array $criteria): array
    {
        return $this->post(self::ENDPOINT_DOWNLOAD_MANIFEST, array_merge(
            ['accessKey' => $this->accessKey],
            $criteria
        ));
    }

    /**
     * Download shipping information for a consignment.
     *
     * @param string $trackingCode
     * @return array
     */
    public function downloadShippingInfo(string $trackingCode): array
    {
        return $this->post(self::ENDPOINT_DOWNLOAD_SHIPPING_INFO, [
            'accessKey'   => $this->accessKey,
            'TrackingCode' => $trackingCode,
        ]);
    }

    /**
     * Download tracking events and POD for a consignment.
     *
     * @param string $trackingNumber
     * @param string $uniqueRef
     * @param array  $filters  Optional: manifestDate, serviceCommitmentDate, lastUpdated
     * @return array
     */
    public function downloadTrackingData(
        string $trackingNumber,
        string $uniqueRef,
        array $filters = []
    ): array {
        return $this->post(self::ENDPOINT_DOWNLOAD_TRACKING, array_merge([
            'accessKey'               => $this->accessKey,
            'trackingNumber'          => $trackingNumber,
            'uniqueTransactionNumber' => $uniqueRef,
        ], $filters));
    }

    /**
     * Get ETA times for one or more consignments.
     *
     * @param string[] $trackingCodes
     * @param string   $uniqueRef
     * @return array
     */
    public function downloadETATimes(array $trackingCodes, string $uniqueRef): array
    {
        return $this->post(self::ENDPOINT_DOWNLOAD_ETA, [
            'accessKey'               => $this->accessKey,
            'trackingCodes'           => $trackingCodes,
            'uniqueTransactionNumber' => $uniqueRef,
        ]);
    }

    /**
     * Determine hub building for one or more UK postcodes.
     *
     * @param string[] $postcodes  e.g. ['BS37', 'NG2']
     * @param string   $uniqueRef
     * @param string   $country    ISO 3166-1 alpha-2 (default 'GB')
     * @return array
     */
    public function downloadHubBuilding(
        array $postcodes,
        string $uniqueRef,
        string $country = 'GB'
    ): array {
        return $this->post(self::ENDPOINT_DOWNLOAD_HUB_BUILDING, [
            'accessKey'               => $this->accessKey,
            'postcodes'               => $postcodes,
            'country'                 => $country,
            'uniqueTransactionNumber' => $uniqueRef,
        ]);
    }

    /**
     * Print labels for a consignment (returns base64-encoded thermal label images).
     *
     * Leave $palletNumber null (or 0) for all pallets.
     *
     * @param string   $trackingNumber
     * @param string   $uniqueRef
     * @param int|null $palletNumber
     * @return array
     */
    public function printLabels(
        string $trackingNumber,
        string $uniqueRef,
        ?int $palletNumber = null
    ): array {
        return $this->post(self::ENDPOINT_PRINT_LABELS, [
            'accessKey'               => $this->accessKey,
            'trackingNumber'          => $trackingNumber,
            'palletNumber'            => $palletNumber,
            'uniqueTransactionNumber' => $uniqueRef,
        ]);
    }

    /**
     * Test connectivity by attempting a hub lookup for a known postcode.
     *
     * @return array ['success' => bool, 'message' => string]
     */
    public function testConnection(): array
    {
        try {
            $response = $this->downloadHubBuilding(['BS37'], 'TEST-' . time());

            if ($response['success'] ?? false) {
                return ['success' => true, 'message' => 'Connection to Palletforce API successful.'];
            }

            return [
                'success' => false,
                'message' => 'API reachable but returned error: ' . ($response['message'] ?? 'unknown'),
            ];
        } catch (\Exception $e) {
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }

    // =========================================================================
    // Private helpers
    // =========================================================================

    /**
     * Execute a POST request to the given endpoint.
     *
     * @param string $endpoint  Path relative to $apiUrl.
     * @param array  $payload   Data to JSON-encode.
     * @return array Decoded response body.
     * @throws \RuntimeException on HTTP or network errors.
     */
    private function post(string $endpoint, array $payload): array
    {
        $this->log("POST {$endpoint}", $payload);

        try {
            $response = $this->_http->createRequest()
                ->setMethod('POST')
                ->setUrl($endpoint)
                ->addHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json'])
                ->setFormat(Client::FORMAT_JSON)
                ->setData($payload)
                ->send();

            if (!$response->isOk) {
                throw new \RuntimeException("Palletforce API HTTP error [{$endpoint}]: " . $response->statusCode);
            }

            $body = $response->content;
            $data = json_decode($body, true) ?? [];

            $this->log("Response {$endpoint}", $data);

            return $data;
        } catch (\yii\httpclient\Exception $e) {
            $msg = "Palletforce API error [{$endpoint}]: " . $e->getMessage();
            Yii::error($msg, 'palletforce');
            throw new \RuntimeException($msg, (int) $e->getCode(), $e);
        }
    }

    /**
     * Conditional debug/info logger.
     *
     * @param string $message
     * @param mixed  $context
     */
    private function log(string $message, $context = null): void
    {
        if (!$this->debug) {
            return;
        }
        $msg = '[Palletforce] ' . $message;
        if ($context !== null) {
            $msg .= ': ' . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        }
        Yii::info($msg, 'palletforce');
    }
}
