<?php

declare(strict_types=1);

namespace App\CreatorsApi;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

/**
 * Client minimale per Amazon Creators API basato sulla documentazione ufficiale.
 *
 * Copre le operazioni di catalogo (GetItems), i feed (ListFeeds/GetFeed) e i
 * report (ListReports/GetReport). L'ottenimento del token OAuth2 dipende dalla
 * credential version: 2.x via Cognito, 3.x via Login with Amazon (LwA). I report
 * vivono su un path distinto (/reports/v1) rispetto al catalogo (/catalog/v1).
 */
class CreatorsApiClient
{
    /**
     * Token endpoint per credential version.
     *
     * Le versioni 2.x usano Cognito (client_credentials via form-urlencoded, scope
     * `creatorsapi/default`); le 3.x usano Login with Amazon (body JSON, scope
     * `creatorsapi::default`). L'endpoint dipende dalla versione, non dalla region.
     */
    private const TOKEN_ENDPOINTS = [
        '2.1' => 'https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token', // NA Cognito
        '2.2' => 'https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token', // EU Cognito
        '2.3' => 'https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token', // FE Cognito
        '3.1' => 'https://api.amazon.com/auth/o2/token',    // NA LwA
        '3.2' => 'https://api.amazon.co.uk/auth/o2/token',  // EU LwA
        '3.3' => 'https://api.amazon.co.jp/auth/o2/token',  // FE LwA
    ];

    /** Credential version che usano il flusso Login with Amazon. */
    private const LWA_VERSIONS = ['3.1', '3.2', '3.3'];

    private const API_BASE = 'https://creatorsapi.amazon';
    private const GET_ITEMS_PATH = '/catalog/v1/getItems';
    private const SEARCH_ITEMS_PATH = '/catalog/v1/searchItems';
    private const LIST_FEEDS_PATH = '/catalog/v1/listFeeds';
    private const GET_FEED_PATH = '/catalog/v1/getFeed';
    private const LIST_REPORTS_PATH = '/reports/v1/listReports';
    private const GET_REPORT_PATH = '/reports/v1/getReport';

    private Client $http;

    private string $clientId;
    private string $clientSecret;
    private string $credentialVersion;
    private string $region;
    private string $marketplace;
    private string $partnerTag;

    private ?string $accessToken = null;
    private int $tokenExpiresAt = 0;

    public function __construct(array $config, ?Client $httpClient = null)
    {
        $this->clientId = $config['clientId'] ?? '';
        $this->clientSecret = $config['clientSecret'] ?? '';
        $this->credentialVersion = $config['credentialVersion'] ?? '';
        $this->region = strtoupper($config['region'] ?? '');
        $this->marketplace = $config['marketplace'] ?? '';
        $this->partnerTag = $config['partnerTag'] ?? '';

        $this->http = $httpClient ?? new Client([
            'timeout' => 15,
        ]);

        $this->assertRequiredConfig();
    }

    /**
     * Tutte le risorse disponibili per GetItems.
     */
    private const DEFAULT_RESOURCES = [
        // BrowseNodeInfo
        'browseNodeInfo.browseNodes',
        'browseNodeInfo.browseNodes.ancestor',
        'browseNodeInfo.browseNodes.salesRank',
        'browseNodeInfo.websiteSalesRank',
        // Images
        'images.primary.small',
        'images.primary.medium',
        'images.primary.large',
        'images.variants.small',
        'images.variants.medium',
        'images.variants.large',
        // ItemInfo
        'itemInfo.byLineInfo',
        'itemInfo.classifications',
        'itemInfo.contentInfo',
        'itemInfo.contentRating',
        'itemInfo.externalIds',
        'itemInfo.features',
        'itemInfo.manufactureInfo',
        'itemInfo.productInfo',
        'itemInfo.technicalInfo',
        'itemInfo.title',
        'itemInfo.tradeInInfo',
        // OffersV2
        'offersV2.listings.availability',
        'offersV2.listings.condition',
        'offersV2.listings.dealDetails',
        'offersV2.listings.isBuyBoxWinner',
        'offersV2.listings.loyaltyPoints',
        'offersV2.listings.merchantInfo',
        'offersV2.listings.price',
        'offersV2.listings.type',
        // ParentASIN
        'parentASIN',
    ];

    /**
     * Recupera dettagli prodotto per uno o più ASIN tramite GetItems.
     *
     * @param string[] $itemIds        Massimo 10 ASIN.
     * @param array    $extraPayload   Parametri opzionali addizionali (es. condition, itemIdType, languagesOfPreference).
     *
     * @return array Decodifica JSON della risposta API.
     *
     * @throws \RuntimeException|GuzzleException
     */
    public function getItems(array $itemIds, array $extraPayload = []): array
    {
        if (count($itemIds) === 0) {
            throw new \InvalidArgumentException('itemIds non può essere vuoto.');
        }

        if (count($itemIds) > 10) {
            throw new \InvalidArgumentException('itemIds supporta al massimo 10 valori per richiesta.');
        }

        $token = $this->getAccessToken();

        $payload = array_merge([
            'itemIds' => array_values($itemIds),
            'itemIdType' => $extraPayload['itemIdType'] ?? 'ASIN',
            'condition' => 'New', // Solo prodotti nuovi, escludi usato
            'marketplace' => $this->marketplace,
            'partnerTag' => $this->partnerTag,
            'resources' => self::DEFAULT_RESOURCES,
        ], $extraPayload);

        $response = $this->http->post(self::API_BASE . self::GET_ITEMS_PATH, [
            'headers' => [
                'Authorization' => $this->authorizationHeader($token),
                'Content-Type' => 'application/json',
                'x-marketplace' => $this->marketplace,
            ],
            'json' => $payload,
        ]);

        return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
    }

    /**
     * Cerca articoli Amazon per parole chiave.
     *
     * SearchItems accetta al massimo 10 risultati per richiesta. Le risorse
     * offerte/immagini sono richieste esplicitamente per permettere al catalogo
     * di costruire un'offerta completa.
     */
    public function searchItems(string $keywords, array $extraPayload = []): array
    {
        $keywords = trim($keywords);
        if ($keywords === '') {
            throw new \InvalidArgumentException('keywords non può essere vuoto.');
        }

        $payload = array_merge([
            'keywords' => $keywords,
            'searchIndex' => 'All',
            'itemCount' => 10,
            'marketplace' => $this->marketplace,
            'partnerTag' => $this->partnerTag,
            'resources' => [
                'itemInfo.title',
                'itemInfo.externalIds',
                'images.primary.medium',
                'images.primary.large',
                'offersV2.listings.price',
                'offersV2.listings.merchantInfo',
            ],
        ], $extraPayload);

        $response = $this->requestJson(self::SEARCH_ITEMS_PATH, $payload);

        return $response;
    }

    /**
     * Elenca i feed disponibili per il marketplace.
     *
     * @return array Decodifica JSON: { feeds: [ { feedName, size, md5, lastUpdated }, ... ] }.
     *
     * @throws \RuntimeException|GuzzleException
     */
    public function listFeeds(): array
    {
        return $this->requestJson(self::LIST_FEEDS_PATH);
    }

    /**
     * Recupera l'URL di download di un feed specifico.
     *
     * @return array Decodifica JSON: { url }.
     *
     * @throws \RuntimeException|GuzzleException
     */
    public function getFeed(string $feedName): array
    {
        if ($feedName === '') {
            throw new \InvalidArgumentException('feedName non può essere vuoto.');
        }

        return $this->requestJson(self::GET_FEED_PATH, ['feedName' => $feedName]);
    }

    /**
     * Elenca i report disponibili per il marketplace.
     *
     * Nota: usa il path /reports/v1, distinto dal catalogo. Le credenziali
     * abilitate ai report non coincidono necessariamente con quelle dei feed.
     *
     * @return array Decodifica JSON: { reports: [ { filename, md5, size, lastModified }, ... ] }.
     *
     * @throws \RuntimeException|GuzzleException
     */
    public function listReports(): array
    {
        return $this->requestJson(self::LIST_REPORTS_PATH);
    }

    /**
     * Recupera l'URL presigned per scaricare un report (.tsv.gz).
     *
     * @return array Decodifica JSON: { url }.
     *
     * @throws \RuntimeException|GuzzleException
     */
    public function getReport(string $filename): array
    {
        if ($filename === '') {
            throw new \InvalidArgumentException('filename non può essere vuoto.');
        }

        return $this->requestJson(self::GET_REPORT_PATH, ['filename' => $filename]);
    }

    /**
     * POST autenticato verso un'operazione dell'API, con serializzazione JSON.
     *
     * Centralizza token + header comuni per feed e report. GetItems mantiene la
     * propria costruzione della richiesta per non alterarne il comportamento.
     *
     * @param array|null $json Corpo della richiesta; null per le operazioni senza body (list*).
     *
     * @return array Decodifica JSON della risposta.
     *
     * @throws \RuntimeException|GuzzleException
     */
    private function requestJson(string $path, ?array $json = null): array
    {
        $token = $this->getAccessToken();

        $options = [
            'headers' => [
                'Authorization' => $this->authorizationHeader($token),
                'Content-Type' => 'application/json',
                'x-marketplace' => $this->marketplace,
            ],
        ];

        if ($json !== null) {
            $options['json'] = $json;
        }

        $response = $this->http->post(self::API_BASE . $path, $options);

        return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
    }

    /**
     * Recupera e cache il token OAuth2.
     */
    private function getAccessToken(): string
    {
        $now = time();
        if ($this->accessToken !== null && $now < $this->tokenExpiresAt - 60) {
            return $this->accessToken;
        }

        $tokenEndpoint = self::TOKEN_ENDPOINTS[$this->credentialVersion] ?? null;
        if ($tokenEndpoint === null) {
            throw new \InvalidArgumentException(sprintf(
                'Credential version non supportata: %s. Supportate: %s.',
                $this->credentialVersion,
                implode(', ', array_keys(self::TOKEN_ENDPOINTS))
            ));
        }

        $isLwa = $this->isLwaVersion();

        $tokenParams = [
            'grant_type' => 'client_credentials',
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            // LwA (3.x) usa lo scope con doppio due-punti; Cognito (2.x) con slash.
            'scope' => $isLwa ? 'creatorsapi::default' : 'creatorsapi/default',
        ];

        // LwA vuole il body in JSON; Cognito in form-urlencoded.
        $requestOptions = $isLwa
            ? ['headers' => ['Content-Type' => 'application/json'], 'json' => $tokenParams]
            : ['headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], 'form_params' => $tokenParams];

        $response = $this->http->post($tokenEndpoint, $requestOptions);

        $body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
        $this->accessToken = $body['access_token'] ?? null;
        if ($this->accessToken === null) {
            throw new \RuntimeException('Access token non presente nella risposta OAuth2.');
        }

        $expiresIn = (int) ($body['expires_in'] ?? 3600);
        $this->tokenExpiresAt = $now + $expiresIn;

        return $this->accessToken;
    }

    /**
     * Indica se la credential version usa il flusso Login with Amazon (3.x).
     */
    private function isLwaVersion(): bool
    {
        return in_array($this->credentialVersion, self::LWA_VERSIONS, true);
    }

    /**
     * Header Authorization per le chiamate API.
     *
     * Le credenziali 2.x richiedono il suffisso `, Version <x.y>`; le 3.x (LwA)
     * no — il token da solo identifica già la credenziale, e inviare il suffisso
     * fa rifiutare la richiesta.
     */
    private function authorizationHeader(string $token): string
    {
        return $this->isLwaVersion()
            ? sprintf('Bearer %s', $token)
            : sprintf('Bearer %s, Version %s', $token, $this->credentialVersion);
    }

    private function assertRequiredConfig(): void
    {
        foreach (['clientId', 'clientSecret', 'credentialVersion', 'region', 'marketplace', 'partnerTag'] as $key) {
            if (!isset($this->$key) || $this->$key === '') {
                throw new \InvalidArgumentException(sprintf('Config mancante: %s', $key));
            }
        }
    }
}
