<?php

namespace auctionforge\inc\sync;

use auctionforge\inc\import\LotList;
use auctionforge\inc\import\AuctionList;
use auctionforge\inc\import\InsertLotsBS;
use auctionforge\inc\model\Auction;
use auctionforge\inc\Debug;
use auctionforge\inc\helpers\SettingsHelper;

class CatalogSyncWorker
{
    /**
     * Process a catalog_sync or full_sync job.
     * Reuses the existing LotList::singleRun() which fetches from BidSpirit API
     * and creates/updates WP posts directly — bypassing the staging table entirely.
     *
     * @param object $job  The queue job row from af_sync_queue
     * @return array  ['lots_processed' => int, 'errors' => array]
     */
    public static function process($job)
    {
        $auctionId = $job->auction_id;
        $startTime = microtime(true);
        $result = ['lots_processed' => 0, 'errors' => []];

        SyncLogger::log('worker', "▶ Starting {$job->job_type} for auction {$auctionId}", 'info', $auctionId, $job->id);

        // Serialize catalog work per auction. Two catalog_sync jobs for the same
        // auction running concurrently (e.g. two BidSpirit webhooks landing in the
        // same second) each insert a full set of lot posts before the other commits,
        // producing duplicate "-2" lots. Skip if another worker already holds the lock.
        $lockName = self::acquireAuctionLock($auctionId);
        if ($lockName === false) {
            SyncLogger::log('worker', "⏭ Skipped {$job->job_type} for auction {$auctionId} — another sync is already in progress (concurrent-lock busy)", 'warning', $auctionId, $job->id);
            return $result; // empty errors => job marked completed; the in-flight sync covers this work
        }

        try {
            // Stage 1: For full_sync, refresh auction list first
            if ($job->job_type === SyncQueue::TYPE_FULL_SYNC) {
                SyncLogger::log('worker', "  [1/5] Refreshing auction list from BidSpirit API...", 'info', $auctionId, $job->id);
                $auctionList = new AuctionList();
                $auctionList->init();

                $alDuration = round(microtime(true) - $startTime, 1);
                if (!empty($auctionList->getErrors())) {
                    $errors = $auctionList->getErrors();
                    SyncLogger::log('worker', "  [1/5] Auction list completed with warnings ({$alDuration}s): " . wp_json_encode($errors), 'warning', $auctionId, $job->id);
                } else {
                    SyncLogger::log('worker', "  [1/5] Auction list refreshed ({$alDuration}s)", 'info', $auctionId, $job->id);
                }
            } else {
                SyncLogger::log('worker', "  [1/5] Skipped auction list refresh (catalog_sync mode)", 'info', $auctionId, $job->id);
            }

            // Stage 2: Resolve auction term
            SyncLogger::log('worker', "  [2/5] Resolving WP term for auction {$auctionId}...", 'info', $auctionId, $job->id);
            $defaultLang = false;
            if (\auctionforge\inc\controllers\PolylangSupport::isPolylangActive() &&
                \auctionforge\inc\controllers\PolylangSupport::isFullSupport()) {
                $defaultLang = \auctionforge\inc\controllers\MultiLanguageSupport::getDefaultLangCode();
            }
            $term = Auction::findAuctionById($auctionId, $defaultLang);
            if ($term instanceof \WP_Term) {
                SyncLogger::log('worker', "  [2/5] Found term #{$term->term_id}: {$term->name}", 'info', $auctionId, $job->id);
            } else {
                SyncLogger::log('worker', "  [2/5] No WP term found for auction {$auctionId} — will be created during sync", 'warning', $auctionId, $job->id);
            }

            // Stage 3: Fetch lots from BidSpirit API + save to WP
            SyncLogger::log('worker', "  [3/5] Fetching lots from BidSpirit API and saving to WordPress...", 'info', $auctionId, $job->id);
            SyncQueue::heartbeat($job->id); // Keep heartbeat alive during long API call
            $apiStart = microtime(true);

            $lotList = new LotList();
            $lotList->setPriority(2);
            $lotList->singleRun($auctionId);

            $apiDuration = round(microtime(true) - $apiStart, 1);

            // Stage 4: Check results
            if (!empty($lotList->getErrors())) {
                $result['errors'] = $lotList->getErrors();
                SyncLogger::log('worker', "  [3/5] API + save completed with errors ({$apiDuration}s): " . wp_json_encode($result['errors']), 'warning', $auctionId, $job->id);
            }

            $loggedInfo = $lotList->getLoggedInfo();
            if (!empty($loggedInfo)) {
                foreach ($loggedInfo as $key => $info) {
                    if (isset($info['new_records'])) {
                        $result['lots_processed'] = (int) $info['new_records'];
                    }
                    if (isset($info['items/getAuctionItems'])) {
                        SyncLogger::log('worker', "  [3/5] API response: " . wp_json_encode($info['items/getAuctionItems']), 'info', $auctionId, $job->id);
                    }
                }
            }

            SyncLogger::log('worker', "  [4/6] Staged {$result['lots_processed']} lots ({$apiDuration}s)", 'info', $auctionId, $job->id);

            // If API returned 0 lots and no errors, the catalog likely isn't ready yet — fail so next webhook can retry
            if ($result['lots_processed'] === 0 && empty($result['errors']) && $job->job_type !== SyncQueue::TYPE_ARCHIVE_SYNC) {
                $result['errors'][] = "BidSpirit API returned 0 lots — catalog may not be ready yet";
                SyncLogger::log('worker', "⚠ 0 lots staged from API — marking as failed for retry", 'warning', $auctionId, $job->id);
                return $result;
            }

            // Stage 5: Process staging table → WP posts + bp_meta_data
            // Ensure autocommit is restored — LotList::prepareResponse() may leave it off
            $db = \auctionforge\inc\InstanceDB::getInstanceDB();
            $db->query('COMMIT;');
            $db->query('SET autocommit = 1;');

            // Suppress heavyweight plugin hooks that make external HTTP calls
            // during bulk post insertion (SmartCrawl SEO, Smush Pro, etc.)
            remove_all_actions('wds_post_update');
            remove_all_actions('smartcrawl_after_post_update');
            remove_all_actions('wp_smush_image_optimised');
            remove_all_actions('delete_attachment');
            add_filter('pre_http_request', function ($preempt, $args, $url) {
                $host = wp_parse_url($url, PHP_URL_HOST);
                if (!$host) return $preempt;
                // Allow localhost, BidSpirit API, and BidSpirit image CDNs
                $allowed = ['127.0.0.1', 'localhost', 'bidspirit.com', 'www.bidspirit.com'];
                if (in_array($host, $allowed, true)) return $preempt;
                // Allow any subdomain of bidspirit.com and known CDN hosts
                if (preg_match('/\.bidspirit\.com$/', $host)) return $preempt;
                if (str_contains($host, 'fastly.net') || str_contains($host, 'cloudfront.net')) return $preempt;
                return new \WP_Error('http_blocked_during_sync', "Blocked external HTTP to {$host} during sync");
            }, 1, 3);

            SyncLogger::log('worker', "  [5/6] Processing staged data → WP posts + meta...", 'info', $auctionId, $job->id);
            $insertStart = microtime(true);
            $totalInserted = 0;
            $batchSize = 50;
            $maxPasses = 100;
            $pass = 0;

            $insertTimeout = 180; // max seconds for stage 5
            while ($pass < $maxPasses) {
                $pass++;
                $batchStart = microtime(true);
                $insertLots = new InsertLotsBS();
                $insertLots->init(['limit' => $batchSize]);
                $insertInfo = $insertLots->getLoggedInfo();

                // loggedInfo is flat: {"unupdated lots": N, "updated lots": M}
                $batchCount = isset($insertInfo['updated lots']) ? (int) $insertInfo['updated lots'] : 0;
                $skippedCount = isset($insertInfo['unupdated lots']) ? (int) $insertInfo['unupdated lots'] : 0;
                $totalInserted += $batchCount;
                $batchDuration = round(microtime(true) - $batchStart, 1);

                // Break only when the staging queue is truly empty.
                // If unupdated lots were cleared, more batches may contain new lots.
                if ($batchCount === 0 && $skippedCount === 0) break;

                if ($pass % 2 === 0 || $batchCount > 0) {
                    SyncLogger::log('worker', "  [5/6] ... {$totalInserted} posts processed so far (pass {$pass}, {$skippedCount} unchanged skipped, {$batchDuration}s)", 'info', $auctionId, $job->id);
                    SyncQueue::heartbeat($job->id); // Keep heartbeat alive during long insert loop
                }

                // Guard against infinite hang — bail if stage 5 exceeds timeout
                if ((microtime(true) - $insertStart) > $insertTimeout) {
                    SyncLogger::log('worker', "⚠ [5/6] Insert timeout ({$insertTimeout}s) reached after {$totalInserted} posts in {$pass} passes", 'warning', $auctionId, $job->id);
                    break;
                }
            }

            $insertDuration = round(microtime(true) - $insertStart, 1);
            SyncLogger::log('worker', "  [5/6] Created/updated {$totalInserted} WP posts + meta in {$pass} passes ({$insertDuration}s)", 'info', $auctionId, $job->id);

            // Stage 6: Clear cache
            SyncLogger::log('worker', "  [6/6] Clearing page cache...", 'info', $auctionId, $job->id);
            Auction::doClearCacheForAuctionPages();

            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✓ Catalog sync complete for auction {$auctionId}: {$result['lots_processed']} lots staged, {$totalInserted} posts saved in {$duration}s", 'success', $auctionId, $job->id);

        } catch (\Exception $e) {
            $result['errors'][] = $e->getMessage();
            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✗ Exception after {$duration}s: " . $e->getMessage(), 'error', $auctionId, $job->id);
            Debug::errorLog("CatalogSyncWorker exception for auction {$auctionId}: " . $e->getMessage());
        } catch (\Throwable $t) {
            $result['errors'][] = $t->getMessage();
            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✗ Fatal after {$duration}s: " . $t->getMessage(), 'error', $auctionId, $job->id);
            Debug::errorLog("CatalogSyncWorker fatal for auction {$auctionId}: " . $t->getMessage());
        } finally {
            self::releaseAuctionLock($lockName);
        }

        return $result;
    }

    /**
     * Process a day_ended job.
     * Updates auction info + re-syncs the catalog to get final prices.
     *
     * @param object $job  The queue job row
     * @return array
     */
    public static function processDayEnded($job)
    {
        $auctionId = $job->auction_id;
        $startTime = microtime(true);
        $result = ['lots_processed' => 0, 'errors' => []];

        SyncLogger::log('worker', "▶ Starting day_ended for auction {$auctionId}", 'info', $auctionId, $job->id);

        // Serialize catalog work per auction (see process() — prevents duplicate lots).
        $lockName = self::acquireAuctionLock($auctionId);
        if ($lockName === false) {
            SyncLogger::log('worker', "⏭ Skipped day_ended for auction {$auctionId} — another sync is already in progress (concurrent-lock busy)", 'warning', $auctionId, $job->id);
            return $result;
        }

        try {
            // Stage 1: Update primary language auction info
            $langCode = SettingsHelper::getOption("lang", 'en');
            SyncLogger::log('worker', "  [1/4] Updating auction info (lang: {$langCode})...", 'info', $auctionId, $job->id);
            $term = \auctionforge\inc\helpers\BS_API::findAuctionByIdWithLang($auctionId, $langCode);
            Auction::updateAuctionInfo($term, $auctionId, $langCode);
            SyncLogger::log('worker', "  [1/4] Primary language auction info updated", 'info', $auctionId, $job->id);

            // Stage 2: Handle multilanguage
            if (\auctionforge\inc\controllers\PolylangSupport::isPolylangActive() &&
                \auctionforge\inc\controllers\PolylangSupport::isFullSupport()) {
                SyncLogger::log('worker', "  [2/4] Updating multilanguage auction info...", 'info', $auctionId, $job->id);
                $secondLangCode = \auctionforge\inc\controllers\MultiLanguageSupport::getSecondLangCode();
                $term = \auctionforge\inc\helpers\BS_API::findAuctionByIdWithLang($auctionId, $secondLangCode);
                Auction::updateAuctionInfo($term, $auctionId, $secondLangCode);

                $otherLanguages = \auctionforge\inc\controllers\PolylangSupport::getSupportedLanguages();
                if (!empty($otherLanguages['other'])) {
                    foreach ($otherLanguages['other'] as $otherLanguage) {
                        $term = \auctionforge\inc\helpers\BS_API::findAuctionByIdWithLang($auctionId, $otherLanguage);
                        Auction::updateAuctionInfo($term, $auctionId, $otherLanguage);
                    }
                }
                SyncLogger::log('worker', "  [2/4] Multilanguage info updated", 'info', $auctionId, $job->id);
            } else {
                SyncLogger::log('worker', "  [2/4] Single language — skipped multilanguage", 'info', $auctionId, $job->id);
            }

            // Stage 3: Re-sync catalog to get final prices
            SyncLogger::log('worker', "  [3/4] Re-syncing catalog for final prices...", 'info', $auctionId, $job->id);
            $apiStart = microtime(true);
            $lotList = new LotList();
            $lotList->setPriority(2);
            $lotList->singleRun($auctionId);
            $apiDuration = round(microtime(true) - $apiStart, 1);

            $loggedInfo = $lotList->getLoggedInfo();
            if (!empty($loggedInfo)) {
                foreach ($loggedInfo as $info) {
                    if (isset($info['new_records'])) {
                        $result['lots_processed'] = (int) $info['new_records'];
                    }
                }
            }
            SyncLogger::log('worker', "  [3/5] Catalog re-synced: {$result['lots_processed']} lots staged ({$apiDuration}s)", 'info', $auctionId, $job->id);

            // Stage 4: Process staging table → WP posts + bp_meta_data
            $db = \auctionforge\inc\InstanceDB::getInstanceDB();
            $db->query('COMMIT;');
            $db->query('SET autocommit = 1;');

            // Suppress heavyweight plugin hooks that make external HTTP calls
            remove_all_actions('wds_post_update');
            remove_all_actions('smartcrawl_after_post_update');
            remove_all_actions('wp_smush_image_optimised');
            remove_all_actions('delete_attachment');
            add_filter('pre_http_request', function ($preempt, $args, $url) {
                $host = wp_parse_url($url, PHP_URL_HOST);
                if (!$host) return $preempt;
                $allowed = ['127.0.0.1', 'localhost', 'bidspirit.com', 'www.bidspirit.com'];
                if (in_array($host, $allowed, true)) return $preempt;
                if (preg_match('/\.bidspirit\.com$/', $host)) return $preempt;
                if (str_contains($host, 'fastly.net') || str_contains($host, 'cloudfront.net')) return $preempt;
                return new \WP_Error('http_blocked_during_sync', "Blocked external HTTP to {$host} during sync");
            }, 1, 3);

            SyncLogger::log('worker', "  [4/5] Processing staged data → WP posts + meta...", 'info', $auctionId, $job->id);
            $insertStart = microtime(true);
            $totalInserted = 0;
            $pass = 0;
            $insertTimeout = 180;
            while ($pass < 100) {
                $pass++;
                $batchStart = microtime(true);
                $insertLots = new InsertLotsBS();
                $insertLots->init(['limit' => 50]);
                $insertInfo = $insertLots->getLoggedInfo();
                $batchCount = isset($insertInfo['updated lots']) ? (int) $insertInfo['updated lots'] : 0;
                $skippedCount = isset($insertInfo['unupdated lots']) ? (int) $insertInfo['unupdated lots'] : 0;
                $totalInserted += $batchCount;
                $batchDuration = round(microtime(true) - $batchStart, 1);
                if ($batchCount === 0 && $skippedCount === 0) break;
                if ($pass % 2 === 0 || $batchCount > 0) {
                    SyncLogger::log('worker', "  [4/5] ... {$totalInserted} posts processed so far (pass {$pass}, {$skippedCount} unchanged skipped, {$batchDuration}s)", 'info', $auctionId, $job->id);
                    SyncQueue::heartbeat($job->id);
                }
                if ((microtime(true) - $insertStart) > $insertTimeout) {
                    SyncLogger::log('worker', "⚠ [4/5] Insert timeout ({$insertTimeout}s) reached after {$totalInserted} posts in {$pass} passes", 'warning', $auctionId, $job->id);
                    break;
                }
            }
            $insertDuration = round(microtime(true) - $insertStart, 1);
            SyncLogger::log('worker', "  [4/5] Created/updated {$totalInserted} posts + meta ({$insertDuration}s)", 'info', $auctionId, $job->id);

            // Stage 5: Clear cache
            SyncLogger::log('worker', "  [5/5] Clearing page cache...", 'info', $auctionId, $job->id);
            Auction::doClearCacheForAuctionPages();

            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✓ Day ended complete for auction {$auctionId}: {$result['lots_processed']} lots in {$duration}s", 'success', $auctionId, $job->id);

        } catch (\Exception $e) {
            $result['errors'][] = $e->getMessage();
            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✗ Exception in day_ended after {$duration}s: " . $e->getMessage(), 'error', $auctionId, $job->id);
        } catch (\Throwable $t) {
            $result['errors'][] = $t->getMessage();
            $duration = round(microtime(true) - $startTime, 2);
            SyncLogger::log('worker', "✗ Fatal in day_ended after {$duration}s: " . $t->getMessage(), 'error', $auctionId, $job->id);
        } finally {
            self::releaseAuctionLock($lockName);
        }

        return $result;
    }

    /**
     * Acquire a per-auction MySQL advisory lock so two workers can't process the
     * same auction's catalog concurrently (which races and creates duplicate lots).
     * The lock is held on the current DB connection and auto-released if the worker
     * process/connection dies, so it can never deadlock the queue.
     *
     * @param string|int $auctionId
     * @return string|false|null  lock name if acquired; false if busy (caller should skip);
     *                            null on lock error (caller should proceed without the lock)
     */
    private static function acquireAuctionLock($auctionId)
    {
        $db = \auctionforge\inc\InstanceDB::getInstanceDB();
        $lockName = $db->prefix . 'af_sync_' . $auctionId;
        $got = $db->get_var($db->prepare("SELECT GET_LOCK(%s, %d)", $lockName, 0));
        if ($got === null) {
            // GET_LOCK error (rare) — don't block syncing entirely; proceed unlocked.
            return null;
        }
        return ((string) $got === '1') ? $lockName : false;
    }

    /**
     * Release a lock acquired via acquireAuctionLock(). No-op when not held (null).
     *
     * @param string|null $lockName
     */
    private static function releaseAuctionLock($lockName)
    {
        if (empty($lockName)) {
            return;
        }
        $db = \auctionforge\inc\InstanceDB::getInstanceDB();
        $db->query($db->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
    }
}
