<?php

namespace App\Jobs;

use App\Contracts\PendingNotificationProcessor;
use App\Facades\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Mtc\MercuryDataModels\ApiNotification;
use Mtc\MercuryDataModels\PendingProviderNotification;
use Mtc\MercuryDataModels\Vehicle;
use Throwable;

use function tenant;

/**
 * Drains the pending notification backlog for a single provider, serialized per
 * (tenant, provider) so there is never more than one writer applying that
 * provider's stock at a time — which removes the concurrent-write deadlocks the
 * old per-webhook jobs caused. Each notification is applied in its own short,
 * deadlock-retried transaction so no single write unit grows large enough to
 * pile up locks.
 */
class ProcessPendingProviderNotifications implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $timeout = 600;

    public int $uniqueFor = 660;

    /**
     * Claims older than this are treated as abandoned (drainer died mid-run) and
     * reclaimed by the next drain.
     */
    private const STALE_CLAIM_MINUTES = 15;

    public function __construct(private readonly string $provider)
    {
        $this->onQueue('motors-batch');
    }

    public function uniqueId(): string
    {
        return \app()->environment() === 'production'
            ? (tenant('id') ?? 'no-tenant') . ':' . $this->provider
            : Str::random(32);
    }

    public function handle(): void
    {
        $processor = $this->resolveProcessor();
        if (!$processor || !$this->providerEnabled()) {
            return;
        }

        $chunk = max(1, (int) Settings::get($this->settingKey('batch-chunk'), 200));

        // These per-save side effects are redundant for machine stock imports and
        // add write load + lock surface; suppress them for the duration of the drain.
        Config::set('vehicles.search-content-observer-enabled', false);
        Vehicle::disableAuditing();

        try {
            while (($claimed = $this->claimChunk($chunk))->isNotEmpty()) {
                foreach ($claimed as $notification) {
                    $this->processOne($notification, $processor);
                }
            }
        } finally {
            Vehicle::enableAuditing();
            Config::set('vehicles.search-content-observer-enabled', true);
        }
    }

    /**
     * Claim a bounded set of unclaimed (or abandoned) rows for this provider.
     */
    private function claimChunk(int $chunk): Collection
    {
        $ids = $this->pending()
            ->where(function ($query) {
                $query->whereNull('claimed_at')
                    ->orWhere('claimed_at', '<', now()->subMinutes(self::STALE_CLAIM_MINUTES));
            })
            ->orderBy('id')
            ->limit($chunk)
            ->pluck('id');

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

        $this->pending()->whereIn('id', $ids)->update(['claimed_at' => now()]);

        return $this->pending()->whereIn('id', $ids)->get();
    }

    private function processOne(PendingProviderNotification $notification, PendingNotificationProcessor $processor): void
    {
        try {
            DB::transaction(fn () => $processor->process($notification->payload), 3);
        } catch (Throwable $exception) {
            $this->recordFailure($notification, $exception);
        } finally {
            // Remove the claimed row (success or recorded failure) unless it was
            // re-captured mid-process — capture() clears claimed_at, so a fresh
            // snapshot is left for the next pass rather than dropped.
            $this->pending()
                ->where('id', $notification->id)
                ->whereNotNull('claimed_at')
                ->delete();
        }
    }

    /**
     * Write a failure-only audit record for visibility. The pending row itself is
     * dropped afterwards (see processOne); a later webhook for the same vehicle will
     * re-queue it, so bad payloads don't loop and don't bloat the queue.
     */
    private function recordFailure(PendingProviderNotification $notification, Throwable $exception): void
    {
        Log::error('Pending provider notification processing failed', [
            'provider' => $this->provider,
            'reference' => $notification->reference,
            'tenant' => tenant('id'),
            'error' => $exception->getMessage(),
        ]);

        ApiNotification::query()->create([
            'provider' => $this->provider,
            'data' => [
                'error' => $exception->getMessage(),
                'payload' => $notification->payload,
            ],
            'data_model' => 'pending-notification-failure',
            'reference' => $notification->reference,
            'processed' => false,
        ]);
    }

    private function pending()
    {
        return PendingProviderNotification::query()->where('provider', $this->provider);
    }

    private function resolveProcessor(): ?PendingNotificationProcessor
    {
        $class = Config::get("pending-notifications.processors.{$this->provider}");

        return $class ? \app($class) : null;
    }

    private function providerEnabled(): bool
    {
        return (bool) Settings::get($this->settingKey('enabled'), false);
    }

    private function settingKey(string $suffix): string
    {
        return "stock-{$this->provider}-{$suffix}";
    }
}
