<?php
/**
 * Sync runner – orchestrates full and partial sync cycles.
 *
 * Flow per product:
 *   1. Eligibility checks (master switch, sync enabled, has vendor_ext_id,
 *      not status=0 on eMAG, wc_stock > 0)
 *   2. Price guard: computed price must be within [min_price, max_price]
 *   3. Build offer/save payload via OfferPayload::from_product()
 *   4. Batch into chunks of 50, send via EmagClient::save_offers()
 *   5. Mark success/error on each product post_meta + log row
 *
 * @package SkyEmagSync
 */

namespace SkyEmagSync\Sync;

use SkyEmagSync\Api\EmagClient;
use SkyEmagSync\Api\OfferPayload;
use SkyEmagSync\Notifications\Dispatcher;
use SkyEmagSync\Notifications\Events;
use SkyEmagSync\Options;
use SkyEmagSync\PostMeta;

defined( 'ABSPATH' ) || exit;

class Runner {

    const LOCK_TRANSIENT = 'skyemag_sync_lock';
    const LOCK_EXPIRY    = 10 * MINUTE_IN_SECONDS;
    const BATCH_SIZE     = 25;

    // ── Public entry points ───────────────────────────────────────────────

    /**
     * Cron entry point. Delegates to Dispatcher so batches are processed
     * asynchronously via Action Scheduler instead of in the cron tick PHP
     * process — keeps cron under the host's max_execution_time even on
     * stores with thousands of products.
     */
    public static function run_cron(): void {
        if ( ! Options::is_master_enabled() ) {
            return;
        }
        // Fully-qualified — the imported Dispatcher (use line 20) points to
        // the Notifications namespace, used elsewhere in this file for email
        // dispatching. The cron entry needs the Sync\Dispatcher introduced
        // alongside Action Scheduler.
        \SkyEmagSync\Sync\Dispatcher::enqueue_full( 'cron' );
    }

    /**
     * Full sync – all enabled products/variations.
     *
     * @param string $trigger  'manual' | 'cron' | 'hook'
     * @return array [ success, synced, skipped, failed, errors[] ]
     */
    public static function run_sync( string $trigger = 'manual' ): array {
        if ( ! self::acquire_lock() ) {
            return self::locked_result();
        }

        $result = self::empty_result();

        try {
            // Cron and hook respect the master switch; manual always runs.
            if ( 'manual' !== $trigger && ! Options::is_master_enabled() ) {
                $result['errors'][] = 'Master sync switch is disabled.';
                return $result;
            }

            $products = self::get_eligible_products();
            $result   = self::process_products( $products, $result );

            Logger::log( 0, 0, Logger::ACTION_BOTH, Logger::RESULT_SUCCESS,
                "Sync ({$trigger}): {$result['synced']} synced, {$result['skipped']} skipped, {$result['failed']} failed" );

            $result['success'] = $result['failed'] === 0;

            self::dispatch_post_sync_notifications( $trigger, $result );

        } finally {
            self::release_lock();
        }

        return $result;
    }

    /**
     * Partial sync – specific product/variation IDs (bulk action "Sync selected").
     *
     * @param int[] $ids
     * @return array [ success, synced, skipped, failed, errors[] ]
     */
    public static function run_sync_for_ids( array $ids ): array {
        if ( ! self::acquire_lock() ) {
            return self::locked_result();
        }

        $result = self::empty_result();

        try {
            $products = [];
            foreach ( $ids as $id ) {
                $product = wc_get_product( $id );
                if ( $product ) {
                    $products[] = $product;
                }
            }

            $result          = self::process_products( $products, $result );
            $result['success'] = $result['failed'] === 0;

        } finally {
            self::release_lock();
        }

        return $result;
    }

    // ── Core processing ───────────────────────────────────────────────────

    /**
     * Process a list of WC_Product objects, batch them, send, log.
     *
     * Two batch-level guards live here:
     *   - usleep between successive flush_batch calls so the cumulative
     *     POST rate stays under the eMAG 3 req/sec ceiling.
     *   - cascade-timeout abort: if two consecutive batches fail with a
     *     cURL/HTTP timeout the eMAG gateway is almost certainly refusing
     *     to respond (it stonewalls instead of returning HTTP 429 once
     *     the per-second limit is crossed). Continuing the loop in that
     *     state just produces more timeouts, so the sync stops early and
     *     logs a warning. The next cron tick will retry from scratch.
     */
    private static function process_products( array $products, array $result ): array {
        $client                = new EmagClient();
        $batch                 = [];   // [ wc_product_id => payload_array ]
        $consecutive_timeouts  = 0;

        foreach ( $products as $product ) {
            $post_id = $product->get_id();

            $excluded = self::matches_exclusion_filters( $product );
            if ( null !== $excluded ) {
                Logger::skip( $post_id, 0, $excluded );
                $result['skipped']++;
                continue;
            }

            $check = self::eligibility_check( $product );

            if ( ! $check['eligible'] ) {
                Logger::skip( $post_id, 0, $check['reason'] );
                $result['skipped']++;
                continue;
            }

            $payload = OfferPayload::from_product( $product );

            if ( null === $payload ) {
                // from_product returns null for wc_stock=0
                Logger::skip( $post_id, 0, 'Stock is 0 – skipped' );
                $result['skipped']++;
                continue;
            }

            // Price guard: block if outside eMAG min/max bounds
            $price_check = self::price_guard( $post_id, $payload['sale_price'] ?? null );
            if ( ! $price_check['ok'] ) {
                PostMeta::mark_error( $post_id, $price_check['reason'] );
                Logger::fail( $post_id, 0, Logger::ACTION_PRICE, $price_check['reason'] );
                $result['failed']++;
                $result['errors'][] = "#{$post_id}: " . $price_check['reason'];
                $result['blocked_products'][] = [
                    'id'     => $post_id,
                    'name'   => $product->get_name(),
                    'reason' => $price_check['reason'],
                ];
                continue;
            }

            // Min margin guard: block if computed price is below minimum allowed margin
            $product = wc_get_product( $post_id );
            if ( $product && OfferPayload::is_below_min_margin( $product ) ) {
                $min_margin = OfferPayload::effective_min_margin( $product );
                $reason     = sprintf(
                    'Calculated price (%.2f) below minimum margin (%.2f%%) — sync blocked',
                    $payload['sale_price'] ?? 0,
                    $min_margin
                );
                PostMeta::mark_error( $post_id, $reason );
                Logger::fail( $post_id, 0, Logger::ACTION_PRICE, $reason );
                $result['failed']++;
                $result['errors'][] = "#{$post_id}: " . $reason;
                $result['blocked_products'][] = [
                    'id'     => $post_id,
                    'name'   => $product->get_name(),
                    'reason' => $reason,
                ];
                continue;
            }

            $batch[ $post_id ] = $payload;

            // Flush batch when full
            if ( count( $batch ) >= self::BATCH_SIZE ) {
                $result = self::flush_batch( $client, $batch, $result );
                $batch  = [];

                if ( ! empty( $result['_last_batch_timeout'] ) ) {
                    $consecutive_timeouts++;
                } else {
                    $consecutive_timeouts = 0;
                }
                unset( $result['_last_batch_timeout'] );

                if ( $consecutive_timeouts >= 2 ) {
                    Logger::log(
                        0, 0, Logger::ACTION_BOTH, Logger::RESULT_FAIL,
                        'Sync aborted: eMAG API appears to be rate-limiting this seller (two consecutive batches timed out). The next scheduled run will retry.'
                    );
                    $result['errors'][] = 'Sync aborted after 2 consecutive batch timeouts. eMAG API rate limit likely. Retry on next cron.';
                    return $result;
                }

                usleep( EmagClient::REQ_INTERVAL_MICROS );
            }
        }

        // Flush remainder
        if ( ! empty( $batch ) ) {
            $result = self::flush_batch( $client, $batch, $result );
            unset( $result['_last_batch_timeout'] );
        }

        return $result;
    }

    /**
     * Send one batch to eMAG, record per-product results.
     *
     * eMAG's offer/save returns results[] keyed by offer id.
     * We match them back to WC post IDs via vendor_ext_id.
     *
     * Two-call dance for price drops:
     * eMAG rejects any offer/save where the new sale_price falls below the
     * currently-stored min_sale_price. To let the seller lower a product's
     * margin, we detect "new_sale_price < stored_min_price" and issue an
     * extra upfront call that only updates min_sale_price — this re-calibrates
     * eMAG's minimum, after which the main batch goes through with the lower
     * sale_price. Both the stored min and max local caches are refreshed on
     * success so subsequent syncs see up-to-date bounds.
     */
    private static function flush_batch( EmagClient $client, array $batch, array $result ): array {
        // Drop products whose eMAG offer id is claimed by more than one
        // product (DB-wide check) before anything is sent. Without this,
        // eMAG rejects the whole batch with "Duplicate product found" when
        // both claimants land in one request — and silently lets a wrongly
        // mapped product overwrite the original offer when synced alone.
        $split = self::extract_mapping_conflicts( $batch );
        foreach ( $split['conflicts'] as $post_id => $conflict ) {
            PostMeta::mark_error( $post_id, $conflict['reason'] );
            Logger::fail( $post_id, 0, Logger::ACTION_BOTH, $conflict['reason'] );
            $result['failed']++;
            $result['errors'][] = "#{$post_id}: " . $conflict['reason'];
            $result['blocked_products'][] = [
                'id'     => $post_id,
                'name'   => get_the_title( $post_id ),
                'reason' => $conflict['reason'],
            ];
        }
        $batch = $split['clean'];
        if ( empty( $batch ) ) {
            return $result;
        }

        // Build reverse map: vendor_ext_id => wc_post_id
        $id_map          = [];
        $payloads        = [];
        $min_preflight   = [];
        $needs_preflight = [];

        foreach ( $batch as $post_id => $payload ) {
            $vendor_id          = (int) $payload['id'];
            $id_map[ $vendor_id ] = $post_id;
            $payloads[]           = $payload;

            $stored_min = PostMeta::get_min_price( $post_id );
            $new_sale   = isset( $payload['sale_price'] )     ? (float) $payload['sale_price']     : null;
            $new_min    = isset( $payload['min_sale_price'] ) ? (float) $payload['min_sale_price'] : null;

            if (
                null !== $stored_min && $stored_min > 0
                && null !== $new_sale && null !== $new_min
                && $new_sale < $stored_min
            ) {
                $min_preflight[] = [
                    'id'              => $vendor_id,
                    'min_sale_price'  => $new_min,
                ];
                $needs_preflight[ $post_id ] = $new_min;
            }
        }

        // Preflight: lower eMAG's stored minimum before pushing the new sale_price.
        if ( ! empty( $min_preflight ) ) {
            $pre_response = $client->save_offers( $min_preflight );
            if ( $pre_response['success'] ) {
                // Update local cache so the guard and UI reflect the new minimum.
                foreach ( $needs_preflight as $post_id => $new_min ) {
                    update_post_meta( $post_id, PostMeta::MIN_PRICE, $new_min );
                    Logger::success( $post_id, 0, Logger::ACTION_PRICE,
                        sprintf( 'min_sale_price lowered to %.2f (preflight)', $new_min )
                    );
                }
            } else {
                // Log and continue — the main call will likely fail too and report the root cause.
                Logger::log( 0, 0, Logger::ACTION_PRICE, Logger::RESULT_FAIL,
                    'min_sale_price preflight failed: ' . $pre_response['error']
                );
            }
        }

        $response = $client->save_offers( $payloads );

        if ( ! $response['success'] ) {
            // Whole batch failed
            foreach ( $batch as $post_id => $payload ) {
                PostMeta::mark_error( $post_id, $response['error'] );
                Logger::fail( $post_id, 0, Logger::ACTION_BOTH, $response['error'] );
                $result['failed']++;
            }
            $result['errors'][] = 'Batch failed: ' . $response['error'];
            // Signal to process_products() that this failure was a timeout —
            // it tracks consecutive timeouts and aborts the sync after two
            // in a row. Key is private to that handshake and cleared after.
            $result['_last_batch_timeout'] = EmagClient::is_timeout_error( $response['error'] );
            return $result;
        }

        $result['_last_batch_timeout'] = false;

        // eMAG returns results per offer. Inspect per-item errors if present.
        $api_results = (array) ( $response['data'] ?? [] );

        // Build a set of vendor IDs that had per-item errors
        $item_errors = [];
        foreach ( $api_results as $item ) {
            if ( ! empty( $item['id'] ) && ! empty( $item['isError'] ) ) {
                $msg = '';
                if ( ! empty( $item['messages'] ) && is_array( $item['messages'] ) ) {
                    $texts = [];
                    foreach ( $item['messages'] as $m ) {
                        if ( is_string( $m ) ) {
                            $texts[] = $m;
                        } elseif ( is_array( $m ) && isset( $m['text'] ) ) {
                            $texts[] = $m['text'];
                        }
                    }
                    $msg = implode( '; ', array_filter( $texts ) );
                }
                $item_errors[ (int) $item['id'] ] = $msg ?: 'eMAG rejected this offer';
            }
        }

        // Mark each product in the batch
        foreach ( $batch as $post_id => $payload ) {
            $vendor_id = (int) $payload['id'];

            if ( isset( $item_errors[ $vendor_id ] ) ) {
                PostMeta::mark_error( $post_id, $item_errors[ $vendor_id ] );
                Logger::fail( $post_id, 0, Logger::ACTION_BOTH, $item_errors[ $vendor_id ] );
                $result['failed']++;
                $result['errors'][] = "vendor#{$vendor_id}: " . $item_errors[ $vendor_id ];
            } else {
                PostMeta::mark_success( $post_id );
                // Keep local min/max cache in sync with what we just sent.
                if ( isset( $payload['min_sale_price'] ) ) {
                    update_post_meta( $post_id, PostMeta::MIN_PRICE, (float) $payload['min_sale_price'] );
                }
                if ( isset( $payload['max_sale_price'] ) ) {
                    update_post_meta( $post_id, PostMeta::MAX_PRICE, (float) $payload['max_sale_price'] );
                }
                Logger::success( $post_id, 0, Logger::ACTION_BOTH,
                    "vendor_ext_id={$vendor_id} price={$payload['sale_price']} stock={$payload['stock'][0]['value']}" );
                $result['synced']++;
            }
        }

        return $result;
    }

    // ── Duplicate-mapping guard ───────────────────────────────────────────

    /**
     * Split a payload batch into clean entries and conflicting ones.
     *
     * A conflict means the payload's vendor_ext_id is claimed by more than
     * one product anywhere in the database (not just inside this batch).
     * The product whose WC SKU matches the stored eMAG part_number keeps
     * the mapping and stays in the batch; every other claimant is returned
     * under 'conflicts' with a ready-to-log reason. When no claimant's SKU
     * matches, all claimants present in the batch are treated as conflicts
     * (no guessing).
     *
     * @param array $batch [ wc_post_id => payload ]
     * @return array{clean: array, conflicts: array<int, array{vendor_id:int, others:int[], reason:string}>}
     */
    public static function extract_mapping_conflicts( array $batch ): array {
        global $wpdb;

        $split = [ 'clean' => $batch, 'conflicts' => [] ];
        if ( empty( $batch ) ) {
            return $split;
        }

        $vendor_ids = [];
        foreach ( $batch as $payload ) {
            $vendor_ids[] = (int) $payload['id'];
        }
        $vendor_ids = array_values( array_unique( $vendor_ids ) );

        $placeholders = implode( ',', array_fill( 0, count( $vendor_ids ), '%d' ) );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a list of %d.
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT pm.post_id, pm.meta_value AS vendor_id
             FROM {$wpdb->postmeta} pm
             INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key = %s
               AND p.post_status NOT IN ('trash','auto-draft')
               AND pm.meta_value IN ($placeholders)",
            array_merge( [ PostMeta::VENDOR_EXT_ID ], $vendor_ids )
        ), ARRAY_A );

        // vendor_id => [post_id, ...] across the whole database.
        $claims = [];
        foreach ( (array) $rows as $row ) {
            $claims[ (int) $row['vendor_id'] ][] = (int) $row['post_id'];
        }

        foreach ( $batch as $post_id => $payload ) {
            $vendor_id = (int) $payload['id'];
            $claimants = $claims[ $vendor_id ] ?? [ $post_id ];
            if ( count( $claimants ) < 2 ) {
                continue; // unique mapping
            }

            $keeper = self::resolve_mapping_keeper( $claimants );
            if ( $keeper === $post_id ) {
                continue; // authoritative product stays in the batch
            }

            $others = array_values( array_diff( $claimants, [ $post_id ] ) );
            $split['conflicts'][ $post_id ] = [
                'vendor_id' => $vendor_id,
                'others'    => $others,
                'reason'    => sprintf(
                    'Duplicate eMAG mapping: offer id %1$d is also claimed by product(s) #%2$s. Sync blocked for this product to protect the eMAG offer. Use "Unlink from eMAG" on the wrongly mapped product (Products page).',
                    $vendor_id,
                    implode( ', #', $others )
                ),
            ];
            unset( $split['clean'][ $post_id ] );
        }

        return $split;
    }

    /**
     * Among products claiming the same offer id, pick the one whose WC SKU
     * equals the stored eMAG part_number — that product was mapped by
     * bootstrap/publish; the others inherited the meta (e.g. via product
     * duplication). Returns null when zero or several claimants match
     * (ambiguous — callers must not guess).
     *
     * @param int[] $claimant_post_ids
     */
    public static function resolve_mapping_keeper( array $claimant_post_ids ): ?int {
        $matches = [];
        foreach ( $claimant_post_ids as $pid ) {
            $pid        = (int) $pid;
            $sku        = (string) get_post_meta( $pid, '_sku', true );
            $identifier = (string) get_post_meta( $pid, PostMeta::EMAG_IDENTIFIER, true );
            if ( '' !== $sku && '' !== $identifier && $sku === $identifier ) {
                $matches[] = $pid;
            }
        }
        return 1 === count( $matches ) ? $matches[0] : null;
    }

    // ── Product collection ────────────────────────────────────────────────

    /**
     * Get all WC products/variations that have sync enabled.
     * Variable parents are excluded (we sync each variation individually).
     *
     * No count limit. The plugin sells on Premium-only feature differentiation
     * (publish, order import, AWB shipping labels, Buy Button tracking,
     * notifications), not on a synced-product cap. Free users sync as many
     * products as they want; the WP.org Plugin Directory guidelines forbid
     * locking baseline functionality behind paywalls.
     *
     * @return \WC_Product[]
     */
    public static function get_eligible_products(): array {
        global $wpdb;

        // Find all post IDs that have _skyemag_sync_enabled = 1.
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->posts and $wpdb->postmeta are core table names; no user input reaches the SQL
        $ids = $wpdb->get_col(
            "SELECT p.ID
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm
                 ON pm.post_id = p.ID
                 AND pm.meta_key = '_skyemag_sync_enabled'
                 AND pm.meta_value = '1'
             WHERE p.post_type IN ('product','product_variation')
               AND p.post_status IN ('publish','private')
             ORDER BY p.ID ASC"
        );

        $products = [];
        foreach ( $ids as $id ) {
            $product = wc_get_product( (int) $id );
            if ( $product && ! $product->is_type( 'variable' ) ) {
                $products[] = $product;
            }
        }

        return $products;
    }

    // ── Eligibility check ─────────────────────────────────────────────────

    /**
     * Stock/SKU/weight/price-based sync exclusions configured in Settings.
     * Returns null when the product is allowed, or a human-readable skip
     * reason otherwise. Kept separate from eligibility_check so users can
     * see in the logs exactly which filter dropped the product.
     */
    public static function matches_exclusion_filters( \WC_Product $product ): ?string {
        // Skip zero-stock sync (hard filter, stronger than the min_wc_stock buffer).
        if ( Options::sync_skip_zero_stock() ) {
            $stock = $product->get_stock_quantity();
            if ( null !== $stock && (int) $stock <= 0 ) {
                return 'Excluded: zero stock filter is on';
            }
        }

        // SKU pattern (regex).
        $pattern = Options::sync_skip_sku_pattern();
        if ( '' !== $pattern ) {
            $sku   = (string) $product->get_sku();
            $regex = '/' . str_replace( '/', '\/', $pattern ) . '/';
            if ( '' !== $sku && @preg_match( $regex, $sku ) === 1 ) {
                return sprintf( "Excluded: SKU '%s' matches skip pattern", $sku );
            }
        }

        // Max weight (kg).
        $max_weight = Options::sync_max_weight_kg();
        if ( $max_weight > 0 ) {
            $weight = (float) $product->get_weight();
            if ( $weight > $max_weight ) {
                return sprintf( 'Excluded: weight %.2f kg exceeds max %.2f kg', $weight, $max_weight );
            }
        }

        // Minimum price ex VAT.
        $min_price = Options::sync_min_price_ex_vat();
        if ( $min_price > 0 ) {
            $source = OfferPayload::source_price( $product );
            if ( null === $source || $source < $min_price ) {
                return sprintf( 'Excluded: price %.2f below minimum %.2f (ex VAT)', (float) $source, $min_price );
            }
        }

        return null;
    }

    /**
     * Check whether a product passes all eligibility rules.
     *
     * @return array [ eligible => bool, reason => string ]
     */
    public static function eligibility_check( \WC_Product $product ): array {
        $post_id = $product->get_id();

        // Must have been mapped in bootstrap
        if ( null === PostMeta::get_vendor_ext_id( $post_id ) ) {
            return [ 'eligible' => false, 'reason' => 'No eMAG vendor_ext_id – run bootstrap first' ];
        }

        // Respect eMAG-side inactive status (status=0 means seller deactivated it there)
        $emag_status = (int) get_post_meta( $post_id, PostMeta::EMAG_STATUS, true );
        if ( $emag_status === 0 ) {
            return [ 'eligible' => false, 'reason' => 'Offer is inactive on eMAG (status=0)' ];
        }

        // WC product must be published
        $status = get_post_status( $post_id );
        if ( ! in_array( $status, [ 'publish', 'private' ], true ) ) {
            return [ 'eligible' => false, 'reason' => "WC post status is '{$status}'" ];
        }

        return [ 'eligible' => true, 'reason' => '' ];
    }

    // ── Price guard ───────────────────────────────────────────────────────

    /**
     * Verify computed price is within eMAG-set max bound. The minimum bound
     * is intentionally NOT checked here: every payload now ships its own
     * min_sale_price derived from the user's min_margin, and flush_batch()
     * handles the two-call dance eMAG requires when lowering sale_price
     * below the stored minimum. A local block here would prevent legitimate
     * margin reductions.
     *
     * Null price (no WC price set) passes – payload just won't include sale_price.
     *
     * @return array [ ok => bool, reason => string ]
     */
    public static function price_guard( int $post_id, ?float $price ): array {
        if ( null === $price ) {
            return [ 'ok' => true, 'reason' => '' ];
        }

        $max = PostMeta::get_max_price( $post_id );
        if ( null !== $max && $max > 0 && $price > $max ) {
            return [ 'ok' => false, 'reason' => "Price {$price} exceeds eMAG maximum {$max}" ];
        }

        return [ 'ok' => true, 'reason' => '' ];
    }

    // ── WC hook triggers ──────────────────────────────────────────────────

    /**
     * Register WooCommerce hooks to trigger sync on price/stock changes.
     * Called from Plugin::boot().
     */
    public static function register_hooks(): void {
        // Stock change
        add_action( 'woocommerce_product_set_stock',           [ __CLASS__, 'on_stock_change' ] );
        add_action( 'woocommerce_variation_set_stock',         [ __CLASS__, 'on_stock_change' ] );

        // Price change (product saved)
        add_action( 'woocommerce_update_product',              [ __CLASS__, 'on_product_save' ], 20 );
        add_action( 'woocommerce_update_product_variation',    [ __CLASS__, 'on_product_save' ], 20 );
    }

    public static function on_stock_change( \WC_Product $product ): void {
        if ( ! Options::is_master_enabled() ) {
            return;
        }
        $post_id = $product->get_id();
        if ( ! PostMeta::is_sync_enabled( $post_id ) ) {
            return;
        }
        // Run async via WP-Cron to avoid blocking the request
        wp_schedule_single_event( time(), 'skyemag_sync_single', [ $post_id ] );
    }

    public static function on_product_save( int $post_id ): void {
        if ( ! Options::is_master_enabled() ) {
            return;
        }
        if ( ! PostMeta::is_sync_enabled( $post_id ) ) {
            return;
        }
        wp_schedule_single_event( time(), 'skyemag_sync_single', [ $post_id ] );
    }

    /**
     * Handle the skyemag_sync_single cron event.
     */
    public static function handle_single_sync( int $post_id ): void {
        $product = wc_get_product( $post_id );
        if ( ! $product ) {
            return;
        }
        self::run_sync_for_ids( [ $post_id ] );
    }

    // ── Lock ─────────────────────────────────────────────────────────────

    public static function acquire_lock(): bool {
        if ( get_transient( self::LOCK_TRANSIENT ) ) {
            return false;
        }
        set_transient( self::LOCK_TRANSIENT, 1, self::LOCK_EXPIRY );
        return true;
    }

    public static function release_lock(): void {
        delete_transient( self::LOCK_TRANSIENT );
    }

    // ── Result helpers ────────────────────────────────────────────────────

    /**
     * Fire the sync-level notification events (sync_errors, product_blocked).
     * Safe no-op when notifications are disabled or the install is not
     * premium. The Free build excludes the Notifications namespace via the
     * Freemius build pipeline; class_exists() guards keep the Free sync
     * loop running cleanly without those classes.
     */
    public static function dispatch_post_sync_notifications( string $trigger, array $result ): void {
        if ( ! class_exists( Dispatcher::class ) || ! class_exists( Events::class ) ) {
            return;
        }

        if ( $result['failed'] > 0 ) {
            Dispatcher::notify( Events::SYNC_ERRORS, [
                'trigger' => $trigger,
                'synced'  => (int) $result['synced'],
                'skipped' => (int) $result['skipped'],
                'failed'  => (int) $result['failed'],
                'errors'  => (array) $result['errors'],
            ] );
        }

        if ( ! empty( $result['blocked_products'] ) ) {
            Dispatcher::notify( Events::PRODUCT_BLOCKED, [
                'products' => (array) $result['blocked_products'],
            ] );
        }
    }

    private static function empty_result(): array {
        return [
            'success'          => false,
            'synced'           => 0,
            'skipped'          => 0,
            'failed'           => 0,
            'errors'           => [],
            'blocked_products' => [],
        ];
    }

    private static function locked_result(): array {
        return [
            'success'          => false,
            'synced'           => 0,
            'skipped'          => 0,
            'failed'           => 0,
            'errors'           => [ 'Sync already running – try again in a moment.' ],
            'blocked_products' => [],
        ];
    }
}
