<?php
namespace WebshoptigerCatalogSync;

defined( 'ABSPATH' ) || exit;

class Sync {

    // -------------------------------------------------------------------------
    // Check remote product status
    // -------------------------------------------------------------------------

    /**
     * Check whether a remote product still exists on the destination site.
     * Returns: 'exists' | 'deleted' | 'not_ours'
     */
    private static function check_remote_product( int $remote_id, array $site ): string {
        $url   = trailingslashit( $site['site_url'] ) . 'wp-json/wc/v3/products/' . $remote_id;
        $creds = base64_encode( $site['api_key'] . ':' . $site['api_secret'] );

        $response = wp_remote_get( $url, [
            'timeout' => 10,
            'headers' => [ 'Authorization' => 'Basic ' . $creds ],
        ] );

        if ( is_wp_error( $response ) ) {
            return 'exists'; // Be cautious on a network error: don't clear
        }

        $code = wp_remote_retrieve_response_code( $response );

        if ( $code === 404 ) {
            return 'deleted';
        }

        if ( $code !== 200 ) {
            return 'exists'; // Unknown error — don't clear
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );

        // Check supplier meta
        foreach ( (array) ( $body['meta_data'] ?? [] ) as $meta ) {
            if ( $meta['key'] === 'supplier' ) {
                return $meta['value'] === Settings::supplier_id() ? 'exists' : 'not_ours';
            }
        }

        // No supplier meta = newly created product or own product without meta
        return 'exists';
    }

    /**
     * Clear all stored remote-link meta for a product/site combination.
     * Called when a remote product turns out to have been deleted.
     */
    private static function clear_remote_meta( int $product_id, int $site_id ): void {
        // Remote ID mapping
        $map = get_post_meta( $product_id, '_webshoptigercatalogsync_remote_ids', true );
        if ( is_array( $map ) ) {
            unset( $map[ $site_id ] );
            update_post_meta( $product_id, '_webshoptigercatalogsync_remote_ids', $map );
        }
        // Product hash — so the product is treated as changed
        delete_post_meta( $product_id, '_webshoptigercatalogsync_hash_' . $site_id );
        // Image hash
        delete_post_meta( $product_id, '_webshoptigercatalogsync_img_hash_' . $site_id );
        delete_post_meta( $product_id, '_webshoptigercatalogsync_img_hash_' . $site_id . '_pending' );
    }

    /**
     * Check whether a variable product on the destination site has zero
     * variations while it has variations locally (should never happen —
     * indicates a previously failed variation sync).
     */
    private static function variations_missing_on_remote( \WC_Product $product, array $site, int $remote_id ): bool {
        $local_children = $product->get_children();
        if ( empty( $local_children ) ) {
            return false; // No local variations — nothing to check
        }

        $url   = trailingslashit( $site['site_url'] ) . 'wp-json/wc/v3/products/' . $remote_id . '/variations?per_page=1';
        $creds = base64_encode( $site['api_key'] . ':' . $site['api_secret'] );

        $response = wp_remote_get( $url, [
            'timeout' => 10,
            'headers' => [ 'Authorization' => 'Basic ' . $creds ],
        ] );

        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return false; // Don't force when uncertain — avoids unnecessary API load on errors
        }

        $remote_variations = json_decode( wp_remote_retrieve_body( $response ), true );

        return empty( $remote_variations );
    }

    private static function log_table(): string {
        global $wpdb;
        return $wpdb->prefix . 'webshoptigercatalogsync_log';
    }

    // -------------------------------------------------------------------------
    // Entry points – populate the queue
    // -------------------------------------------------------------------------

    public static function run_all(): void {
        if ( ! Settings::is_source_site() ) return;
        Queue::enqueue_all();
    }

    public static function sync_site( int $site_id ): void {
        Queue::enqueue_one_site( $site_id );
    }

    // -------------------------------------------------------------------------
    // Push one product to one site (called by Queue::process_task)
    // -------------------------------------------------------------------------

    public static function push_single_product( \WC_Product $product, array $site ): void {
        $remote_id = self::get_remote_id( $site['id'], $product->get_id() );
        $base_url  = trailingslashit( $site['site_url'] ) . 'wp-json/wc/v3/products';
        $creds     = base64_encode( $site['api_key'] . ':' . $site['api_secret'] );

        // Hash of the product data to avoid unnecessary updates
        $current_hash = self::product_hash( $product );
        $saved_hash   = get_post_meta( $product->get_id(), '_webshoptigercatalogsync_hash_' . $site['id'], true );

        // Fast path: hash unchanged AND remote_id known → skip.
        // If a product was manually deleted on the destination site, the next
        // sync will detect the 404 on the UPDATE attempt below and automatically
        // re-create it. Alternatively, use "Reset sync state" in the UI to force
        // all products to be treated as new.
        if ( $remote_id && $current_hash === $saved_hash ) {
            if ( $product->is_type( 'variable' ) && self::variations_missing_on_remote( $product, $site, $remote_id ) ) {
                self::log( $site['id'], $product->get_id(), $remote_id, 'fix-variations', 'success',
                    'Hash unchanged but variations were missing on the destination site — variations re-synced.' );
                self::sync_variations( $product, $site, $remote_id, $creds );
                return;
            }

            self::log( $site['id'], $product->get_id(), $remote_id, 'skip', 'success', 'No changes' );
            return;
        }

        // New or changed product: always push
        // Deleted remote products are handled via the 404 response (see below)

        // Look up/create categories on the destination site
        $categories = CategorySync::resolve( $product->get_category_ids(), $site );

        // Look up/create brands on the destination site
        $brand_remote_ids = BrandSync::resolve( $product->get_id(), $site );

        $data = self::build_product_data( $product, $site, $categories, $brand_remote_ids );

        if ( $remote_id ) {
            $response = self::api_request_with_retry( 'PUT', $base_url . '/' . $remote_id, $creds, $data );
            $action   = 'update';
        } else {
            $response = self::api_request_with_retry( 'POST', $base_url, $creds, $data );
            $action   = 'create';
        }

        if ( is_wp_error( $response ) ) {
            $err = $response->get_error_message();
            self::log( $site['id'], $product->get_id(), $remote_id, $action, 'error', $err );
            throw new \RuntimeException( esc_html( $err ) );
        }

        $code      = wp_remote_retrieve_response_code( $response );
        $raw_body  = wp_remote_retrieve_body( $response );
        $body      = json_decode( $raw_body, true );

        // Detect HTML error page (WordPress critical error as a response)
        if ( $body === null && strlen( $raw_body ) > 0 ) {
            // Response is not valid JSON — likely a PHP fatal error page
            $preview = substr( wp_strip_all_tags( $raw_body ), 0, 300 );
            self::log( $site['id'], $product->get_id(), $remote_id, $action, 'error',
                'Destination site did not return JSON (HTTP ' . $code . '). Response preview: ' . $preview );
            throw new \RuntimeException( 'Destination site critical error (HTTP ' . absint( $code ) . '): ' . esc_html( $preview ) );
        }

        if ( in_array( $code, [ 200, 201 ], true ) ) {
            $new_remote_id = (int) ( $body['id'] ?? $remote_id );
            self::save_remote_id( $site['id'], $product->get_id(), $new_remote_id );

            // Save the hash so the next sync skips unchanged products
            update_post_meta( $product->get_id(), '_webshoptigercatalogsync_hash_' . $site['id'], $current_hash );

            // Confirm the image hash after a successful push
            $img_meta_key     = '_webshoptigercatalogsync_img_hash_' . $site['id'];
            $pending_img_hash = get_post_meta( $product->get_id(), $img_meta_key . '_pending', true );
            if ( $pending_img_hash ) {
                update_post_meta( $product->get_id(), $img_meta_key, $pending_img_hash );
                delete_post_meta( $product->get_id(), $img_meta_key . '_pending' );
            }

            self::log( $site['id'], $product->get_id(), $new_remote_id, $action, 'success', '' );

            // Link brands to the product on the destination site
            if ( ! empty( $brand_remote_ids ) ) {
                BrandSync::assign( $new_remote_id, $brand_remote_ids, $product->get_id(), $site );
            }

            if ( $product->is_type( 'variable' ) ) {
                self::sync_variations( $product, $site, $new_remote_id, $creds );
            }
        } elseif ( $remote_id && $code === 404 ) {
            // Remote product was deleted — clear meta and recreate
            self::clear_remote_meta( $product->get_id(), $site['id'] );
            self::push_single_product( $product, $site );
        } elseif ( ! $remote_id && $code === 400 ) {
            // Check for duplicate SKU — product already exists on destination with this SKU
            $error_code   = $body['code'] ?? '';
            $error_msg    = $body['message'] ?? '';
            $is_sku_error = strpos( $error_code, 'sku' ) !== false
                         || stripos( $error_msg, 'SKU' ) !== false
                         || stripos( $error_msg, 'duplicated' ) !== false;

            if ( $is_sku_error && $product->get_sku() ) {
                // Find the existing product on the destination by SKU
                $lookup      = wp_remote_get(
                    $base_url . '?sku=' . rawurlencode( $product->get_sku() ) . '&per_page=1',
                    [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Basic ' . $creds ] ]
                );
                $lookup_body = json_decode( wp_remote_retrieve_body( $lookup ), true );

                if ( ! empty( $lookup_body[0]['id'] ) ) {
                    $found_id = (int) $lookup_body[0]['id'];
                    self::save_remote_id( $site['id'], $product->get_id(), $found_id );
                    $update      = self::api_request_with_retry( 'PUT', $base_url . '/' . $found_id, $creds, $data );
                    $update_code = (int) wp_remote_retrieve_response_code( $update );
                    if ( in_array( $update_code, [ 200, 201 ], true ) ) {
                        update_post_meta( $product->get_id(), '_webshoptigercatalogsync_hash_' . $site['id'], $current_hash );
                        self::log( $site['id'], $product->get_id(), $found_id, 'update', 'success',
                            'Recovered from duplicate SKU — found and updated existing product.' );
                        if ( $product->is_type( 'variable' ) ) {
                            self::sync_variations( $product, $site, $found_id, $creds );
                        }
                        return;
                    }
                }
            }

            $msg = $error_msg ?: ( 'HTTP 400 — ' . substr( wp_strip_all_tags( $raw_body ), 0, 200 ) );
            self::log( $site['id'], $product->get_id(), 0, 'create', 'error', $msg );
            throw new \RuntimeException( esc_html( $msg ) );
        } else {
            $msg = $body['message'] ?? $body['code'] ?? ( 'HTTP ' . $code . ' — ' . substr( wp_strip_all_tags( $raw_body ), 0, 200 ) );
            self::log( $site['id'], $product->get_id(), $remote_id, $action, 'error', $msg );
            throw new \RuntimeException( esc_html( $msg ) );
        }
    }

    // -------------------------------------------------------------------------
    // Hash of the product data for change detection
    // -------------------------------------------------------------------------

    private static function product_hash( \WC_Product $product ): string {
        // Include variations in the hash so new/changed variations trigger detection
        $variation_data = [];
        if ( $product->is_type( 'variable' ) ) {
            foreach ( $product->get_children() as $var_id ) {
                $var = wc_get_product( $var_id );
                if ( ! $var ) continue;
                $variation_data[] = [
                    $var_id,
                    $var->get_regular_price(),
                    $var->get_sale_price(),
                    $var->get_stock_quantity(),
                    $var->get_stock_status(),
                    $var->get_sku(),
                    $var->get_status(),
                    $var->get_variation_attributes(),
                ];
            }
        }

        return md5( serialize( [
            $product->get_name(),
            $product->get_description(),
            $product->get_short_description(),
            $product->get_sku(),
            $product->get_regular_price(),
            $product->get_sale_price(),
            $product->get_stock_quantity(),
            $product->get_stock_status(),
            $product->get_weight(),
            $product->get_length(),
            $product->get_width(),
            $product->get_height(),
            $product->get_image_id(),
            $product->get_gallery_image_ids(),
            $product->get_category_ids(),
            wp_get_object_terms( $product->get_id(), 'product_brand', [ 'fields' => 'ids' ] ),
            $product->get_status(),
            $variation_data,
        ] ) );
    }

    // -------------------------------------------------------------------------
    // Build the product payload
    // (categories are passed in from push_single_product)
    // -------------------------------------------------------------------------

    private static function build_product_data( \WC_Product $product, array $site, array $categories, array $brand_remote_ids = [] ): array {
        $description       = $product->get_description();
        $short_description = $product->get_short_description();

        $sync_desc = isset( $site['sync_description'] ) ? (int) $site['sync_description'] : 1;
        if ( $sync_desc === 0 ) {
            $description       = null;
            $short_description = null;
        }

        $images = self::build_images_payload( $product, $site['id'], $site );

        $attributes = [];
        foreach ( $product->get_attributes() as $attr ) {
            if ( $attr instanceof \WC_Product_Attribute ) {
                $options = $attr->is_taxonomy()
                    ? wc_get_product_terms( $product->get_id(), $attr->get_name(), [ 'fields' => 'names' ] )
                    : $attr->get_options();
                $attributes[] = [
                    'name'      => wc_attribute_label( $attr->get_name() ),
                    'visible'   => $attr->get_visible(),
                    'variation' => $attr->get_variation(),
                    'options'   => $options,
                ];
            }
        }

        $is_variable   = $product->is_type( 'variable' );
        $regular_price = $is_variable ? '' : (string) $product->get_regular_price();
        $sale_price    = $is_variable ? '' : (string) $product->get_sale_price();
        $date_on_sale_from = ! $is_variable ? $product->get_date_on_sale_from() : null;
        $date_on_sale_to   = ! $is_variable ? $product->get_date_on_sale_to()   : null;

        $data = [
            'name'              => $product->get_name(),
            'type'              => $product->get_type(),
            'status'            => $product->get_status(),
            'description'       => $sync_desc === 0 ? null : $description,
            'short_description' => $sync_desc === 0 ? null : $short_description,
            'sku'               => $product->get_sku(),
            'regular_price'     => $regular_price,
            'sale_price'        => $sale_price,
            'date_on_sale_from' => $date_on_sale_from ? $date_on_sale_from->date( 'Y-m-d\TH:i:s' ) : '',
            'date_on_sale_to'   => $date_on_sale_to   ? $date_on_sale_to->date( 'Y-m-d\TH:i:s' )   : '',
            'manage_stock'      => $product->get_manage_stock(),
            'stock_quantity'    => $product->get_stock_quantity(),
            'stock_status'      => $product->get_stock_status(),
            'backorders'        => $product->get_backorders(),
            'weight'            => (string) $product->get_weight(),
            'dimensions'        => [
                'length' => (string) $product->get_length(),
                'width'  => (string) $product->get_width(),
                'height' => (string) $product->get_height(),
            ],
            'categories' => $categories,
            'attributes' => $attributes,
            'meta_data'  => [
                [ 'key' => 'supplier',              'value' => Settings::supplier_id() ],
                [ 'key' => '_sc_source_product_id', 'value' => $product->get_id() ],
                [ 'key' => '_sc_source_url',        'value' => get_site_url() ],
            ],
        ];

        if ( ! empty( $images ) ) {
            $data['images'] = $images;
        }

        $data = array_filter( $data, fn( $v ) => $v !== null );
        return $data;
    }

    // -------------------------------------------------------------------------
    // HTTP call with retry on connection reset
    // -------------------------------------------------------------------------

    /**
     * Make an API call with automatic retry on temporary failures.
     * Splits large payloads: send the product without images first,
     * then send the images separately if the first call succeeds.
     */
    private static function api_request_with_retry( string $method, string $url, string $creds, array $data, int $max_retries = 3 ) {
        $headers = [
            'Authorization' => 'Basic ' . $creds,
            'Content-Type'  => 'application/json',
        ];

        // Split images out of the payload for large requests
        $images       = $data['images'] ?? [];
        $has_images   = ! empty( $images );
        $payload_size = strlen( wp_json_encode( $data ) );

        // For a large payload (>50KB): send without images first
        if ( $has_images && $payload_size > 51200 ) {
            $data_no_images = $data;
            unset( $data_no_images['images'] ); // Omit instead of an empty array
            $response = self::do_request_with_retry( $method, $url, $headers, $data_no_images, $max_retries );

            if ( ! is_wp_error( $response ) && in_array( wp_remote_retrieve_response_code( $response ), [ 200, 201 ], true ) ) {
                // Product created/updated — now send images separately
                $body = json_decode( wp_remote_retrieve_body( $response ), true );
                if ( ! empty( $body['id'] ) ) {
                    $img_url = preg_replace( '/\/\d+$/', '/' . $body['id'], $url );
                    if ( strpos( $img_url, '/' . $body['id'] ) === false ) {
                        $img_url = rtrim( $url, '/' ) . ( $method === 'POST' ? '/' . $body['id'] : '' );
                    }
                    self::do_request_with_retry( 'PUT', $img_url, $headers, [ 'images' => $images ], 2 );
                }
            }

            return $response;
        }

        return self::do_request_with_retry( $method, $url, $headers, $data, $max_retries );
    }

    private static function do_request_with_retry( string $method, string $url, array $headers, array $data, int $max_retries ) {
        $last_response = null;
        $body_json     = wp_json_encode( $data );
        $payload_size  = strlen( $body_json );
        $image_count   = count( $data['images'] ?? [] );

        // HTTP status codes that indicate a temporary block (rate limit, server load)
        // and therefore justify retrying after a pause
        $retryable_codes = [ 401, 403, 429, 500, 502, 503, 504 ];

        for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
            $response = wp_remote_request( $url, [
                'method'  => $method,
                'timeout' => 60,
                'headers' => $headers,
                'body'    => $body_json,
            ] );

            // Network error (no connection, timeout, connection reset)
            if ( is_wp_error( $response ) ) {
                $error_msg     = $response->get_error_message();
                $last_response = $response;

                $detail = sprintf(
                    '%s (attempt %d/%d) | method:%s | payload:%s bytes | images:%d | url:%s',
                    $error_msg, $attempt, $max_retries, $method,
                    number_format( $payload_size ), $image_count, $url
                );
                $last_response = new \WP_Error( $response->get_error_code(), $detail );

                $is_connection_error = (
                    strpos( $error_msg, 'cURL error 56' ) !== false ||
                    strpos( $error_msg, 'cURL error 28' ) !== false ||
                    strpos( $error_msg, 'cURL error 35' ) !== false ||
                    strpos( $error_msg, 'cURL error 7' )  !== false
                );

                if ( ! $is_connection_error || $attempt >= $max_retries ) {
                    return $last_response;
                }

                // On cURL 56: try without images if the payload is large
                if ( strpos( $error_msg, 'cURL error 56' ) !== false && $image_count > 0 && $payload_size > 20000 ) {
                    $data_no_img = $data;
                    unset( $data_no_img['images'] );
                    $body_json    = wp_json_encode( $data_no_img );
                    $payload_size = strlen( $body_json );
                    $image_count  = 0;
                }

                sleep( $attempt * 2 );
                continue;
            }

            // Connection succeeded — check whether the HTTP status code warrants a retry
            $code = wp_remote_retrieve_response_code( $response );

            if ( ! in_array( $code, $retryable_codes, true ) ) {
                return $response; // Success, or a definitive error (e.g. 400 validation error) — don't retry
            }

            $last_response = $response;

            if ( $attempt >= $max_retries ) {
                return $response; // Last attempt used up, return the (failed) response
            }

            // Temporary block (401/403/429/5xx) — wait longer than for network errors,
            // since this looks like a rate limit or brief server dip that needs time to recover
            sleep( $attempt * 10 );
        }

        return $last_response;
    }

    // -------------------------------------------------------------------------
    // Images — only resend if changed
    // -------------------------------------------------------------------------

    private static function build_images_payload( \WC_Product $product, int $site_id, array $site ): array {
        // Collect all image IDs and their URLs
        $image_ids = [];
        if ( $product->get_image_id() ) {
            $image_ids[] = (int) $product->get_image_id();
        }
        foreach ( $product->get_gallery_image_ids() as $gid ) {
            $image_ids[] = (int) $gid;
        }

        if ( empty( $image_ids ) ) {
            return [];
        }

        // Calculate a hash of all images (ID + modified date)
        $image_hash_data = [];
        $url_map         = []; // [ url => attachment_id ]

        foreach ( $image_ids as $id ) {
            $post = get_post( $id );
            $url  = wp_get_attachment_url( $id );
            if ( ! $url ) continue;
            $image_hash_data[ $id ] = $post ? $post->post_modified : '';
            $url_map[ $url ]        = $id;
        }

        if ( empty( $url_map ) ) {
            return [];
        }

        $current_hash = md5( serialize( $image_hash_data ) );
        $meta_key     = '_webshoptigercatalogsync_img_hash_' . $site_id;
        $saved_hash   = get_post_meta( $product->get_id(), $meta_key, true );

        // Images unchanged on the source site — send nothing, destination site keeps existing images
        if ( $saved_hash === $current_hash ) {
            return [];
        }

        // Images are new or changed — first check whether they already exist on the destination site
        $urls   = array_keys( $url_map );
        $found  = self::resolve_remote_images( $urls, $site );

        $images = [];
        foreach ( $url_map as $url => $local_id ) {
            if ( isset( $found[ $url ] ) ) {
                // Image already exists on the destination site — link via ID, no download
                $images[] = [ 'id' => (int) $found[ $url ] ];
            } else {
                // Image doesn't exist yet — download via the src URL
                // Store the source site URL as meta so the destination site recognizes it on the next sync
                $images[] = [
                    'src'  => $url,
                    'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
                ];
            }
        }

        update_post_meta( $product->get_id(), $meta_key . '_pending', $current_hash );

        return $images;
    }

    /**
     * Ask the destination site which images already exist in the media library.
     * Returns [ url => remote_attachment_id ] for the images that were found.
     * Returns an empty array if the endpoint isn't available (plugin not installed).
     */
    private static function resolve_remote_images( array $urls, array $site ): array {
        $url   = trailingslashit( $site['site_url'] ) . 'wp-json/webshoptigercatalogsync-sync/v1/resolve-images';
        $creds = base64_encode( $site['api_key'] . ':' . $site['api_secret'] );

        $response = wp_remote_post( $url, [
            'timeout' => 10,
            'headers' => [
                'Authorization' => 'Basic ' . $creds,
                'Content-Type'  => 'application/json',
            ],
            'body' => wp_json_encode( [ 'urls' => $urls ] ),
        ] );

        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return []; // Endpoint not available — just download
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );
        return (array) ( $body['found'] ?? [] );
    }

    // -------------------------------------------------------------------------
    // Variations
    // -------------------------------------------------------------------------

    private static function sync_variations( \WC_Product_Variable $product, array $site, int $remote_product_id, string $creds ): void {
        $base_url = trailingslashit( $site['site_url'] ) . 'wp-json/wc/v3/products/' . $remote_product_id . '/variations';

        // Fetch existing variations from the destination site (max 100 per page)
        $remote_map = [];
        $page = 1;
        do {
            $res = wp_remote_get( $base_url . '?per_page=100&page=' . $page, [
                'timeout' => 20,
                'headers' => [ 'Authorization' => 'Basic ' . $creds ],
            ] );
            if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) !== 200 ) break;
            $batch = json_decode( wp_remote_retrieve_body( $res ), true ) ?: [];
            foreach ( $batch as $ev ) {
                foreach ( (array) ( $ev['meta_data'] ?? [] ) as $meta ) {
                    if ( $meta['key'] === '_sc_source_variation_id' ) {
                        $remote_map[ (int) $meta['value'] ] = (int) $ev['id'];
                    }
                }
            }
            $page++;
        } while ( count( $batch ) === 100 );

        foreach ( $product->get_children() as $variation_id ) {
            $variation = wc_get_product( $variation_id );
            if ( ! $variation ) continue;

            // Sale period
            $date_on_sale_from = $variation->get_date_on_sale_from();
            $date_on_sale_to   = $variation->get_date_on_sale_to();

            // Variation image — only send if changed
            $variation_image     = [];
            $var_img_id          = $variation->get_image_id();
            $var_img_meta_key    = '_webshoptigercatalogsync_img_hash_var_' . $site['id'];
            $var_img_post        = $var_img_id ? get_post( $var_img_id ) : null;
            $var_img_hash        = $var_img_id ? md5( $var_img_id . ( $var_img_post->post_modified ?? '' ) ) : '';
            $var_img_saved_hash  = get_post_meta( $variation_id, $var_img_meta_key, true );

            if ( $var_img_id && $var_img_hash !== $var_img_saved_hash ) {
                $img_url = wp_get_attachment_url( $var_img_id );
                if ( $img_url ) {
                    $variation_image = [ 'src' => $img_url ];
                    update_post_meta( $variation_id, $var_img_meta_key, $var_img_hash );
                }
            }

            // Variation SKU: only send if it DIFFERS from the parent product's SKU.
            // Many source products have variations without their own SKU, where
            // WooCommerce falls back to the parent product's SKU — which is already
            // in use on the destination site by the parent product itself, causing
            // a "duplicate SKU" error.
            $variation_sku = $variation->get_sku();
            $parent_sku    = $product->get_sku();
            $sku_to_send   = ( $variation_sku !== '' && $variation_sku !== $parent_sku ) ? $variation_sku : '';

            $vdata = [
                'status'          => $variation->get_status() === 'private' ? 'private' : 'publish',
                'sku'             => $sku_to_send,
                'regular_price'   => (string) $variation->get_regular_price(),
                'sale_price'      => (string) $variation->get_sale_price(),
                'date_on_sale_from' => $date_on_sale_from ? $date_on_sale_from->gmdate( 'Y-m-d\\TH:i:s' ) : '',
                'date_on_sale_to'   => $date_on_sale_to   ? $date_on_sale_to->gmdate( 'Y-m-d\\TH:i:s' ) : '',
                'manage_stock'    => $variation->get_manage_stock(),
                'stock_quantity'  => $variation->get_stock_quantity(),
                'stock_status'    => $variation->get_stock_status(),
                'backorders'      => $variation->get_backorders(),
                'weight'          => (string) $variation->get_weight(),
                'dimensions'      => [
                    'length' => (string) $variation->get_length(),
                    'width'  => (string) $variation->get_width(),
                    'height' => (string) $variation->get_height(),
                ],
                'attributes'      => self::variation_attributes( $variation ),
                'meta_data'       => [
                    [ 'key' => '_sc_source_variation_id', 'value' => $variation_id ],
                ],
            ];

            if ( ! empty( $variation_image ) ) {
                $vdata['image'] = $variation_image;
            }

            $var_headers = [ 'Authorization' => 'Basic ' . $creds, 'Content-Type' => 'application/json' ];
            if ( isset( $remote_map[ $variation_id ] ) ) {
                $var_response = self::do_request_with_retry( 'PUT', $base_url . '/' . $remote_map[ $variation_id ], $var_headers, $vdata, 3 );
            } else {
                $var_response = self::do_request_with_retry( 'POST', $base_url, $var_headers, $vdata, 3 );
            }

            // Fallback: if an empty SKU string still gets flagged as a duplicate
            // (some WC versions treat "" differently from a missing field),
            // try omitting the field entirely instead of sending it empty.
            if ( ! is_wp_error( $var_response ) ) {
                $var_code_check = wp_remote_retrieve_response_code( $var_response );
                if ( $var_code_check === 400 ) {
                    $var_body_check = json_decode( wp_remote_retrieve_body( $var_response ), true );
                    if ( ( $var_body_check['code'] ?? '' ) === 'product_invalid_sku' ) {
                        $vdata_no_sku = $vdata;
                        unset( $vdata_no_sku['sku'] );

                        if ( isset( $remote_map[ $variation_id ] ) ) {
                            $var_response = self::do_request_with_retry( 'PUT', $base_url . '/' . $remote_map[ $variation_id ], $var_headers, $vdata_no_sku, 2 );
                        } else {
                            $var_response = self::do_request_with_retry( 'POST', $base_url, $var_headers, $vdata_no_sku, 2 );
                        }
                    }
                }
            }

            // Log errors: both network errors (WP_Error) and HTTP status errors (401/403/4xx/5xx)
            if ( is_wp_error( $var_response ) ) {
                $err_msg = 'variation #' . $variation_id . ': ' . $var_response->get_error_message();
                if ( defined('WP_DEBUG') && WP_DEBUG ) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                    error_log( 'Webshoptiger Catalog Sync variation error for product #' . $product->get_id() . ' ' . $err_msg );
                }
                self::log( $site['id'], $product->get_id(), $remote_product_id, 'variation-error', 'error', $err_msg );
            } else {
                $var_code = wp_remote_retrieve_response_code( $var_response );
                if ( ! in_array( $var_code, [ 200, 201 ], true ) ) {
                    $var_body  = json_decode( wp_remote_retrieve_body( $var_response ), true );
                    $var_msg   = $var_body['message'] ?? ( 'HTTP ' . $var_code );
                    $err_msg   = 'variation #' . $variation_id . ' (HTTP ' . $var_code . '): ' . $var_msg;
                    if ( defined('WP_DEBUG') && WP_DEBUG ) {
                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                        error_log( 'Webshoptiger Catalog Sync variation error for product #' . $product->get_id() . ' ' . $err_msg );
                    }
                    self::log( $site['id'], $product->get_id(), $remote_product_id, 'variation-error', 'error', $err_msg );
                }
            }
        }

        // Force recalculation of min/max prices on the parent product on the destination site.
        // WooCommerce stores these in _price, _min_variation_price, _max_variation_price.
        // We trigger this via the REST API by sending an empty PUT.
        self::trigger_price_recalculation( $remote_product_id, $site, $creds );
    }

    /**
     * Send an empty PUT to the parent product so WooCommerce recalculates
     * the min/max prices of the variations.
     */
    private static function trigger_price_recalculation( int $remote_product_id, array $site, string $creds ): void {
        // Send a PUT with only a meta_data field to make WooCommerce recalculate variation prices
        // catalog_visibility could change visibility; meta_data is safer
        $url = trailingslashit( $site['site_url'] ) . 'wp-json/wc/v3/products/' . $remote_product_id;
        wp_remote_request( $url, [
            'method'  => 'PUT',
            'timeout' => 20,
            'headers' => [ 'Authorization' => 'Basic ' . $creds, 'Content-Type' => 'application/json' ],
            'body'    => wp_json_encode( [ 'meta_data' => [ [ 'key' => '_sc_price_recalc', 'value' => time() ] ] ] ),
        ] );
    }

    private static function variation_attributes( \WC_Product_Variation $variation ): array {
        $result = [];
        foreach ( $variation->get_variation_attributes() as $key => $value ) {
            $attr_name = str_replace( 'attribute_', '', $key );
            // Empty value = 'any' variation (applies to all options of this attribute)
            $result[] = [
                'name'   => wc_attribute_label( $attr_name ),
                'option' => $value ?: '',
            ];
        }
        return $result;
    }

    // -------------------------------------------------------------------------
    // Remote ID mapping
    // -------------------------------------------------------------------------

    private static function get_remote_id( int $site_id, int $product_id ): ?int {
        $map = get_post_meta( $product_id, '_webshoptigercatalogsync_remote_ids', true );
        if ( ! is_array( $map ) ) return null;
        return isset( $map[ $site_id ] ) ? (int) $map[ $site_id ] : null;
    }

    private static function save_remote_id( int $site_id, int $product_id, int $remote_id ): void {
        $map = get_post_meta( $product_id, '_webshoptigercatalogsync_remote_ids', true );
        if ( ! is_array( $map ) ) $map = [];
        $map[ $site_id ] = $remote_id;
        update_post_meta( $product_id, '_webshoptigercatalogsync_remote_ids', $map );
    }

    /**
     * Reset all sync state (remote IDs and hashes) for a given destination site.
     *
     * Use this when products have been manually deleted on the destination site
     * so the next sync treats every product as new and re-creates them.
     * Uses direct SQL to avoid per-row overhead on large catalogs.
     */
    public static function clear_remote_ids_for_site( int $site_id ): int {
        global $wpdb;

        // 1. Remove the site's entry from _webshoptigercatalogsync_remote_ids on every product
        // phpcs:ignore // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $products_with_meta = $wpdb->get_col(
            "SELECT post_id FROM {$wpdb->postmeta}
             WHERE meta_key = '_webshoptigercatalogsync_remote_ids'"
        );

        $cleared = 0;
        foreach ( $products_with_meta as $product_id ) {
            $map = get_post_meta( (int) $product_id, '_webshoptigercatalogsync_remote_ids', true );
            if ( is_array( $map ) && isset( $map[ $site_id ] ) ) {
                unset( $map[ $site_id ] );
                update_post_meta( (int) $product_id, '_webshoptigercatalogsync_remote_ids', $map );
                $cleared++;
            }
        }

        // 2. Remove the hash for this site so every product is treated as changed
        // phpcs:ignore // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->postmeta}
             WHERE meta_key = %s",
            '_webshoptigercatalogsync_hash_' . $site_id
        ) );

        // 3. Remove image hashes for this site
        // phpcs:ignore // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->postmeta}
             WHERE meta_key LIKE %s",
            $wpdb->esc_like( '_webshoptigercatalogsync_img_hash_' . $site_id ) . '%'
        ) );

        return $cleared;
    }

    // -------------------------------------------------------------------------
    // Logging
    // -------------------------------------------------------------------------

    private static function log( int $site_id, int $product_id, ?int $remote_id, string $action, string $status, string $message ): void {
        global $wpdb;
        // phpcs:ignore // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->insert( self::log_table(), [
            'site_id'    => $site_id,
            'product_id' => $product_id,
            'remote_id'  => $remote_id,
            'action'     => $action,
            'status'     => $status,
            'message'    => $message,
            'synced_at'  => current_time( 'mysql' ),
        ] );
    }

    public static function get_log( int $limit = 100 ): array {
        global $wpdb;
        $lt = $wpdb->prefix . 'webshoptigercatalogsync_log';
        return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare(
                "SELECT l.*, s.label as site_label, p.post_title as product_name
                 FROM `{$lt}` l
                 LEFT JOIN `{$wpdb->prefix}webshoptigercatalogsync_sites` s ON s.id = l.site_id
                 LEFT JOIN `{$wpdb->posts}` p ON p.ID = l.product_id
                 ORDER BY l.synced_at DESC
                 LIMIT %d",
                $limit
            ),
            ARRAY_A
        ) ?: [];
    }

    public static function clear_log(): void {
        global $wpdb;
        $lt = $wpdb->prefix . 'webshoptigercatalogsync_log';
        $wpdb->query( "TRUNCATE TABLE `{$lt}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
    }

}
