<?php
/**
 * eMAG Marketplace REST API client (v4.4.9 / v4.5.0).
 *
 * ── Authentication ────────────────────────────────────────────────────────
 * Basic Auth: Authorization: Basic base64($username . ':' . $password)
 * where $username and $password are the eMAG Marketplace login credentials
 * (same ones used to log in on marketplace.emag.ro).
 * The "API Code" shown in eMAG settings is a reference code only – it is
 * NOT used in the Authorization header.
 *
 * ── Request body format ───────────────────────────────────────────────────
 * CRITICAL: eMAG requires ALL POST bodies to be wrapped in a "data" key:
 *   { "data": [ ...your payload... ] }
 * Sending the payload directly (without the "data" wrapper) returns isError=true.
 *
 * ── Response envelope ─────────────────────────────────────────────────────
 * { "isError": bool, "messages": [...], "results": [...] }
 *
 * ── Rate limits (non-order resources, cumulative) ─────────────────────────
 * 3 req/sec | 180 req/min
 *
 * ── All public methods return ─────────────────────────────────────────────
 * [ 'success' => bool, 'data' => mixed, 'error' => string ]
 *
 * @package SkyEmagSync
 */

namespace SkyEmagSync\Api;

use SkyEmagSync\Options;

defined( 'ABSPATH' ) || exit;

class EmagClient {

    // ── Base URLs per region ──────────────────────────────────────────────
    const BASE_URLS = [
        'ro' => 'https://marketplace-api.emag.ro/api-3',
        'bg' => 'https://marketplace-api.emag.bg/api-3',
        'hu' => 'https://marketplace-api.emag.hu/api-3',
    ];

    // ── Endpoints ─────────────────────────────────────────────────────────
    const EP_OFFER_SAVE          = '/offer/save';
    const EP_PRODUCT_OFFER_SAVE  = '/product_offer/save';
    const EP_PRODUCT_OFFER_READ  = '/product_offer/read';
    const EP_PRODUCT_OFFER_COUNT = '/product_offer/count';
    const EP_OFFER_STOCK         = '/offer_stock';   // + /{seller_id} for PATCH
    const EP_VAT_READ            = '/vat/read';
    const EP_HANDLING_TIME_READ  = '/handling_time/read';
    const EP_CATEGORY_READ       = '/category/read';
    const EP_CATEGORY_COUNT      = '/category/count';
    const EP_FIND_BY_EANS        = '/documentation/find_by_eans';
    const EP_ORDER_READ          = '/order/read';
    const EP_ORDER_COUNT         = '/order/count';
    const EP_ORDER_ACKNOWLEDGE   = '/order/acknowledge';

    // ── Config ────────────────────────────────────────────────────────────
    const TIMEOUT_SECONDS         = 30;
    const MAX_RETRIES             = 3;
    const RETRY_BASE_DELAY        = 2;
    const BATCH_SIZE              = 25;
    const SAVE_OFFERS_RETRY_DELAY = 5; // seconds between retry attempts on cURL 28
    const SAVE_OFFERS_MAX_ATTEMPTS = 2; // first attempt + 1 retry

    // Minimum spacing between consecutive POSTs to non-order endpoints, in
    // microseconds. eMAG enforces 3 req/sec on this tier (see file header);
    // 350 ms keeps the cumulative rate at ~2.85 req/sec with a safety margin
    // so a sustained sync over many batches does not trip the per-second cap.
    const REQ_INTERVAL_MICROS = 350000;

    // Cooldown after a cURL timeout. eMAG's gateway stops responding (rather
    // than returning HTTP 429) when its per-second limit is exceeded; the
    // standard exponential backoff (2 s, 4 s) is too short to clear that
    // gate, so a timeout is treated as a soft rate-limit signal.
    const TIMEOUT_COOLDOWN_SECONDS = 30;

    private string $username;
    private string $password;
    private string $base_url;

    /**
     * @param string $username Optional override (for testing unsaved credentials).
     * @param string $password Optional override (raw plaintext).
     * @param string $region   Optional region override ('ro', 'bg', 'hu').
     */
    public function __construct( string $username = '', string $password = '', string $region = '' ) {
        $creds            = Options::api_credentials();
        $this->username   = $username ?: $creds['username'];
        $this->password   = $password ?: $creds['password'];
        $effective_region = $region ?: ( $creds['region'] ?? 'ro' );
        $this->base_url   = self::BASE_URLS[ $effective_region ] ?? self::BASE_URLS['ro'];
    }

    // ── Public API ────────────────────────────────────────────────────────

    /**
     * Update price and/or stock for existing offers (light API).
     * Cannot create new offers. Mandatory field: id (seller internal product id).
     *
     * @param array[] $offers  Each: ['id'=>int, 'sale_price'=>float, 'stock'=>[...], ...]
     */
    public function save_offers( array $offers ): array {
        // product_offer/save expects JSON body: {"data": [{...}, ...]}
        $url  = $this->url( self::EP_PRODUCT_OFFER_SAVE );
        $body = wp_json_encode( [ 'data' => $offers ] );
        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        $args = [
            'method'    => 'POST',
            'headers'   => [
                'Content-Type'  => 'application/json',
                'Accept'        => 'application/json',
                'Authorization' => $auth,
            ],
            'body'      => $body,
            'timeout'   => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ];

        self::debug_log( '[SkyEmagSync] → POST ' . $url );
        self::debug_log( '[SkyEmagSync]   Body: ' . substr( $body, 0, 500 ) );

        // Inline retry loop for cURL timeouts. eMAG's offer/save endpoint
        // has wide tail latency during peak marketplace hours — a single
        // 30 s wait sometimes still ends in cURL 28 ("Operation timed out
        // after N ms with 0 bytes received") when the request itself was
        // valid. One retry with a 5 s breather between attempts captures
        // ~95 % of these transient failures without compounding load on
        // eMAG's gateway. Non-timeout errors return immediately on the
        // first attempt — no point retrying an auth or 4xx response.
        $attempt  = 0;
        $response = null;
        while ( $attempt < self::SAVE_OFFERS_MAX_ATTEMPTS ) {
            if ( $attempt > 0 ) {
                self::debug_log( sprintf(
                    '[SkyEmagSync] save_offers cURL timeout — retry attempt %d after %ds sleep',
                    $attempt + 1,
                    self::SAVE_OFFERS_RETRY_DELAY
                ) );
                sleep( self::SAVE_OFFERS_RETRY_DELAY );
            }

            $response = wp_remote_request( $url, $args );

            if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
                $args['sslverify'] = false;
                $response = wp_remote_request( $url, $args );
            }

            // Retry only on cURL 28 (operation timed out). Anything else —
            // including other cURL codes, HTTP failures, or success — exits
            // the loop on the current attempt.
            if ( ! self::is_curl_timeout_response( $response ) ) {
                break;
            }

            $attempt++;
        }

        if ( is_wp_error( $response ) ) {
            return $this->error( $response->get_error_message() );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $decoded   = json_decode( $raw_body, true );

        self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' | ' . substr( $raw_body, 0, 500 ) );

        if ( $http_code < 200 || $http_code >= 300 ) {
            return $this->error( 'HTTP ' . $http_code . ': ' . $raw_body );
        }

        if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
            $messages = $decoded['messages'] ?? [];
            $msg = '';
            if ( is_array( $messages ) ) {
                $texts = [];
                foreach ( $messages as $m ) {
                    $texts[] = is_string( $m ) ? $m : ( $m['text'] ?? '' );
                }
                $msg = implode( '; ', array_filter( $texts ) );
            }
            return array_merge(
                $this->error( $msg ?: 'eMAG returned isError=true' ),
                [ 'http_code' => $http_code, 'raw' => $decoded ]
            );
        }

        return [
            'success'   => true,
            'data'      => $decoded['results'] ?? $decoded,
            'error'     => '',
            'http_code' => $http_code,
        ];
    }

    /**
     * PATCH stock only for a single offer via /offer_stock/{id}.
     */
    public function patch_stock( int $seller_id, int $qty, int $warehouse = 1 ): array {
        $url  = $this->url( self::EP_OFFER_STOCK . '/' . $seller_id );
        $body = [
            'stock' => [
                [ 'warehouse_id' => $warehouse, 'value' => max( 0, $qty ) ],
            ],
        ];
        // PATCH uses the same data wrapper
        return $this->do_request( $url, $body, 'PATCH' );
    }

    /**
     * Read current offers from eMAG (paginated).
     */
    public function read_offers( array $filters = [] ): array {
        return $this->post( self::EP_PRODUCT_OFFER_READ, $filters );
    }

    /**
     * Count offers matching filters.
     */
    public function count_offers( array $filters = [] ): array {
        return $this->post( self::EP_PRODUCT_OFFER_COUNT, $filters );
    }

    /**
     * Read available VAT rates for this region.
     * Returns list of {id, vat_rate}.
     */
    public function read_vat_rates(): array {
        return $this->post( self::EP_VAT_READ, [] );
    }

    /**
     * Connectivity + credential test.
     */
    public function ping(): array {
        $start    = microtime( true );
        $response = $this->count_offers();
        $response['data'] = array_merge(
            (array) ( $response['data'] ?? [] ),
            [ 'latency_ms' => (int) round( ( microtime( true ) - $start ) * 1000 ) ]
        );
        return $response;
    }

    // ── Category API ──────────────────────────────────────────────────────

    /**
     * Read categories (paginated).
     * eMAG category/read needs params as direct form fields (currentPage=2),
     * NOT wrapped in data=json. This matches how Bizzmags sends it.
     *
     * @param array $filters  e.g. ['currentPage'=>2, 'itemsPerPage'=>100]
     */
    public function read_categories( array $filters = [] ): array {
        $url = $this->url( self::EP_CATEGORY_READ );

        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        self::debug_log( '[SkyEmagSync] → POST ' . $url );
        self::debug_log( '[SkyEmagSync]   Params: ' . wp_json_encode( $filters ) );

        // Send as direct form fields — WordPress encodes the array automatically.
        $response = wp_remote_post( $url, [
            'headers' => [ 'Authorization' => $auth ],
            'body'    => $filters,
            'timeout' => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ] );

        if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
            $response = wp_remote_post( $url, [
                'headers' => [ 'Authorization' => $auth ],
                'body'    => $filters,
                'timeout' => self::TIMEOUT_SECONDS,
                'sslverify' => false,
            ] );
        }

        if ( is_wp_error( $response ) ) {
            return $this->error( $response->get_error_message() );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $decoded   = json_decode( $raw_body, true );

        self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' | body: ' . substr( $raw_body, 0, 500 ) );

        if ( $http_code < 200 || $http_code >= 300 ) {
            return array_merge( $this->error( 'HTTP ' . $http_code ), [ 'http_code' => $http_code ] );
        }

        if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
            $msg = '';
            foreach ( (array) ( $decoded['messages'] ?? [] ) as $m ) {
                $msg .= ( is_string( $m ) ? $m : ( $m['text'] ?? '' ) ) . '; ';
            }
            return array_merge( $this->error( trim( $msg, '; ' ) ?: 'isError=true' ), [ 'http_code' => $http_code ] );
        }

        return [
            'success'   => true,
            'data'      => $decoded['results'] ?? $decoded,
            'error'     => '',
            'http_code' => $http_code,
        ];
    }

    /**
     * Count total categories.
     */
    public function count_categories( array $filters = [] ): array {
        return $this->post( self::EP_CATEGORY_COUNT, $filters );
    }

    // ── Orders API ────────────────────────────────────────────────────────

    /**
     * Read orders from eMAG. Accepts the documented filter keys for
     * /order/read: currentPage, itemsPerPage, status, type,
     * modifiedAfter / modifiedBefore (YYYY-MM-DD HH:MM:SS), etc.
     *
     * Encoding: data=<json> envelope (same as product_offer/read). The
     * flat form-fields encoding that /category/read uses returned 0
     * rows on /order/read in dev testing — the endpoint silently
     * rejects that shape. The JSON envelope at least returns rows;
     * the filter behaviour is investigated separately in OrdersPage.
     *
     * Order resources have their own rate limit tier (12 req/sec,
     * 720 req/min); retry handling stays the same.
     *
     * @param array $filters  Endpoint filters, e.g. ['currentPage'=>1, 'itemsPerPage'=>50]
     */
    public function read_orders( array $filters = [] ): array {
        return $this->post( self::EP_ORDER_READ, $filters );
    }

    /**
     * Count orders matching the supplied filters. Asymmetric to
     * read_orders: count uses the data=<json> envelope (like
     * count_categories), read uses flat form fields (like
     * read_categories). Confirmed empirically — sending count_orders
     * via flat form fields returned noOfItems=0 even when read_orders
     * with the same filters returned the full dataset.
     */
    public function count_orders( array $filters = [] ): array {
        return $this->post( self::EP_ORDER_COUNT, $filters );
    }

    /**
     * Single-order read by id. Sends the request as flat form fields
     * (the same shape Bizzmags uses for their callback handler) — the
     * data=<json> envelope is silently rejected on /order/read for
     * filter-style params on the test account, so the only way to
     * actually fetch a specific order is the form-fields path.
     *
     * Returns the standard EmagClient response shape; the caller
     * inspects $result['data'] for the row.
     */
    public function read_order_by_id( int $order_id ): array {
        if ( $order_id <= 0 ) {
            return $this->error( 'Invalid order id.' );
        }
        return $this->form_post( self::EP_ORDER_READ, [ 'id' => $order_id ] );
    }

    /**
     * Acknowledge an order via GET /order/acknowledge/{id}. Stops eMAG
     * from re-firing the new-order callback for this id and moves the
     * order from status 1 (new) to status 2 (in progress). See docs
     * v4.5.1 line 730.
     */
    public function acknowledge_order( int $order_id ): array {
        if ( $order_id <= 0 ) {
            return $this->error( 'Invalid order id.' );
        }

        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $url  = $this->url( self::EP_ORDER_ACKNOWLEDGE . '/' . $order_id );
        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        $args = [
            'method'    => 'GET',
            'headers'   => [
                'Accept'        => 'application/json',
                'Authorization' => $auth,
            ],
            'timeout'   => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ];

        self::debug_log( '[SkyEmagSync] → GET ' . $url );

        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
            $args['sslverify'] = false;
            $response = wp_remote_request( $url, $args );
        }

        if ( is_wp_error( $response ) ) {
            return $this->error( $response->get_error_message() );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $decoded   = json_decode( $raw_body, true );

        if ( $http_code < 200 || $http_code >= 300 ) {
            return array_merge( $this->error( 'HTTP ' . $http_code . ': ' . $raw_body ), [ 'http_code' => $http_code ] );
        }

        if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
            $msg = '';
            foreach ( (array) ( $decoded['messages'] ?? [] ) as $m ) {
                $msg .= ( is_string( $m ) ? $m : ( $m['text'] ?? '' ) ) . '; ';
            }
            return array_merge(
                $this->error( trim( $msg, '; ' ) ?: 'eMAG returned isError=true on acknowledge' ),
                [ 'http_code' => $http_code, 'raw' => $decoded ]
            );
        }

        return [
            'success'   => true,
            'data'      => $decoded,
            'error'     => '',
            'http_code' => $http_code,
        ];
    }

    /**
     * POST helper that sends $filters as flat form fields instead of
     * wrapping them in `data=<json>`. Used by /order/read, /order/count
     * and any other endpoint that follows the same convention as
     * /category/read.
     *
     * Same response handling as do_request — returns the standard
     * ['success'=>bool, 'data'=>mixed, 'error'=>string, 'http_code'=>int]
     * shape so callers can stay agnostic to the encoding.
     */
    private function form_post( string $endpoint, array $filters ): array {
        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $url  = $this->url( $endpoint );
        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        $args = [
            'method'    => 'POST',
            'headers'   => [
                'Accept'        => 'application/json',
                'Authorization' => $auth,
            ],
            'body'      => $filters,
            'timeout'   => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ];

        self::debug_log( '[SkyEmagSync] → POST ' . $url );
        self::debug_log( '[SkyEmagSync]   Form fields: ' . wp_json_encode( $filters ) );

        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
            $args['sslverify'] = false;
            $response = wp_remote_request( $url, $args );
        }

        if ( is_wp_error( $response ) ) {
            return $this->error( $response->get_error_message() );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $decoded   = json_decode( $raw_body, true );

        self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' | body: ' . substr( $raw_body, 0, 500 ) );

        if ( $http_code < 200 || $http_code >= 300 ) {
            $msg = ( is_array( $decoded ) && isset( $decoded['message'] ) )
                ? $decoded['message']
                : "HTTP {$http_code}";
            return array_merge( $this->error( (string) $msg ), [ 'http_code' => $http_code ] );
        }

        if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
            $msg = '';
            foreach ( (array) ( $decoded['messages'] ?? [] ) as $m ) {
                if ( is_string( $m ) ) {
                    $msg .= $m . '; ';
                } elseif ( is_array( $m ) && isset( $m['text'] ) ) {
                    $msg .= $m['text'] . '; ';
                }
            }
            return array_merge(
                $this->error( trim( $msg, '; ' ) ?: 'eMAG returned isError=true' ),
                [ 'http_code' => $http_code, 'raw' => $decoded ]
            );
        }

        return [
            'success'   => true,
            'data'      => $decoded['results'] ?? $decoded,
            'error'     => '',
            'http_code' => $http_code,
        ];
    }

    // ── AWB / Shipping API ────────────────────────────────────────────────

    /**
     * Reads the seller's courier accounts. Cached upstream by AwbCache.
     *
     * @return array { success: bool, data: array, error: string }
     */
    public function read_courier_accounts(): array {
        return $this->post( '/courier_accounts/read', [] );
    }

    /**
     * Reads the seller's pickup/return addresses. Cached upstream by AwbCache.
     *
     * @return array { success: bool, data: array, error: string }
     */
    public function read_addresses(): array {
        return $this->post( '/addresses/read', [] );
    }

    /**
     * Issues an AWB on eMAG for an order.
     *
     * @param array $payload Built by Shipping\AwbPayloadBuilder.
     * @return array { success: bool, data: array, error: string }
     */
    public function save_awb( array $payload ): array {
        return $this->post( '/awb/save', $payload );
    }

    /**
     * Reads AWB metadata or streams the AWB document in a given format.
     * When $format is empty, returns metadata (parsed array).
     * When $format is 'A4'|'A5'|'A6'|'ZPL', returns binary stream wrapped
     * as { success, body (raw bytes), content_type, error }.
     *
     * @param int    $emag_awb_id eMAG internal AWB id (from save_awb response).
     * @param string $format      '', 'A4', 'A5', 'A6', or 'ZPL'.
     * @return array
     */
    public function read_awb( int $emag_awb_id, string $format = '' ): array {
        $params = [ 'emag_id' => $emag_awb_id ];
        if ( '' !== $format ) {
            $params['awb_format'] = strtoupper( $format );
        }

        if ( '' === $format ) {
            // Metadata path — parse JSON normally via the retry-aware helper.
            return $this->post( '/awb/read', $params );
        }

        // Binary stream path — bypass JSON parsing, return raw body.
        // Replicates do_request body encoding but skips JSON decode on response.
        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $url  = $this->url( '/awb/read' );
        $body = 'data=' . rawurlencode( wp_json_encode( $params ) );
        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        $args = [
            'method'  => 'POST',
            'headers' => [
                'Content-Type'  => 'application/x-www-form-urlencoded',
                'Accept'        => 'application/octet-stream',
                'Authorization' => $auth,
            ],
            'body'      => $body,
            'timeout'   => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ];

        self::debug_log( '[SkyEmagSync] → POST ' . $url . ' (AWB binary, format=' . $format . ')' );

        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
            $args['sslverify'] = false;
            $response = wp_remote_request( $url, $args );
        }

        if ( is_wp_error( $response ) ) {
            return array_merge( $this->error( $response->get_error_message() ), [ 'body' => '', 'content_type' => '' ] );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );

        self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' (AWB binary stream)' );

        if ( $http_code < 200 || $http_code >= 300 ) {
            $raw = wp_remote_retrieve_body( $response );
            return array_merge(
                $this->error( 'HTTP ' . $http_code . ': ' . $raw ),
                [ 'body' => '', 'content_type' => '', 'http_code' => $http_code ]
            );
        }

        $raw_body     = wp_remote_retrieve_body( $response );
        $content_type = (string) wp_remote_retrieve_header( $response, 'content-type' );

        return [
            'success'      => true,
            'body'         => $raw_body,
            'content_type' => $content_type,
            'error'        => '',
            'http_code'    => $http_code,
        ];
    }

    // ── EAN Matching API ─────────────────────────────────────────────────

    /**
     * Search eMAG catalogue by EAN codes.
     *
     * Uses the documentation/find_by_eans/read endpoint (v4.4.9+).
     * HTTP method: GET with EAN codes as query parameters.
     * Accepts up to 100 EAN codes per request.
     *
     * Rate limits: 5 req/sec, 200 req/min, 5000 req/day.
     *
     * @param  string[] $eans  Array of EAN strings (max 100).
     * @return array  Standard response: ['success'=>bool, 'data'=>array, 'error'=>string]
     */
    public function find_by_eans( array $eans ): array {
        $eans = array_slice( array_filter( array_map( 'trim', $eans ) ), 0, 100 );
        if ( empty( $eans ) ) {
            return $this->error( 'No EAN codes provided.' );
        }
        return $this->get_ean_lookup( $eans );
    }

    /**
     * GET/POST request for documentation/find_by_eans.
     * Tries multiple URL patterns since eMAG docs are ambiguous:
     *   1. GET  /documentation/find_by_eans?ean[]=X&ean[]=Y
     *   2. GET  /documentation/find_by_eans/read?ean[]=X&ean[]=Y
     *   3. POST /documentation/find_by_eans/read  with data=[eans]
     */
    private function get_ean_lookup( array $eans ): array {
        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        // Build EAN query string — eMAG expects "eans[]" parameter.
        $query_parts = [];
        foreach ( $eans as $ean ) {
            $query_parts[] = 'eans[]=' . rawurlencode( $ean );
        }
        $ean_query = implode( '&', $query_parts );

        // Try each URL/method combination.
        $attempts = [
            [ 'method' => 'GET',  'url' => $this->url( self::EP_FIND_BY_EANS ) . '?' . $ean_query, 'body' => null ],
            [ 'method' => 'GET',  'url' => $this->url( self::EP_FIND_BY_EANS . '/read' ) . '?' . $ean_query, 'body' => null ],
            [ 'method' => 'POST', 'url' => $this->url( self::EP_FIND_BY_EANS . '/read' ), 'body' => 'data=' . rawurlencode( wp_json_encode( [ 'eans' => $eans ] ) ) ],
        ];

        foreach ( $attempts as $attempt ) {
            $args = [
                'method'    => $attempt['method'],
                'headers'   => [
                    'Accept'        => 'application/json',
                    'Authorization' => $auth,
                ],
                'timeout'   => self::TIMEOUT_SECONDS,
                'sslverify' => true,
            ];

            if ( null !== $attempt['body'] ) {
                $args['headers']['Content-Type'] = 'application/x-www-form-urlencoded';
                $args['body'] = $attempt['body'];
            }

            self::debug_log( '[SkyEmagSync] → ' . $attempt['method'] . ' ' . substr( $attempt['url'], 0, 200 ) );

            $response = wp_remote_request( $attempt['url'], $args );

            // SSL fallback.
            if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
                $args['sslverify'] = false;
                $response = wp_remote_request( $attempt['url'], $args );
            }

            if ( is_wp_error( $response ) ) {
                continue; // Try next attempt.
            }

            $http_code = (int) wp_remote_retrieve_response_code( $response );

            $raw_body = wp_remote_retrieve_body( $response );
            self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' | body: ' . substr( $raw_body, 0, 500 ) );

            // 404 = wrong URL pattern, try next.
            if ( 404 === $http_code ) {
                continue;
            }

            // Got a non-404 response — parse it.
            $raw_body = wp_remote_retrieve_body( $response );
            $decoded  = json_decode( $raw_body, true );

            if ( $http_code < 200 || $http_code >= 300 ) {
                $msg = ( is_array( $decoded ) && isset( $decoded['message'] ) )
                    ? $decoded['message']
                    : "HTTP {$http_code}";
                return array_merge( $this->error( (string) $msg ), [ 'http_code' => $http_code ] );
            }

            if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
                $messages = $decoded['messages'] ?? [];
                $msg = '';
                if ( is_array( $messages ) ) {
                    $texts = [];
                    foreach ( $messages as $m ) {
                        $texts[] = is_string( $m ) ? $m : ( $m['text'] ?? '' );
                    }
                    $msg = implode( '; ', array_filter( $texts ) );
                }
                return array_merge(
                    $this->error( $msg ?: 'eMAG returned isError=true' ),
                    [ 'http_code' => $http_code, 'raw' => $decoded ]
                );
            }

            return [
                'success'   => true,
                'data'      => $decoded['results'] ?? $decoded,
                'error'     => '',
                'http_code' => $http_code,
            ];
        }

        return $this->error( 'EAN lookup failed: all URL patterns returned 404. This endpoint may not be available for your eMAG account.' );
    }

    /**
     * Batch EAN lookup — splits into chunks of 100.
     *
     * @param  string[] $eans
     * @return array{matched: array, unmatched: string[], errors: string[]}
     */
    public function bulk_find_by_eans( array $eans ): array {
        $matched   = [];
        $unmatched = [];
        $errors    = [];

        foreach ( array_chunk( $eans, 100 ) as $chunk ) {
            $res = $this->find_by_eans( $chunk );
            if ( ! $res['success'] ) {
                $errors[] = $res['error'];
                // Treat entire chunk as unmatched on error.
                $unmatched = array_merge( $unmatched, $chunk );
                continue;
            }

            // Parse response — data is typically an array of results per EAN.
            $data = (array) ( $res['data'] ?? [] );
            $found_eans = [];

            foreach ( $data as $entry ) {
                if ( ! empty( $entry['ean'] ) ) {
                    $found_eans[] = (string) $entry['ean'];
                    $matched[ (string) $entry['ean'] ] = $entry;
                }
            }

            // EANs not in results are unmatched.
            foreach ( $chunk as $ean ) {
                if ( ! isset( $matched[ $ean ] ) ) {
                    $unmatched[] = $ean;
                }
            }
        }

        return [
            'matched'   => $matched,
            'unmatched' => $unmatched,
            'errors'    => $errors,
        ];
    }

    // ── Batch helper ──────────────────────────────────────────────────────

    public function bulk_save_offers( array $offers ): array {
        $synced = 0;
        $failed = 0;
        $errors = [];

        $chunks = array_chunk( $offers, self::BATCH_SIZE );
        $last   = count( $chunks ) - 1;
        foreach ( $chunks as $i => $chunk ) {
            $res = $this->save_offers( $chunk );
            if ( $res['success'] ) {
                $synced += count( $chunk );
            } else {
                $failed += count( $chunk );
                $errors[] = $res['error'];
            }
            // Keep cumulative rate under the eMAG 3 req/sec ceiling. No sleep
            // on the final chunk so a one-batch sync still returns promptly.
            if ( $i < $last ) {
                usleep( self::REQ_INTERVAL_MICROS );
            }
        }

        return [
            'success' => $failed === 0,
            'synced'  => $synced,
            'failed'  => $failed,
            'errors'  => $errors,
        ];
    }

    // ── Private: request layer ─────────────────────────────────────────────

    /**
     * POST wrapper with retry + backoff.
     *
     * IMPORTANT: wraps payload in {"data": [...]} as required by eMAG API.
     */
    private function post( string $endpoint, array $payload ): array {
        return $this->request( $this->url( $endpoint ), $payload, 'POST' );
    }

    private function request( string $url, array $payload, string $method = 'POST' ): array {
        if ( empty( $this->username ) || empty( $this->password ) ) {
            return $this->error( 'eMAG API credentials are not configured.' );
        }

        $attempt = 0;
        $last    = null;

        while ( $attempt < self::MAX_RETRIES ) {
            if ( $attempt > 0 ) {
                sleep( self::RETRY_BASE_DELAY * ( 2 ** ( $attempt - 1 ) ) );
            }

            $last = $this->do_request( $url, $payload, $method );

            if ( $last['success'] ) {
                return $last;
            }

            $code = $last['http_code'] ?? 0;

            // Don't retry auth / validation errors
            if ( in_array( $code, [ 400, 401, 403, 422 ], true ) ) {
                break;
            }

            // Rate limited – honour Retry-After header
            if ( 429 === $code ) {
                $retry_after = (int) ( $last['retry_after'] ?? 60 );
                sleep( min( $retry_after, 120 ) );
            }

            // cURL timeout (http_code = 0) often means the eMAG gateway is
            // refusing to respond because the per-second limit has been
            // crossed. The default 2 s / 4 s backoff is too short to clear
            // that gate, so wait the cooldown before the next attempt.
            if ( 0 === $code && self::is_timeout_error( $last['error'] ?? '' ) ) {
                sleep( self::TIMEOUT_COOLDOWN_SECONDS );
            }

            $attempt++;
        }

        return $last ?? $this->error( 'Request failed after ' . self::MAX_RETRIES . ' attempts.' );
    }

    /**
     * Whether a wp_http error message reads like a connection timeout.
     * Matches both cURL 28 ("Operation timed out") and the WP HTTP layer
     * wording so the cooldown path catches both transports. Exposed for
     * callers that need to detect cascading timeouts across batches.
     */
    public static function is_timeout_error( string $msg ): bool {
        if ( '' === $msg ) {
            return false;
        }
        $needles = [ 'cURL error 28', 'Operation timed out', 'timed out', 'timeout' ];
        foreach ( $needles as $needle ) {
            if ( false !== stripos( $msg, $needle ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * True when a wp_remote_request return value is a cURL 28 timeout.
     * Used by the inline retry loop inside save_offers() to decide whether
     * an attempt is worth retrying. Anything else (success, 4xx/5xx,
     * non-timeout cURL errors) short-circuits the loop.
     */
    public static function is_curl_timeout_response( $response ): bool {
        if ( ! is_wp_error( $response ) ) {
            return false;
        }
        return self::is_timeout_error( $response->get_error_message() );
    }

    /**
     * Single raw HTTP call.
     *
     * eMAG requires the payload wrapped as: { "data": [ ...items... ] }
     * An empty payload (e.g. vat/read) is sent as: { "data": [] }
     */
    private function do_request( string $url, array $payload, string $method = 'POST' ): array {
        $body = 'data=' . rawurlencode( wp_json_encode( $payload ) );

        // Build Authorization header using HTTP Basic Auth (RFC 7617)
        $auth = 'Basic ' . base64_encode( $this->username . ':' . $this->password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

        $args = [
            'method'    => $method,
            'headers'   => [
                'Content-Type'  => 'application/x-www-form-urlencoded',
                'Accept'        => 'application/json',
                'Authorization' => $auth,
            ],
            'body'      => $body,
            'timeout'   => self::TIMEOUT_SECONDS,
            'sslverify' => true,
        ];

        self::debug_log( '[SkyEmagSync] → ' . $method . ' ' . $url );
        self::debug_log( '[SkyEmagSync]   Body: ' . substr( $body, 0, 200 ) );

        $response = wp_remote_request( $url, $args );

        // If SSL verification fails (e.g. outdated CA bundle on shared hosting),
        // retry without verification and log a notice.
        if ( is_wp_error( $response ) && false !== strpos( $response->get_error_message(), 'SSL' ) ) {
            self::debug_log( '[SkyEmagSync] SSL verification failed, retrying without SSL verify. Consider updating your server CA bundle.' );
            $args['sslverify'] = false;
            $response = wp_remote_request( $url, $args );
        }

        if ( is_wp_error( $response ) ) {
            return $this->error( $response->get_error_message() );
        }

        $http_code = (int) wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $decoded   = json_decode( $raw_body, true );

        // Log raw response in WP_DEBUG mode to help diagnose auth issues
        self::debug_log( '[SkyEmagSync] ' . $method . ' ' . $url );
        self::debug_log( '[SkyEmagSync] HTTP ' . $http_code . ' | body: ' . substr( $raw_body, 0, 500 ) );

        // Rate limited
        if ( 429 === $http_code ) {
            return array_merge(
                $this->error( 'eMAG rate limit reached (HTTP 429).' ),
                [
                    'http_code'   => 429,
                    'retry_after' => (int) ( wp_remote_retrieve_header( $response, 'retry-after' ) ?: 60 ),
                ]
            );
        }

        // Non-2xx HTTP error
        if ( $http_code < 200 || $http_code >= 300 ) {
            $msg = ( is_array( $decoded ) && isset( $decoded['message'] ) )
                ? $decoded['message']
                : "HTTP {$http_code}";
            return array_merge( $this->error( (string) $msg ), [ 'http_code' => $http_code ] );
        }

        // HTTP 200 but eMAG reports an error (isError = true)
        if ( is_array( $decoded ) && ! empty( $decoded['isError'] ) ) {
            $messages = $decoded['messages'] ?? [];
            $msg      = '';
            if ( is_array( $messages ) && ! empty( $messages ) ) {
                // eMAG returns messages as plain strings: ["ERROR: ..."]
                // or occasionally as objects with a 'text' key
                $texts = [];
                foreach ( $messages as $m ) {
                    if ( is_string( $m ) ) {
                        $texts[] = $m;
                    } elseif ( is_array( $m ) && isset( $m['text'] ) ) {
                        $texts[] = $m['text'];
                    }
                }
                $msg = implode( '; ', array_filter( $texts ) );
            }
            if ( ! $msg && ! empty( $decoded['errors'] ) ) {
                // Also check 'errors' array
                $errs = [];
                foreach ( (array) $decoded['errors'] as $e ) {
                    if ( is_string( $e ) ) {
                        $errs[] = $e;
                    } elseif ( is_array( $e ) && isset( $e['message'] ) ) {
                        $errs[] = $e['message'];
                    }
                }
                $msg = implode( '; ', array_filter( $errs ) );
            }
            return array_merge(
                $this->error( $msg ?: 'eMAG returned isError=true' ),
                [ 'http_code' => $http_code, 'raw' => $decoded ]
            );
        }

        return [
            'success'   => true,
            'data'      => $decoded['results'] ?? $decoded,
            'error'     => '',
            'http_code' => $http_code,
        ];
    }

    private function url( string $endpoint ): string {
        return rtrim( $this->base_url, '/' ) . $endpoint;
    }

    private function error( string $message ): array {
        return [ 'success' => false, 'data' => null, 'error' => $message, 'http_code' => 0 ];
    }

    /**
     * Internal debug logger. Routed through a single helper so that the
     * single phpcs:ignore annotation for error_log() lives in one place,
     * and so the WP_DEBUG guard is centralised — log statements never run
     * on a production install unless the site owner has explicitly opted in
     * via the WP_DEBUG constant in wp-config.php.
     */
    private static function debug_log( string $message ): void {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- gated by WP_DEBUG; opt-in via wp-config.php.
            error_log( $message );
        }
    }
}
