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

namespace PortalPeople\caterpoint\components;

use Yii;
use yii\base\BaseObject;
use yii\base\InvalidConfigException;
use yii\httpclient\Client;
use yii\httpclient\Exception as HttpClientException;

/**
 * Caterpoint API Client
 * 
 * Handles authentication and API calls to Caterpoint REST API
 */
class CaterpointClient extends BaseObject
{
    /**
     * @var string Caterpoint API base endpoint URL
     */
    public string $apiEndpoint = '';

    /**
     * @var string Caterpoint API authentication token
     */
    public string $apiToken = '';

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

    /**
     * @var array Custom headers to include in requests
     */
    public array $headers = [];

    /**
     * @var Client HTTP client instance
     */
    private Client $httpClient;

    /**
     * @var string|null Last detected upstream response format
     */
    private ?string $lastResponseFormat = null;

    /**
     * Initialize the client
     * 
     * @throws InvalidConfigException
     */
    public function init(): void
    {
        parent::init();

        if (empty($this->apiEndpoint)) {
            throw new InvalidConfigException('Caterpoint API endpoint must be configured.');
        }

        if (empty($this->apiToken)) {
            throw new InvalidConfigException('Caterpoint API token must be configured.');
        }

        $this->httpClient = new Client([
            'baseUrl' => rtrim($this->apiEndpoint, '/') . '/',
        ]);
    }
    
    /**
     * Get default headers for requests
     * 
     * @param string $method HTTP method
     * @return array
     */
    private function getDefaultHeaders(string $method = 'GET'): array
    {
        $headers = [
            'Authorization' => 'Bearer ' . $this->apiToken,
            'Accept' => 'application/xml, text/xml, application/json',
        ];
        
        // Only set Content-Type for requests with body
        if ($method !== 'GET') {
            $headers['Content-Type'] = 'application/json';
        }
        
        return array_merge($headers, $this->headers);
    }

    /**
     * Get orders from Caterpoint API
     * 
     * @param array $filters Optional query filters
     * @param int $limit Maximum number of orders to retrieve
     * @param int $offset Pagination offset
     * @return array
     * @throws \Exception
     */
    public function getOrders(array $filters = [], int $limit = 100, int $offset = 0): array
    {
        $query = array_merge($filters, [
            'limit' => $limit,
            'offset' => $offset,
        ]);

        $response = $this->request('GET', 'api/integration/distributor/orders', $query);
        $orders = $this->extractOrders($response);
        $total = $this->extractTotal($response, count($orders));

        return [
            'orders' => $orders,
            'total' => $total,
            'limit' => $limit,
            'offset' => $offset,
        ];
    }

    /**
     * Get orders updated since a specific timestamp
     * 
     * @param string|null $cursor ISO 8601 timestamp or cursor
     * @param int $limit Maximum number of orders to retrieve
     * @return array
     * @throws \Exception
     */
    public function getOrdersUpdatedSince(?string $cursor = null, int $limit = 100): array
    {
        $filters = [];
        if ($cursor !== null && $cursor !== '') {
            $filters['updatedSince'] = $cursor;
        }

        $response = $this->getOrders($filters, $limit);

        // Determine next cursor from response
        $nextCursor = null;
        if (isset($response['nextCursor'])) {
            $nextCursor = $response['nextCursor'];
        } elseif (count($response['orders']) >= $limit) {
            // If we got a full page, use the last order's update timestamp as next cursor
            $lastOrder = end($response['orders']);
            if ($lastOrder && isset($lastOrder['updated_at'])) {
                $nextCursor = $lastOrder['updated_at'];
            }
        }

        return [
            'orders' => $response['orders'],
            'nextCursor' => $nextCursor,
            'total' => $response['total'],
        ];
    }

    /**
     * Get a single order by ID
     * 
     * @param string $orderId Caterpoint order ID
     * @return array
     * @throws \Exception
     */
    public function getOrder(string $orderId): array
    {
        return $this->request('GET', "api/integration/distributor/orders/{$orderId}");
    }

    /**
     * Mark an order as exported/deleted
     * 
     * @param string $orderId Caterpoint order ID
     * @return array
     * @throws \Exception
     */
    public function markOrderExported(string $orderId): array
    {
        return $this->request('DELETE', "api/integration/distributor/order/{$orderId}/mark-exported");
    }

    /**
     * Submit order confirmation to Caterpoint
     * 
     * @param array $confirmation Order confirmation data
     * @return array
     * @throws \Exception
     */
    public function submitOrderConfirmation(array $confirmation): array
    {
        return $this->request('POST', 'api/integration/distributor/order-confirmations', [], $confirmation);
    }

    /**
     * Submit invoice to Caterpoint
     * 
     * @param array $invoice Invoice data
     * @return array
     * @throws \Exception
     */
    public function submitInvoice(array $invoice): array
    {
        return $this->request('POST', 'api/integration/distributor/invoices', [], $invoice);
    }

    /**
     * Submit credit note to Caterpoint
     * 
     * @param array $creditNote Credit note data
     * @return array
     * @throws \Exception
     */
    public function submitCreditNote(array $creditNote): array
    {
        return $this->request('POST', 'api/integration/distributor/credit-notes', [], $creditNote);
    }

    /**
     * Get the detected format of the last upstream response.
     *
     * @return string|null
     */
    public function getLastResponseFormat(): ?string
    {
        return $this->lastResponseFormat;
    }

    /**
     * Execute an HTTP request to the API
     * 
     * @param string $method HTTP method (GET, POST, PUT, DELETE)
     * @param string $endpoint API endpoint
     * @param array $query Query parameters
     * @param array|null $body Request body data
     * @return array
     * @throws \Exception
     */
    private function request(string $method, string $endpoint, array $query = [], ?array $body = null): array
    {
        try {
            $endpointUrl = $this->normalizeEndpoint($endpoint);

            // Append query parameters to URL for GET requests
            if (!empty($query) && $method === 'GET') {
                $endpointUrl .= '?' . http_build_query($query);
            }

            $request = $this->httpClient
                ->createRequest()
                ->setMethod($method)
                ->setUrl($endpointUrl)
                ->setHeaders($this->getDefaultHeaders($method))
                ->setOptions([
                    'timeout' => $this->timeout,
                ]);
            
            // Only set data for non-GET requests with a body
            if ($body !== null && $method !== 'GET') {
                $request->setData($body);
            }

            $response = $request->send();

            if (!$response->isOk) {
                $statusCode = $response->statusCode;
                $responseContent = $this->formatErrorContent((string) $response->content);
                
                // Log API error response
                Yii::warning([
                    'message' => 'Caterpoint API returned error status',
                    'statusCode' => $statusCode,
                    'method' => $method,
                    'endpoint' => $endpointUrl,
                    'responseContent' => mb_substr($responseContent, 0, 2000),
                ], __METHOD__);
                
                throw new \Exception(
                    "Caterpoint API Error ({$statusCode}): {$responseContent}",
                    $statusCode
                );
            }

            return $this->parseResponseContent(
                (string) $response->content,
                (string) $response->headers->get('content-type', '')
            );
        } catch (HttpClientException $e) {
            throw new \Exception('Caterpoint API Error: ' . $e->getMessage(), 0, $e);
        } catch (\Throwable $throwable) {
            if ($throwable instanceof \Exception) {
                throw $throwable;
            }

            throw new \Exception('Caterpoint API Error: ' . $throwable->getMessage(), 0, $throwable);
        }
    }

    /**
     * Parse response body into a normalized array.
     *
     * @param string $content
     * @param string $contentType
     * @return array
     * @throws \Exception
     */
    private function parseResponseContent(string $content, string $contentType = ''): array
    {
        $trimmed = trim($content);
        if ($trimmed === '') {
            $this->lastResponseFormat = 'empty';
            return [];
        }

        $format = $this->detectResponseFormat($trimmed, $contentType);
        $this->lastResponseFormat = $format;

        if ($format === 'xml') {
            return $this->parseXmlContent($trimmed);
        }

        if ($format === 'json') {
            try {
                $decoded = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR);
                return is_array($decoded) ? $decoded : ['value' => $decoded];
            } catch (\JsonException $jsonException) {
                throw new \Exception('Caterpoint API returned invalid JSON: ' . $jsonException->getMessage(), 0, $jsonException);
            }
        }

        if ($format === 'html') {
            $preview = mb_substr($trimmed, 0, 500);
            
            // Log the full response for debugging
            Yii::warning([
                'message' => 'Caterpoint API returned HTML instead of XML/JSON',
                'contentType' => $contentType,
                'responsePreview' => $preview,
                'fullResponse' => mb_substr($trimmed, 0, 2000),
            ], __METHOD__);
            
            throw new \Exception(
                "Caterpoint API returned HTML instead of XML/JSON.\n" .
                "Content-Type: {$contentType}\n" .
                "Response preview: " . $preview
            );
        }

        throw new \Exception('Caterpoint API returned unsupported response format.');
    }

    /**
     * Detect upstream response format from content-type and payload.
     * Analyzes actual content first, then falls back to Content-Type header.
     *
     * @param string $trimmedContent
     * @param string $contentType
     * @return string
     */
    private function detectResponseFormat(string $trimmedContent, string $contentType = ''): string
    {
        $type = strtolower($contentType);
        
        // Analyze actual content structure first (most reliable)
        if (str_starts_with($trimmedContent, '{') || str_starts_with($trimmedContent, '[')) {
            $detectedFormat = 'json';
        } elseif (str_starts_with($trimmedContent, '<?xml') || (str_starts_with($trimmedContent, '<') && !str_starts_with(strtolower(substr($trimmedContent, 0, 20)), '<!doctype html') && !str_starts_with(strtolower(substr($trimmedContent, 0, 20)), '<html'))) {
            $detectedFormat = 'xml';
        } elseif (str_starts_with(strtolower(substr($trimmedContent, 0, 20)), '<!doctype html') || str_starts_with(strtolower(substr($trimmedContent, 0, 20)), '<html')) {
            $detectedFormat = 'html';
        } else {
            $detectedFormat = null;
        }
        
        // If we detected a format from content, use it and warn if Content-Type mismatches
        if ($detectedFormat !== null) {
            if ($type && !str_contains($type, $detectedFormat) && !($detectedFormat === 'xml' && str_contains($type, 'html'))) {
                Yii::warning([
                    'message' => 'Content-Type header mismatch with actual content format',
                    'contentType' => $contentType,
                    'detectedFormat' => $detectedFormat,
                ], __METHOD__);
            }
            
            // Special case: if Content-Type says HTML but content is XML, use XML
            if ($detectedFormat === 'xml' && str_contains($type, 'html')) {
                Yii::warning([
                    'message' => 'Caterpoint API returned XML with text/html Content-Type header (using XML)',
                    'contentType' => $contentType,
                ], __METHOD__);
                return 'xml';
            }
            
            return $detectedFormat;
        }
        
        // Fall back to Content-Type if content analysis was inconclusive
        if (str_contains($type, 'json')) {
            return 'json';
        }

        if (str_contains($type, 'xml')) {
            return 'xml';
        }

        if (str_contains($type, 'html')) {
            return 'html';
        }

        return 'text';
    }

    /**
     * Parse XML content into an associative array.
     *
     * @param string $xmlContent
     * @return array
     * @throws \Exception
     */
    private function parseXmlContent(string $xmlContent): array
    {
        $internalErrors = libxml_use_internal_errors(true);

        try {
            $xml = simplexml_load_string($xmlContent, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET);
            if ($xml === false) {
                $errors = array_map(static function ($error) {
                    return trim($error->message);
                }, libxml_get_errors());
                libxml_clear_errors();

                throw new \Exception('Invalid XML response: ' . implode('; ', array_filter($errors)));
            }

            $json = json_encode($xml);
            if ($json === false) {
                throw new \Exception('Unable to convert XML response to JSON representation.');
            }

            $decoded = json_decode($json, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                throw new \Exception('Unable to decode XML response: ' . json_last_error_msg());
            }

            return [
                $xml->getName() => $decoded,
            ];
        } finally {
            libxml_clear_errors();
            libxml_use_internal_errors($internalErrors);
        }
    }

    /**
     * Extract an orders list from variable API payload shapes.
     *
     * @param array $payload
     * @return array
     */
    private function extractOrders(array $payload): array
    {
        $orders = $payload['orders']
            ?? $payload['data']
            ?? $this->findNestedKey($payload, 'orders')
            ?? $this->findNestedKey($payload, 'order');

        if (!is_array($orders)) {
            return [];
        }

        return $this->normalizeOrderList($orders);
    }

    /**
     * Extract total count from variable API payload shapes.
     *
     * @param array $payload
     * @param int $fallback
     * @return int
     */
    private function extractTotal(array $payload, int $fallback): int
    {
        $total = $payload['total']
            ?? $payload['count']
            ?? $this->findNestedKey($payload, 'total')
            ?? $this->findNestedKey($payload, 'count');

        if (is_numeric($total)) {
            return (int) $total;
        }

        return $fallback;
    }

    /**
     * Find the first occurrence of a key in a nested array tree.
     *
     * @param array $data
     * @param string $targetKey
     * @return mixed|null
     */
    private function findNestedKey(array $data, string $targetKey)
    {
        foreach ($data as $key => $value) {
            if ($key === $targetKey) {
                return $value;
            }

            if (is_array($value)) {
                $found = $this->findNestedKey($value, $targetKey);
                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    /**
     * Normalize potential single-order/object structures to a list.
     *
     * @param array $orders
     * @return array
     */
    private function normalizeOrderList(array $orders): array
    {
        if ($orders === []) {
            return [];
        }

        if (array_is_list($orders)) {
            return $orders;
        }

        if (isset($orders['order']) && is_array($orders['order'])) {
            return $this->normalizeOrderList($orders['order']);
        }

        return [$orders];
    }

    /**
     * Convert error content into compact readable text.
     *
     * @param string $content
     * @return string
     */
    private function formatErrorContent(string $content): string
    {
        $trimmed = trim($content);
        if ($trimmed === '') {
            return 'Empty response body.';
        }

        if (str_starts_with($trimmed, '<')) {
            $text = trim(preg_replace('/\s+/', ' ', strip_tags($trimmed)) ?? '');
            if ($text !== '') {
                return substr($text, 0, 500);
            }
        }

        return substr($trimmed, 0, 500);
    }

    /**
     * Normalize endpoint path to avoid duplicated /api segments.
     *
     * @param string $endpoint
     * @return string
     */
    private function normalizeEndpoint(string $endpoint): string
    {
        $normalizedEndpoint = ltrim($endpoint, '/');
        $normalizedBase = rtrim($this->apiEndpoint, '/');

        if (str_ends_with($normalizedBase, '/api') && str_starts_with($normalizedEndpoint, 'api/')) {
            return substr($normalizedEndpoint, 4);
        }

        return $normalizedEndpoint;
    }
}
