<?php
/**
 * Wrapper API eMAG Marketplace v4.5.1.
 *
 * Documentație: 6c88104d-emag-marketplace-api-documentation-v4.5.1.docx
 *
 * Autentificare: Basic Auth cu hash = base64(username:password)
 *   - "password" = parola contului eMAG al user-ului care are API rights activat
 *   - NU este un API key separat
 *   - IP-ul serverului trebuie whitelistat în Marketplace → Setări → Securitate
 *
 * Rate limits (per docs §1.5):
 *   - Orders: 12 req/sec sau 720/min
 *   - Restul resurselor: 3 req/sec sau 180/min (cumulativ pe toate)
 *   - HTTP 429 când e depășit, cu headere X-RateLimit-*
 *
 * Răspuns standard: { "isError": bool, "messages": [...], "results": [...] }
 */

require_once __DIR__ . '/config.php';

class EmagAPI
{
    // Mapping platformă → URL API (cf. §2 din docs)
    public const PLATFORMS = [
        'ro'    => ['name' => 'eMAG România',  'url' => 'https://marketplace-api.emag.ro/api-3'],
        'bg'    => ['name' => 'eMAG Bulgaria', 'url' => 'https://marketplace-api.emag.bg/api-3'],
        'hu'    => ['name' => 'eMAG Ungaria',  'url' => 'https://marketplace-api.emag.hu/api-3'],
        'fd_ro' => ['name' => 'Fashion Days RO', 'url' => 'https://marketplace-ro-api.fashiondays.com/api-3'],
        'fd_bg' => ['name' => 'Fashion Days BG', 'url' => 'https://marketplace-bg-api.fashiondays.com/api-3'],
    ];

    /**
     * Statusul de validare a documentației (cheia `validation_status` din
     * product_offer/read; docs, tabelul „validation_status"). Aceleași valori
     * sunt acceptate și ca FILTRU la citire (product_offer/read).
     *
     * `available` = produsul poate fi vândut în acel status (col. „Availability").
     */
    public const VALIDATION_STATUSES = [
        0  => ['label' => 'Draft',                          'available' => false],
        1  => ['label' => 'În validare MKTP',               'available' => false],
        2  => ['label' => 'În așteptare validare brand',    'available' => false],
        3  => ['label' => 'În așteptare aprobare EAN',      'available' => true],
        4  => ['label' => 'Documentație nouă în validare',  'available' => false],
        5  => ['label' => 'Brand respins',                  'available' => false],
        6  => ['label' => 'Produs invalid – EAN respins',   'available' => false],
        8  => ['label' => 'Documentație respinsă',          'available' => false],
        9  => ['label' => 'Documentație aprobată',          'available' => true],
        10 => ['label' => 'Blocat',                         'available' => false],
        11 => ['label' => 'Actualizare în validare',        'available' => true],
        12 => ['label' => 'Actualizare respinsă',           'available' => true],
    ];

    /** Statusul pe care îl primește un produs aprobat după ce îi editezi documentația (ex: descrierea). */
    public const VALIDATION_UPDATE_PENDING  = 11;
    /** Update-ul de documentație trimis de seller a fost refuzat de eMAG. */
    public const VALIDATION_UPDATE_REJECTED = 12;

    private string $username;
    private string $password;
    private string $baseUrl;
    private int    $timeout;

    /** Eticheta în română a unui validation_status; null dacă valoarea e necunoscută. */
    public static function validationLabel(?int $value): ?string
    {
        if ($value === null) return null;
        return self::VALIDATION_STATUSES[$value]['label'] ?? ('Status ' . $value);
    }

    /**
     * Ordinea de prioritate când un produs are MAI MULTE statusuri simultan
     * (vezi parseValidationStatuses). Primul găsit câștigă. Logica: mai întâi
     * ce e respins/blocat (necesită acțiune), apoi update-ul în validare, apoi
     * validările în curs, apoi conflictul de EAN, iar „aprobat" ultimul.
     */
    private const VALIDATION_PRIORITY = [12, 8, 5, 6, 10, 11, 0, 4, 1, 2, 3, 9];

    /**
     * Normalizează cheia `validation_status` dintr-un rezultat product_offer/read.
     *
     * ATENȚIE: e o LISTĂ și poate conține MAI MULTE statusuri deodată. Exemplu real:
     *   [{"value":3,"description":"EAN validation pending"},
     *    {"value":11,"description":"Documentation update validation pending"}]
     * (produs cu conflict de EAN nerezolvat ȘI cu un update de documentație în
     * validare). Un parser care ia doar primul element ar rata statusul 11.
     * Acceptăm și forma de obiect simplu {value, description, errors}.
     *
     * @return array<int, array{value: int, description: ?string, errors: ?array}>
     *         lista completă, în ordinea întoarsă de eMAG
     */
    public static function parseValidationStatuses($raw): array
    {
        if (!is_array($raw) || !$raw) return [];

        // Formă de obiect simplu → o tratăm ca listă cu un element
        $entries = array_key_exists('value', $raw) ? [$raw] : $raw;

        $out = [];
        foreach ($entries as $entry) {
            if (!is_array($entry) || !array_key_exists('value', $entry)) continue;
            $val = $entry['value'];
            if ($val === null || $val === '' || !is_numeric($val)) continue;

            // `description` apare în docs și cu D mare; acceptăm ambele.
            $desc = $entry['description'] ?? $entry['Description'] ?? null;

            // `errors` vine de regulă ca {info: [], warnings: [], errors: []} — deci
            // prezent, dar gol. Îl păstrăm doar dacă are conținut real, altfel am
            // afișa „feedback eMAG" la fiecare produs, fără nimic în el.
            $errs = $entry['errors'] ?? null;
            if (is_array($errs)) {
                $hasContent = false;
                array_walk_recursive($errs, function ($v) use (&$hasContent) {
                    if ($v !== null && $v !== '') $hasContent = true;
                });
                if (!$hasContent) $errs = null;
            } else {
                $errs = null;
            }

            $out[] = [
                'value'       => (int)$val,
                'description' => ($desc !== null && $desc !== '') ? (string)$desc : null,
                'errors'      => $errs ?: null,
            ];
        }
        return $out;
    }

    /**
     * Alege statusul „principal" dintr-o listă întoarsă de parseValidationStatuses.
     *
     * @param array    $entries lista de la parseValidationStatuses()
     * @param int|null $prefer  dacă produsul a fost citit cu filtrul validation_status=X,
     *                          statusul X e cel relevant — îl preferăm dacă apare în listă.
     * @return array{value: ?int, description: ?string, errors: ?array}
     */
    public static function primaryValidationStatus(array $entries, ?int $prefer = null): array
    {
        $empty = ['value' => null, 'description' => null, 'errors' => null];
        if (!$entries) return $empty;

        if ($prefer !== null) {
            foreach ($entries as $e) {
                if ($e['value'] === $prefer) return $e;
            }
        }
        foreach (self::VALIDATION_PRIORITY as $p) {
            foreach ($entries as $e) {
                if ($e['value'] === $p) return $e;
            }
        }
        return $entries[0];
    }

    /**
     * Shortcut: lista + statusul principal dintr-un răspuns brut.
     *
     * @return array{value: ?int, description: ?string, errors: ?array, all: int[]}
     */
    public static function parseValidationStatus($raw, ?int $prefer = null): array
    {
        $entries = self::parseValidationStatuses($raw);
        $primary = self::primaryValidationStatus($entries, $prefer);
        $primary['all'] = array_values(array_unique(array_column($entries, 'value')));
        return $primary;
    }

    public function __construct(string $username, string $password, string $platform = 'ro', ?int $timeout = null)
    {
        if (!isset(self::PLATFORMS[$platform])) {
            throw new InvalidArgumentException("Platformă necunoscută: $platform");
        }
        $this->username = $username;
        $this->password = $password;
        $this->baseUrl  = self::PLATFORMS[$platform]['url'];
        $this->timeout  = $timeout ?? EMAG_API_TIMEOUT;
    }

    /**
     * Verifică credențialele cu un read minimal.
     * @return array{ok: bool, message: string}
     */
    public function testConnection(): array
    {
        try {
            $res = $this->request('/product_offer/read', [
                'currentPage'  => 1,
                'itemsPerPage' => 1,
            ]);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare necunoscută'])];
            }
            return ['ok' => true, 'message' => 'Conexiune reușită.'];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Citește o pagină de produse (offers).
     *
     * @param array $filters Filtre opționale acceptate de product_offer/read
     *   (docs, tabelul de filtre la citire): status, general_stock,
     *   offer_validation_status, validation_status, translation_validation_status.
     *   Ex: ['validation_status' => 11] → doar produsele cu update în validare.
     * @return array{results: array, total: ?int, page: int, perPage: int}
     */
    public function getProducts(int $page = 1, int $perPage = 100, array $filters = []): array
    {
        $perPage = max(1, min($perPage, 100)); // hard cap din docs

        $res = $this->request('/product_offer/read', array_merge($filters, [
            'currentPage'  => $page,
            'itemsPerPage' => $perPage,
        ]));

        if (!empty($res['isError'])) {
            throw new RuntimeException($this->joinMessages($res['messages'] ?? ['Eroare API']));
        }

        return [
            'results' => $res['results'] ?? [],
            'total'   => isset($res['noOfItems']) ? (int)$res['noOfItems'] : null,
            'page'    => $page,
            'perPage' => $perPage,
        ];
    }

    /**
     * Citește o pagină de produse filtrate după statusul de validare a documentației.
     *
     * Endpoint: POST /product_offer/read cu filtrul `validation_status`.
     * Cel mai util: 11 = „Actualizare în validare" (produsele la care ai editat
     * documentația — ex. descrierea — și eMAG încă nu a validat modificarea) și
     * 12 = „Actualizare respinsă".
     *
     * @param int $status una din cheile VALIDATION_STATUSES
     * @return array{results: array, total: ?int, page: int, perPage: int}
     */
    public function getProductsByValidationStatus(int $status, int $page = 1, int $perPage = 100): array
    {
        if (!array_key_exists($status, self::VALIDATION_STATUSES)) {
            throw new InvalidArgumentException("validation_status necunoscut: $status");
        }
        return $this->getProducts($page, $perPage, ['validation_status' => $status]);
    }

    /**
     * Update doar la numele unui produs offer.
     *
     * Endpoint: POST /product_offer/save
     * Body: array de produse cu id seller intern + name. eMAG suportă bulk
     * (până la 50 per request) — aici trimit unul singur pe call pentru
     * granularitate la error tracking.
     *
     * @param array $product Trebuie să conțină măcar:
     *   - id (seller internal id, integer) — MANDATORY pentru a identifica produsul
     *   - name (string) — noul nume
     *   - part_number (string) — SKU intern, frecvent cerut de eMAG la save
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function updateProductName(array $product): array
    {
        if (empty($product['id']) || empty($product['name'])) {
            return ['ok' => false, 'message' => 'id sau name lipsesc'];
        }

        // Body-ul pentru product_offer/save trebuie să fie un ARRAY de produse.
        // Trimit minimal: id + name + part_number (dacă există) pentru a evita
        // să suprascriu alte câmpuri.
        $payload = [array_filter([
            'id'          => (int)$product['id'],
            'name'        => (string)$product['name'],
            'part_number' => $product['part_number'] ?? null,
        ], fn($v) => $v !== null && $v !== '')];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Update doar la codul de produs (part_number / SKU intern al sellerului).
     *
     * Endpoint: POST /product_offer/save
     * Body: listă cu id seller intern + part_number. Trimit minimal (id +
     * part_number) ca să nu suprascriu alte câmpuri ale ofertei. Folosit pentru
     * a propaga „cod produs" de pe contul normal pe cel FBE (vezi cod-produs-sync).
     *
     * @param array $product Trebuie să conțină:
     *   - id (seller internal id = emag_id) — MANDATORY
     *   - part_number (string, ne-gol) — noul cod produs — MANDATORY
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function updateProductPartNumber(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        $partNumber = trim((string)($product['part_number'] ?? ''));
        if ($partNumber === '') {
            return ['ok' => false, 'message' => 'part_number (cod produs) lipsește'];
        }

        $payload = [[
            'id'          => (int)$product['id'],
            'part_number' => $partNumber,
        ]];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Update doar la prețul de vânzare al unui produs offer.
     *
     * Endpoint: POST /product_offer/save
     * Body: array de produse cu id seller intern + sale_price.
     * `sale_price` este FĂRĂ TVA, în moneda contului (docs §2.5). Trimit minimal
     * (id + sale_price) pentru a nu suprascrie alte câmpuri ale ofertei.
     *
     * @param array $product Trebuie să conțină:
     *   - id (seller internal id = emag_id) — MANDATORY
     *   - sale_price (float > 0) — noul preț de vânzare fără TVA
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function updateProductPrice(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        $price = isset($product['sale_price']) ? (float)$product['sale_price'] : 0;
        if ($price <= 0) {
            return ['ok' => false, 'message' => 'sale_price trebuie să fie > 0'];
        }

        // Body = LISTĂ de array-uri (la fel ca product_offer/save pentru nume).
        $payload = [[
            'id'         => (int)$product['id'],
            'sale_price' => round($price, 2),
        ]];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Update la intervalul de preț permis al unei oferte (min/max sale price).
     *
     * Endpoint: POST /product_offer/save
     * Docs v4.5.1: `min_sale_price` / `max_sale_price` sunt prețuri FĂRĂ TVA,
     * decimale > 0, iar `max_sale_price` trebuie să fie mai mare decât
     * `min_sale_price`. Tot documentația: „Min / Max sale price keys are used for
     * price check purposes […] As a best practice we recommend sending these keys
     * only when you want to change their values." — deci trimitem perechea DOAR
     * la editare explicită, niciodată la un push obișnuit de preț.
     *
     * Trimitem întotdeauna ambele chei împreună: eMAG validează max > min pe
     * valorile finale, iar un update parțial poate cădea pe vechea pereche.
     *
     * @param array $product ['id' => emag_id, 'min_sale_price' => float, 'max_sale_price' => float]
     */
    public function updateOfferPriceRange(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        $min = isset($product['min_sale_price']) ? (float)$product['min_sale_price'] : 0;
        $max = isset($product['max_sale_price']) ? (float)$product['max_sale_price'] : 0;
        if ($min <= 0 || $max <= 0) {
            return ['ok' => false, 'message' => 'min_sale_price și max_sale_price trebuie să fie > 0'];
        }
        if ($max <= $min) {
            return ['ok' => false, 'message' => 'max_sale_price trebuie să fie mai mare decât min_sale_price'];
        }

        // Body = LISTĂ de array-uri (la fel ca restul apelurilor product_offer/save).
        $payload = [[
            'id'             => (int)$product['id'],
            'min_sale_price' => round($min, 4),
            'max_sale_price' => round($max, 4),
        ]];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Interval actualizat.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Update doar la stocul unui produs offer.
     *
     * Endpoint: POST /product_offer/save
     * Body: array de produse cu id seller intern + stock (listă pe depozite).
     * eMAG cere stocul ca listă: stock = [{ warehouse_id, value }] (docs §2.5).
     * Pentru majoritatea sellerilor există un singur depozit (warehouse_id = 1).
     * Trimit minimal (id + stock) pentru a nu suprascrie alte câmpuri ale ofertei.
     *
     * ATENȚIE: pentru conturile FBE (Fulfillment by eMAG) stocul e gestionat de
     * eMAG și NU trebuie trimis prin API — blocarea se face în stratul de aplicație
     * (vezi api/stock-*.php), nu aici.
     *
     * @param array $product Trebuie să conțină:
     *   - id (seller internal id = emag_id) — MANDATORY
     *   - stock (int >= 0) — noul stoc
     *   - warehouse_id (int, opțional, default 1)
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function updateProductStock(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        if (!isset($product['stock']) || !is_numeric($product['stock']) || (int)$product['stock'] < 0) {
            return ['ok' => false, 'message' => 'stock trebuie să fie un număr întreg >= 0'];
        }
        $warehouseId = (int)($product['warehouse_id'] ?? 1);

        $payload = [[
            'id'    => (int)$product['id'],
            'stock' => [[
                'warehouse_id' => $warehouseId,
                'value'        => (int)$product['stock'],
            ]],
        ]];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Activează / dezactivează oferta unui produs.
     *
     * Endpoint: POST /product_offer/save
     * Body: listă cu id seller intern + status (1 = activ, 0 = inactiv).
     * Trimit minimal (id + status) ca să nu suprascriu alte câmpuri ale ofertei.
     *
     * @param array $product Trebuie să conțină:
     *   - id (seller internal id = emag_id) — MANDATORY
     *   - status (1 sau 0) — MANDATORY
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function updateProductStatus(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        if (!isset($product['status']) || !in_array((int)$product['status'], [0, 1], true)) {
            return ['ok' => false, 'message' => 'status trebuie să fie 0 sau 1'];
        }

        $payload = [[
            'id'     => (int)$product['id'],
            'status' => (int)$product['status'],
        ]];

        try {
            $res = $this->request('/product_offer/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Salvează volumetria unui produs (dimensiuni + greutate).
     *
     * Endpoint: POST /measurements/save  (docs §2.7)
     * Resursa `measurements`, acțiunea `save`. Unitățile cerute de eMAG:
     *   - length / width / height → milimetri (mm)
     *   - weight                  → grame (g)
     * Toate sunt OBLIGATORII, valori decimale între 0 și 999999 (max 2 zecimale).
     *
     * @param array $product Trebuie să conțină:
     *   - id (seller internal product id = `emag_id` din app) — MANDATORY
     *   - length, width, height (mm), weight (g)
     * @return array{ok: bool, message: string, raw?: array}
     */
    public function saveMeasurements(array $product): array
    {
        if (empty($product['id'])) {
            return ['ok' => false, 'message' => 'id (emag_id) lipsește'];
        }
        foreach (['length', 'width', 'height', 'weight'] as $k) {
            if (!isset($product[$k]) || $product[$k] === '' || $product[$k] === null) {
                return ['ok' => false, 'message' => "Câmp volumetrie lipsă: $k"];
            }
        }

        // IMPORTANT: măsurătorile trebuie trimise ca LISTĂ de array-uri (la fel ca
        // product_offer/save), nu ca un singur array. Altfel eMAG răspunde:
        // „Expected list of arrays, single array received." Încapsulând într-un
        // array cu cheie numerică, request() detectează lista și trimite JSON.
        $payload = [[
            'id'     => (int)$product['id'],
            'length' => round((float)$product['length'], 2),
            'width'  => round((float)$product['width'], 2),
            'height' => round((float)$product['height'], 2),
            'weight' => round((float)$product['weight'], 2),
        ]];

        try {
            $res = $this->request('/measurements/save', $payload);
            if (!empty($res['isError'])) {
                return ['ok' => false, 'message' => $this->joinMessages($res['messages'] ?? ['Eroare API']), 'raw' => $res];
            }
            return ['ok' => true, 'message' => 'Trimis cu succes.', 'raw' => $res];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Numără total produse (mai eficient decât a parcurge toate paginile).
     */
    public function countProducts(): ?int
    {
        try {
            $res = $this->request('/product_offer/count', []);
            if (!empty($res['isError'])) {
                return null;
            }
            return isset($res['results']['noOfItems']) ? (int)$res['results']['noOfItems'] : null;
        } catch (Throwable $e) {
            return null;
        }
    }

    /**
     * Citește „performanța" (hotness) produselor după EAN.
     *
     * Endpoint: GET /documentation/find_by_eans?eans[]=...  (docs §3, EAN reading)
     * Limite: max 100 EAN-uri / request; 5 req/sec, 200/min, 5000/zi.
     *
     * Răspuns: results este un obiect indexat după EAN:
     *   { "<ean>": { "products": [ { "hotness": "HOT", "part_number_key": ..., ... } ] } }
     *
     * @param string[] $eans
     * @return array<string,string|null> map EAN → hotness (ex: "HOT", "SUPER COLD")
     */
    public function getHotnessByEans(array $eans): array
    {
        $eans = array_values(array_filter(array_unique(array_map('strval', $eans)),
            fn($e) => $e !== ''));
        if (!$eans) return [];
        $eans = array_slice($eans, 0, 100); // hard cap din docs

        try {
            $res = $this->requestGet('/documentation/find_by_eans', ['eans' => $eans]);
        } catch (Throwable $e) {
            return []; // nefatal: dacă hotness eșuează, sync-ul continuă
        }
        if (!empty($res['isError'])) return [];

        $out = [];
        foreach (($res['results'] ?? []) as $ean => $entry) {
            $products = $entry['products'] ?? [];
            $first    = $products[0] ?? null;
            if ($first && isset($first['hotness']) && $first['hotness'] !== '') {
                $out[(string)$ean] = (string)$first['hotness'];
            }
        }
        return $out;
    }

    // ----------------------------------------------------------

    /**
     * Execută request GET cu Basic Auth (pentru resurse care cer GET, ex:
     * documentation/find_by_eans). Retry pe HTTP 429.
     */
    private function requestGet(string $endpoint, array $query, int $attempt = 1): array
    {
        $url  = $this->baseUrl . $endpoint . '?' . http_build_query($query);
        $hash = base64_encode($this->username . ':' . $this->password);

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_HTTPGET        => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HTTPHEADER     => [
                'Authorization: Basic ' . $hash,
                'Accept: application/json',
            ],
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_FOLLOWLOCATION => true,
        ]);
        $bodyRaw = curl_exec($ch);
        $err     = curl_error($ch);
        $code    = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($bodyRaw === false) {
            throw new RuntimeException('Eroare cURL: ' . ($err ?: 'unknown'));
        }
        if ($code === 429) {
            if ($attempt < 3) {
                usleep(600 * $attempt * 1000);
                return $this->requestGet($endpoint, $query, $attempt + 1);
            }
            throw new RuntimeException('Rate limit eMAG depășit (HTTP 429).');
        }
        if ($code >= 400) {
            throw new RuntimeException("HTTP $code la $endpoint.");
        }
        $data = json_decode($bodyRaw, true);
        if (!is_array($data)) {
            throw new RuntimeException('Răspuns API invalid (nu este JSON).');
        }
        return $data;
    }

    /**
     * Execută request POST cu Basic Auth.
     * Gestionează HTTP 429 cu retry (max 3 încercări, backoff exponențial).
     *
     * Format body: dacă $body conține chei numerice (e listă de obiecte ca la
     * product_offer/save), trimit JSON. Altfel form-encoded (compatibil cu read).
     */
    private function request(string $endpoint, array $body, int $attempt = 1): array
    {
        $url  = $this->baseUrl . $endpoint;
        $hash = base64_encode($this->username . ':' . $this->password);

        // Detect: listă de obiecte (numeric keys cu array values) → JSON
        $useJson = !empty($body) && array_keys($body) === range(0, count($body) - 1);
        $postData = $useJson ? json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : http_build_query($body);
        $contentType = $useJson ? 'application/json' : 'application/x-www-form-urlencoded';

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $postData,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HTTPHEADER     => [
                'Authorization: Basic ' . $hash,
                'Content-Type: ' . $contentType,
                'Accept: application/json',
            ],
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HEADER         => true, // includem headerele pentru rate limit
        ]);

        $raw  = curl_exec($ch);
        $err  = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $hLen = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        curl_close($ch);

        if ($raw === false) {
            throw new RuntimeException('Eroare cURL: ' . ($err ?: 'unknown'));
        }

        $headersRaw = substr($raw, 0, $hLen);
        $bodyRaw    = substr($raw, $hLen);

        // Rate limit: retry cu backoff
        if ($code === 429) {
            if ($attempt < 3) {
                $sleepMs = 600 * $attempt; // 600ms, 1.2s, 1.8s
                usleep($sleepMs * 1000);
                return $this->request($endpoint, $body, $attempt + 1);
            }
            throw new RuntimeException('Rate limit eMAG depășit (HTTP 429). Reîncearcă în câteva secunde.');
        }

        if ($code === 401 || $code === 403) {
            throw new RuntimeException("Autentificare eșuată (HTTP $code). Verifică username/parolă și că IP-ul serverului este whitelistat în eMAG.");
        }
        if ($code >= 400) {
            throw new RuntimeException("HTTP $code la $endpoint. Răspuns: " . substr($bodyRaw, 0, 200));
        }

        $data = json_decode($bodyRaw, true);
        if (!is_array($data)) {
            throw new RuntimeException('Răspuns API invalid (nu este JSON): ' . substr($bodyRaw, 0, 200));
        }
        return $data;
    }

    private function joinMessages($messages): string
    {
        if (!is_array($messages)) return (string)$messages;
        $out = [];
        foreach ($messages as $m) {
            if (is_string($m)) { $out[] = $m; continue; }
            if (is_array($m))  { $out[] = $m['message'] ?? json_encode($m); }
        }
        return implode(' | ', $out) ?: 'Eroare necunoscută';
    }
}
