<?php

namespace Modules\ProductCompat\Entities\Services;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\ProductCompat\Entities\PcSearchQueue;

/**
 * The persistent product search queue. Products land here (create → auto,
 * edit → opt-in, plus manual/backfill), and the worker claims batches, runs the
 * aggressive enrichment, and lands everything review-first. Built so the UI
 * chunk-runner and a cron worker can run concurrently without double-processing
 * (atomic claimBatch + releaseStuck).
 */
class SearchQueueService
{
    /**
     * Enqueue (or re-enqueue) a product. One live row per (business, product):
     * a row that is already queued or processing is left untouched; anything
     * else is reset back to queued (attempts cleared) so a done/failed product
     * can be re-checked.
     */
    public function enqueue(int $businessId, int $productId, string $reason = 'manual', int $priority = 0, ?int $userId = null): PcSearchQueue
    {
        $existing = PcSearchQueue::where('business_id', $businessId)
            ->where('product_id', $productId)
            ->first();

        if ($existing && in_array($existing->status, ['queued', 'processing'], true)) {
            // Already waiting / running — don't disturb it, but let a higher
            // priority (e.g. an explicit edit) bump an idle queued row.
            if ($existing->status === 'queued' && $priority > (int) $existing->priority) {
                $existing->update(['priority' => $priority, 'reason' => $reason]);
            }

            return $existing;
        }

        return PcSearchQueue::updateOrCreate(
            ['business_id' => $businessId, 'product_id' => $productId],
            [
                'reason'                => $reason,
                'status'                => 'queued',
                'priority'              => $priority,
                'attempts'              => 0,
                'last_error'            => null,
                'queued_by'             => $userId,
                'processing_started_at' => null,
                'processed_at'          => null,
                'result_summary'        => null,
            ]
        );
    }

    /**
     * Atomically claim up to $limit queued rows for a business and flip them to
     * processing. Uses a transaction + lockForUpdate so a concurrent UI chunk
     * and cron worker never grab the same row. Highest priority first, then
     * oldest id.
     *
     * @return \Illuminate\Support\Collection<int, PcSearchQueue>
     */
    public function claimBatch(int $businessId, int $limit = 10)
    {
        return DB::transaction(function () use ($businessId, $limit) {
            $rows = PcSearchQueue::where('business_id', $businessId)
                ->where('status', 'queued')
                ->orderByDesc('priority')
                ->orderBy('id')
                ->limit($limit)
                ->lockForUpdate()
                ->get();

            $now = Carbon::now();
            foreach ($rows as $row) {
                $row->update([
                    'status'                => 'processing',
                    'processing_started_at' => $now,
                ]);
            }

            return $rows;
        });
    }

    /**
     * Process a single claimed entry through the aggressive enrichment service.
     * Success → done + summary. Failure → retry (back to queued) until attempts
     * reach the configured max, then failed. A budget_exceeded error is special:
     * the entry is put back to queued WITHOUT burning an attempt, and a halt
     * flag is returned so the caller can stop the whole batch.
     *
     * @return array{status:string, halt:bool, summary?:array, error?:string}
     */
    public function processEntry(PcSearchQueue $entry, AggressiveEnrichmentService $service): array
    {
        $entry->increment('attempts');
        $entry->refresh();

        $runId = 'aiq_' . $entry->id . '_' . now()->format('YmdHis');

        try {
            $summary = $service->run($entry->product, $runId);

            // The AI pass surfaces a budget/rate-limit stop inside the run summary.
            // Treat it as a soft halt, not a failure: put the entry back to queued
            // UNtouched (attempt un-burned) and signal the drain to pause so it
            // resumes once the limit window resets — instead of finalizing every
            // product without AI data while the provider is throttling us.
            $aiError = (string) ($summary['ai_error'] ?? ($summary['ai'] ?? ''));
            if ($this->isRateLimited($aiError)) {
                $entry->update([
                    'status'                => 'queued',
                    'processing_started_at' => null,
                ]);
                $entry->decrement('attempts');

                return ['status' => 'halted', 'halt' => true, 'error' => 'ai_rate_limited: ' . mb_substr($aiError, 0, 120)];
            }

            $entry->markDone($summary);

            return ['status' => 'done', 'halt' => false, 'summary' => $summary];
        } catch (\Throwable $e) {
            $max = $this->maxAttempts();
            $entry->refresh();

            // A budget/rate-limit stop can also arrive as a thrown error — halt softly.
            if ($this->isRateLimited($e->getMessage())) {
                $entry->update([
                    'status'                => 'queued',
                    'processing_started_at' => null,
                ]);
                $entry->decrement('attempts');

                return ['status' => 'halted', 'halt' => true, 'error' => 'ai_rate_limited: ' . mb_substr($e->getMessage(), 0, 120)];
            }

            if ((int) $entry->attempts >= $max) {
                $entry->markFailed($e->getMessage());

                return ['status' => 'failed', 'halt' => false, 'error' => $e->getMessage()];
            }

            // Retry later — back to queued, remember the error.
            $entry->update([
                'status'                => 'queued',
                'processing_started_at' => null,
                'last_error'            => mb_substr($e->getMessage(), 0, 2000),
            ]);

            return ['status' => 'retry', 'halt' => false, 'error' => $e->getMessage()];
        }
    }

    /**
     * Reclaim rows stuck in `processing` for longer than $olderThanMinutes
     * (e.g. a worker crashed mid-run) so they can be picked up again.
     *
     * @return int number of rows released
     */
    public function releaseStuck(int $olderThanMinutes = 15): int
    {
        $cutoff = Carbon::now()->subMinutes($olderThanMinutes);

        return PcSearchQueue::where('status', 'processing')
            ->where('processing_started_at', '<', $cutoff)
            ->update([
                'status'                => 'queued',
                'processing_started_at' => null,
            ]);
    }

    /** Configured max attempts before a row is marked failed. */
    protected function maxAttempts(): int
    {
        return (int) Settings::get('ai_queue_max_attempts', 3);
    }

    /**
     * Whether an AI error is a provider-wide budget / rate-limit / quota signal
     * (as opposed to a per-product failure). These pause the drain gracefully so
     * it resumes after the limit window resets, rather than churning the whole
     * catalog to "done without AI" while the provider is throttling us. Covers
     * the common wordings across Groq / Gemini / z.ai / OpenAI / Anthropic.
     */
    protected function isRateLimited(string $msg): bool
    {
        $m = strtolower($msg);
        foreach ([
            'budget_exceeded', 'rate limit', 'rate_limit', 'ratelimit', '429',
            'too many requests', 'quota', 'resource_exhausted', 'overloaded',
            'insufficient balance', 'insufficient_quota', 'billing',
        ] as $needle) {
            if (strpos($m, $needle) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Enqueue up to $cap products that have NEVER been in the queue (no row at
     * all), so a drain can auto-feed itself through the catalog until every
     * product has been checked once, then stop. Self-terminating: products
     * already processed (done/failed) are NOT re-added — use Queue-all for a
     * deliberate re-check. Bulk insert for speed. Returns the number seeded.
     */
    public function seedUnchecked(int $businessId, int $cap = 500): int
    {
        $cap = max(1, $cap);

        $ids = \App\Product::where('business_id', $businessId)
            ->whereNotExists(fn ($s) => $s->select(DB::raw(1))->from('pc_search_queue')
                ->whereColumn('pc_search_queue.product_id', 'products.id')
                ->where('pc_search_queue.business_id', $businessId))
            ->orderBy('id')
            ->limit($cap)
            ->pluck('id');

        if ($ids->isEmpty()) {
            return 0;
        }

        $now = Carbon::now();
        $rows = $ids->map(fn ($id) => [
            'business_id' => $businessId,
            'product_id'  => (int) $id,
            'reason'      => 'auto',
            'status'      => 'queued',
            'priority'    => 0,
            'attempts'    => 0,
            'created_at'  => $now,
            'updated_at'  => $now,
        ])->all();

        PcSearchQueue::insert($rows);

        return count($rows);
    }

    /**
     * When "require SearXNG" is on and a SearXNG URL is configured but the
     * instance is unreachable, the run should HOLD — process nothing and leave
     * entries queued so they retry next time (rather than enriching with a
     * degraded source set and marking products done). Returns a hold reason
     * string, or null when it's fine to proceed.
     */
    public function searxngHoldReason(): ?string
    {
        if (! Settings::bool('ai_require_searxng', false)) {
            return null; // feature off — never hold
        }

        $url = trim((string) Settings::get('searxng_url', ''));
        if ($url === '') {
            return null; // nothing configured to hold for
        }

        try {
            $resp = \Illuminate\Support\Facades\Http::timeout(10)
                ->withHeaders(['ngrok-skip-browser-warning' => 'true'])
                ->get(rtrim($url, '/') . '/search', ['q' => 'ping', 'format' => 'json']);

            if ($resp->successful() && is_array($resp->json()) && array_key_exists('results', (array) $resp->json())) {
                return null; // reachable and serving the JSON API
            }

            return 'SearXNG not serving JSON (HTTP ' . $resp->status() . ') — is it running and format=json enabled?';
        } catch (\Throwable $e) {
            return 'SearXNG unreachable — ' . $e->getMessage();
        }
    }
}
