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

namespace PortalPeople\gforce\components;

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

/**
 * GForceClient
 *
 * Wraps the Vigo / Pallet Track REST API.
 *
 * Authentication is via the `code` query parameter, which maps to
 * the customer API key provisioned by Vigo.
 *
 * Primary UAT root URL:
 *   https://api-uat.vigocloud.com/api/v1
 */
class GForceClient extends Component
{
    // -------------------------------------------------------------------------
    // Configuration properties
    // -------------------------------------------------------------------------

    /** @var string Vigo REST API base URL. */
    public $apiUrl = 'https://api-uat.vigocloud.com/api/v1';

    /** @var string Customer API key issued by GForce IT. Used in all API calls. */
    public $apiKey;

    /** @var bool When true, sets Confirm=yes in the manifest (sends directly to depot). */
    public $autoConfirm = false;

    /** @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('GForce API URL must be configured.');
        }

        if (empty($this->apiKey)) {
            throw new InvalidConfigException('GForce API key must be configured.');
        }

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

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

    /**
    * Create a consignment in Vigo.
     *
     * @param array $manifest  Full manifest array as returned by ConsignmentForm::getManifest().
     * @param bool  $commit    If true, data is stored as a live consignment.
     *                         If false, data is only validated (no import occurs).
     * @return array Decoded JSON response.
     */
    public function createConsignment(array $manifest, bool $commit = false): array
    {
        $endpoint = '/consignments';
        $this->log('POST /consignments (commit=' . ($commit ? 'true' : 'false') . ')', $manifest);

        try {
            // prepare payload (normalize types and defaults)
            $payload = $manifest;
            if (isset($payload['References']['ConsignmentType'])) {
                $payload['References']['ConsignmentType'] = (string) $payload['References']['ConsignmentType'];
            }
            if ($commit) {
                $payload['Commit'] = true;
            } else {
                $payload['Commit'] = false;
            }

            // ensure contacts exist
            if (!isset($payload['Collection']['Contacts']) || !is_array($payload['Collection']['Contacts'])) {
                $payload['Collection']['Contacts'] = [];
                if (!empty($payload['Collection']['Address']['Name'])) {
                    $payload['Collection']['Contacts'][] = ['Name' => $payload['Collection']['Address']['Name']];
                }
            }
            if (!isset($payload['Delivery']['Contacts']) || !is_array($payload['Delivery']['Contacts'])) {
                $payload['Delivery']['Contacts'] = [];
                if (!empty($payload['Delivery']['Address']['Name'])) {
                    $payload['Delivery']['Contacts'][] = ['Name' => $payload['Delivery']['Address']['Name']];
                }
            }

            // uppercase item types
            if (!empty($payload['GoodsDefinition']['Items']) && is_array($payload['GoodsDefinition']['Items'])) {
                foreach ($payload['GoodsDefinition']['Items'] as $i => $it) {
                    if (isset($it['ItemType'])) {
                        $payload['GoodsDefinition']['Items'][$i]['ItemType'] = strtoupper((string) $it['ItemType']);
                    }
                }
            }

            // Debug: log payload and endpoint
            $this->log('POST ' . $endpoint . ' (commit=' . ($commit ? 'true' : 'false') . ')', $payload);

            // ActionLog::create(['class' => self::class, 'action' => 'gforce-create', 'relatedid' => $cart->id, 'type' => 'Info', 'message' => "GForce: Sending Consignment for cart #{$cart->orderref}"]);
            $response = $this->_http->createRequest()
                ->setMethod('POST')
                ->setUrl($endpoint . '?' . http_build_query($this->defaultQuery()))
                ->setFormat(Client::FORMAT_JSON)
                ->setData($payload)
                ->setOptions(['timeout' => $this->timeout])
                ->send();
                
            // Log raw response and status
            $this->log('createConsignment response', $response->content);
            $data = json_decode($response->content, true) ?? [];
        
            return $data;
        } catch (\Exception $e) {
            return $this->handleError('/consignments', $e);
        }
    }

    /**
     * Update an existing consignment by Vigo tracking code.
     *
     * @param string $trackingCode
     * @param array $manifest
     * @return array
     */
    public function updateConsignment(string $trackingCode, array $manifest): array
    {
        $path = '/consignments/' . rawurlencode($trackingCode);

        try {
            $response = $this->_http->createRequest()
                ->setMethod('PUT')
                ->setUrl($path . '?' . http_build_query($this->defaultQuery()))
                ->setFormat(Client::FORMAT_JSON)
                ->setData($manifest)
                ->setOptions(['timeout' => $this->timeout])
                ->send();

            return json_decode($response->content, true) ?? [];
        } catch (\Exception $e) {
            return $this->handleError($path, $e);
        }
    }

    /**
    * Get consignment status using tracking code.
     *
     * Returns dc_syscon (GForce Tracking ID) and other details.
     *
     * @param string $responseId  Backward-compatible alias for tracking code.
     * @return array
     */
    public function getConStatusById(string $responseId): array
    {
        return $this->get('/consignments/status/', ['trackingCode' => $responseId]);
    }

    /**
     * Get consignment status using the customer-supplied consignment number.
     *
     * Returns GForce Tracking ID and tracking link.
     *
     * @param string $conNo  Customer consignment number (the Number field).
     * @return array
     */
    public function getConStatusByConNo(string $conNo): array
    {
        return $this->get('/consignments/status/', ['consignmentNumber' => $conNo]);
    }

    /**
     * Get consignment status using the GForce Tracking ID.
     *
     * @param string $trackingId  GForce Tracking ID (dc_syscon).
     * @return array
     */
    public function getConStatusByTrackingId(string $trackingId): array
    {
        return $this->get('/consignments/status/', ['trackingCode' => $trackingId]);
    }

    /**
     * Get all consignments entered on a specific day.
     *
     * @param string $date  Date in YYYY-MM-DD format. Omit for today.
     * @return array
     */
    public function getConsEntered(string $date = ''): array
    {
        if (!empty($date)) {
            $from = gmdate('Y-m-d\\T00:00:00.000\\Z', strtotime($date));
            $to = gmdate('Y-m-d\\T23:59:59.999\\Z', strtotime($date));
            return $this->get('/consignments/status', ['from' => $from, 'to' => $to]);
        }

        return $this->get('/consignments/status');
    }

    /**
     * Get full consignment details using the GForce Tracking ID.
     *
     * @param string $trackingId
     * @return array
     */
    public function getConsignment(string $trackingId): array
    {
        return $this->get('/consignments/' . rawurlencode($trackingId));
    }

    /**
     * Manifest (confirm and submit) a consignment from the customer account.
     *
     * Only applicable to consignments created with Confirm=no.
     *
     * @param string $responseId
     * @return array
     */
    public function manifestConsignment(string $responseId): array
    {
        return [
            'Status' => [
                'Code' => 'UNSUPPORTED',
                'Description' => 'Manifest endpoint is not defined in Vigo v1 documentation.',
            ],
        ];
    }

    /**
     * Delete a consignment from the customer account.
     *
     * Only applicable to consignments created with Confirm=no.
     *
     * @param string $responseId
     * @return array
     */
    public function deleteConsignment(string $responseId): array
    {
        return $this->delete('/consignments/' . rawurlencode($responseId));
    }

    /**
     * Get tracking notes and tracking URL by customer consignment number.
     *
     * @param string $conNo  Customer consignment number.
     * @return array
     */
    public function getTrackingNotesByConNo(string $conNo): array
    {
        return $this->get('/consignments/status', ['consignmentNumber' => $conNo]);
    }

    /**
     * Get all tracking note updates for a consignment by GForce Tracking ID.
     *
     * @param string $trackingId  GForce Tracking ID.
     * @return array
     */
    public function getTrackingNotes(string $trackingId): array
    {
        return $this->get('/consignments/status', ['trackingCode' => $trackingId]);
    }

    /**
     * Get available services between two locations.
     *
     * Highly recommended to call before selecting a service level.
     *
     * @param string $consignmentType  D=Delivery, C=Collection, 3=ThirdParty
     * @param string $collectionCountry  ISO country code (e.g. GB)
     * @param string $collectionPostcode
     * @param string $deliveryCountry
     * @param string $deliveryPostcode
     * @return array
     */
    public function getAvailableServices(
        string $consignmentType,
        string $collectionCountry,
        string $collectionPostcode,
        string $deliveryCountry,
        string $deliveryPostcode
    ): array {
        return [
            'Status' => [
                'Code' => 'UNSUPPORTED',
                'Description' => 'Route/service endpoint is not documented in Vigo v1.',
            ],
            'Requested' => [
                'consignmentType' => $consignmentType,
                'collectionCountry' => $collectionCountry,
                'collectionPostcode' => $collectionPostcode,
                'deliveryCountry' => $deliveryCountry,
                'deliveryPostcode' => $deliveryPostcode,
            ],
        ];
    }

    /**
     * Retrieve a consignment label as PDF by Response ID.
     *
     * @param string $responseId
     * @return string  Raw PDF binary content.
     */
    public function getLabelsById(string $responseId): string|array
    {
        return $this->getLabelPdfByTrackingCode($responseId);
    }

    /**
     * Retrieve a consignment label as PDF by consignment number.
     *
     * @param string $conNo  Customer consignment number.
     * @return string  Raw PDF binary content.
     */
    public function getLabelsByConNo(string $conNo): string
    {
        $status = $this->get('/consignments/status', ['consignmentNumber' => $conNo]);
        $trackingCode = $this->extractTrackingCode($status);
        if ($trackingCode === '') {
            throw new \RuntimeException('Could not resolve tracking code for consignment number: ' . $conNo);
        }

        $data = $this->getLabelPdfByTrackingCode($trackingCode);

        yii::warning(['getLabelsByConNo' => [
            'conNo' => $conNo,
            'trackingCode' => $trackingCode,
            'pdfDataLength' => strlen($data),
        ]], 'gforce');
        return $data;
    }

    /**
     * Get POD (Proof of Delivery) PDF by GForce Tracking ID.
     *
     * @param string $trackingId
     * @return string  Raw PDF binary content.
     */
    public function getPodByTrackingId(string $trackingId): string
    {
        $imageIds = $this->getImageIdsByTrackingCode($trackingId, true, false);
        [$imageType, $uId] = $this->extractFirstImageRef($imageIds);
        if ($imageType === '' || $uId === '') {
            throw new \RuntimeException('No POD image references returned for tracking code: ' . $trackingId);
        }

        $imageData = $this->getImageData($imageType, $uId);
        $base64 = $this->extractBase64FromResponse($imageData);
        if ($base64 === '') {
            throw new \RuntimeException('No POD image data returned for tracking code: ' . $trackingId);
        }

        $decoded = base64_decode($base64, true);
        if ($decoded === false) {
            throw new \RuntimeException('Invalid base64 POD payload for tracking code: ' . $trackingId);
        }

        return $decoded;
    }

    /**
     * Get ETA by tracking code.
     */
    public function getEtaByTrackingCode(string $trackingCode, bool $includeAll = false): array
    {
        $query = ['trackingCode' => $trackingCode];
        if ($includeAll) {
            $query['includeAll'] = 'true';
        }

        return $this->get('/consignments/eta', $query);
    }

    /**
     * Get ETA by consignment number.
     */
    public function getEtaByConsignmentNumber(string $consignmentNumber, bool $includeAll = false): array
    {
        $query = ['consignmentNumber' => $consignmentNumber];
        if ($includeAll) {
            $query['includeAll'] = 'true';
        }

        return $this->get('/consignments/eta', $query);
    }

    /**
     * Get ETA by customer reference 1.
     */
    public function getEtaByCustomerReference1(string $customerReference1, bool $includeAll = false): array
    {
        $query = ['customerReference1' => $customerReference1];
        if ($includeAll) {
            $query['includeAll'] = 'true';
        }

        return $this->get('/consignments/eta', $query);
    }

    /**
     * Get ETA by customer reference 2.
     */
    public function getEtaByCustomerReference2(string $customerReference2, bool $includeAll = false): array
    {
        $query = ['customerReference2' => $customerReference2];
        if ($includeAll) {
            $query['includeAll'] = 'true';
        }

        return $this->get('/consignments/eta', $query);
    }

    /**
     * Get ETA updates between timestamps.
     */
    public function getEtaByDateRange(string $fromTimestamp, string $toTimestamp): array
    {
        return $this->get('/consignments/eta', [
            'from' => $fromTimestamp,
            'to' => $toTimestamp,
        ]);
    }

    /**
     * Get image references by tracking code.
     */
    public function getImageIdsByTrackingCode(string $trackingCode, bool $includePod = false, bool $includePhoto = false): array
    {
        $query = ['trackingCode' => $trackingCode];
        if ($includePod) {
            $query['includePod'] = 'true';
        }
        if ($includePhoto) {
            $query['includePhoto'] = 'true';
        }

        return $this->get('/consignments/images', $query);
    }

    /**
     * Get image references by consignment number.
     */
    public function getImageIdsByConsignmentNumber(string $consignmentNumber, bool $includePod = false, bool $includePhoto = false): array
    {
        $query = ['consignmentNumber' => $consignmentNumber];
        if ($includePod) {
            $query['includePod'] = 'true';
        }
        if ($includePhoto) {
            $query['includePhoto'] = 'true';
        }

        return $this->get('/consignments/images', $query);
    }

    /**
     * Get image references by customer reference 1.
     */
    public function getImageIdsByCustomerReference1(string $customerReference1, bool $includePod = false, bool $includePhoto = false): array
    {
        $query = ['customerReference1' => $customerReference1];
        if ($includePod) {
            $query['includePod'] = 'true';
        }
        if ($includePhoto) {
            $query['includePhoto'] = 'true';
        }

        return $this->get('/consignments/images', $query);
    }

    /**
     * Get image references by customer reference 2.
     */
    public function getImageIdsByCustomerReference2(string $customerReference2, bool $includePod = false, bool $includePhoto = false): array
    {
        $query = ['customerReference2' => $customerReference2];
        if ($includePod) {
            $query['includePod'] = 'true';
        }
        if ($includePhoto) {
            $query['includePhoto'] = 'true';
        }

        return $this->get('/consignments/images', $query);
    }

    /**
     * Get image references between timestamps.
     */
    public function getImageIdsByDateRange(string $fromTimestamp, string $toTimestamp): array
    {
        return $this->get('/consignments/images', [
            'from' => $fromTimestamp,
            'to' => $toTimestamp,
        ]);
    }

    /**
     * Fetch base64 image data by image type and UID.
     */
    public function getImageData(string $imageType, string $uId): array
    {
        return $this->get('/consignments/images', [
            'imageType' => $imageType,
            'uId' => $uId,
        ]);
    }

    /**
     * Test connectivity and API key validity.
     *
     * Uses the AvailableServices endpoint with known GForce UK postcodes.
     *
     * @return array ['success' => bool, 'message' => string]
     */
    public function testConnection(): array
    {
        try {
            $result = $this->get('/consignments/configurationsettings');
            $code = $result['Status']['Code'] ?? ($result['Response']['Status']['Code'] ?? null);
            // Some valid API responses (e.g. /configurationsettings) omit Status
            // and return configuration data directly.
            if (isset($result['UnitCodes']) && is_array($result['UnitCodes'])) {
                return ['success' => true, 'message' => 'Connection to GForce API successful.'];
            }
            if ($code === 'OK') {
                return ['success' => true, 'message' => 'Connection to GForce API successful.'];
            }

            if ($code === 'ERROR' || $code === 'FAIL') {
                $desc = $result['Status']['Description']
                    ?? ($result['Response']['Status']['Description'] ?? 'unknown');
                return [
                    'success' => false,
                    'message' => 'API reachable but returned: ' . $code . ' - ' . $desc,
                ];
            }

            if (!empty($result)) {
                return ['success' => true, 'message' => 'Connection to GForce API successful.'];
            }

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

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

    /**
     * Default query parameters for all requests.
     */
    private function defaultQuery(): array
    {
        return [
            'code' => $this->apiKey,
        ];
    }

    /**
     * Execute a GET request and return decoded JSON.
     *
     * @param string $path  Path relative to base URL (with leading slash).
     * @param array  $extraQuery  Additional query parameters to merge.
     * @return array
     */
    private function get(string $path, array $extraQuery = []): array
    {
        $this->log('GET ' . $path, $extraQuery ?: null);

        try {
            $response = $this->_http->createRequest()
                ->setMethod('GET')
                ->setUrl($path)
                ->setData(array_merge($this->defaultQuery(), $extraQuery))
                ->setOptions(['timeout' => $this->timeout])
                ->send();

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

            $this->log('Response ' . $path, $data);

            return $data;
        } catch (\Exception $e) {
            return $this->handleError($path, $e);
        }
    }

    /**
     * Execute a DELETE request and return decoded JSON.
     *
     * @param string $path
     * @return array
     */
    private function delete(string $path): array
    {
        $this->log('DELETE ' . $path);

        try {
            $response = $this->_http->createRequest()
                ->setMethod('DELETE')
                ->setUrl($path)
                ->setData($this->defaultQuery())
                ->setOptions(['timeout' => $this->timeout])
                ->send();

            return json_decode($response->content, true) ?? [];
        } catch (\Exception $e) {
            return $this->handleError($path, $e);
        }
    }

    /**
     * Execute a GET request and return binary PDF content.
     *
     * @param string $path
     * @return string  Raw binary content.
     * @throws \RuntimeException on HTTP or network errors.
     */
    private function getPdf(string $path): string
    {
        $this->log('GET (PDF) ' . $path);

        try {
            $response = $this->_http->createRequest()
                ->setMethod('GET')
                ->setUrl($path)
                ->setData($this->defaultQuery())
                ->addHeaders(['Accept' => 'application/pdf'])
                ->setOptions(['timeout' => $this->timeout])
                ->send();

            return $response->content;
        } catch (\Exception $e) {
            $msg = 'GForce API error [' . $path . ']: ' . $e->getMessage();
            Yii::error($msg, 'gforce');
            throw new \RuntimeException($msg, (int) $e->getCode(), $e);
        }
    }

    /**
     * Request label data from Vigo and decode Base64 PDF content.
     *
     * @param string $trackingCode
     * @return string
     */
    private function getLabelPdfByTrackingCode(string $trackingCode): array|string
    {
        $result = $this->get('/consignments/' . rawurlencode($trackingCode) . '/labels/');
        $base64 = $this->extractBase64FromResponse($result);
        return $base64;
        if ($base64 === '') {
            throw new \RuntimeException('No label data returned for tracking code: ' . $trackingCode);
        }

        $decoded = base64_decode($base64, true);
        if ($decoded === false) {
            throw new \RuntimeException('Invalid base64 label payload for tracking code: ' . $trackingCode);
        }

        return $decoded;
    }

    /**
     * Extract Base64 data field from varying response shapes.
     *
     * @param array $result
     * @return string
     */
    private function extractBase64FromResponse(array $result): string
    {
        return (string) (
            $result['Data']
            ?? $result['Detail']['Data']
            ?? $result['Response']['Detail']['Data']
            ?? $result['Response']['Detail']['LabelData']
            ?? $result['Response']['Detail']['ImageData']
            ?? $result['Document']
            ?? ''
        );
    }

    /**
     * Extract tracking code from status response shapes.
     *
     * @param array $result
     * @return string
     */
    private function extractTrackingCode(array $result): string
    {
        return (string) (
            $result['TrackingCode']
            ?? $result['Detail']['TrackingCode']
            ?? $result['Response']['Detail']['TrackingCode']
            ?? ''
        );
    }

    /**
     * Extract first imageType/uId pair from image listing response shapes.
     *
     * @param array $result
     * @return array{0:string,1:string}
     */
    private function extractFirstImageRef(array $result): array
    {
        $candidates = [
            $result['Detail']['Data'] ?? null,
            $result['Response']['Detail']['Data'] ?? null,
            $result['Data'] ?? null,
        ];

        foreach ($candidates as $candidate) {
            if (!is_array($candidate)) {
                continue;
            }

            if (isset($candidate['imageType'], $candidate['uId'])) {
                return [(string) $candidate['imageType'], (string) $candidate['uId']];
            }

            foreach ($candidate as $row) {
                if (!is_array($row)) {
                    continue;
                }

                $imageType = (string) ($row['imageType'] ?? $row['ImageType'] ?? '');
                $uId = (string) ($row['uId'] ?? $row['UID'] ?? '');
                if ($imageType !== '' && $uId !== '') {
                    return [$imageType, $uId];
                }
            }
        }

        return ['', ''];
    }

    /**
     * Handle a Guzzle request exception and return a structured error array.
     *
     * @param string           $path
    * @param \Exception $e
     * @return array
     */
    private function handleError(string $path, \Exception $e): array
    {
        $msg = 'GForce API error [' . $path . ']: ' . $e->getMessage();
        Yii::error($msg, 'gforce');

        return [
            'Response' => [
                'Status' => [
                    'Code'        => 'ERROR',
                    'Description' => $e->getMessage(),
                ],
            ],
        ];
    }

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