<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierQuotaAllocator;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use stdClass;
use Throwable;

/**
 * Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
 *
 * Алгоритм (per spec §4.3):
 *   1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
 *   2. Для каждого:
 *      a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
 *      b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
 *      c. Вызвать SupplierQuotaAllocator::allocate() — pure distribution.
 *      d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
 *      e. saveProject() при supplier_external_id=null, иначе updateProject().
 *      f. Записать audit row в supplier_sync_log.
 *   3. Failure-handling:
 *      - SupplierAuthException → SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
 *      - SupplierTransientException → log + continue. После 50 подряд → mass_transient alert + break.
 *      - SupplierClientException → log + continue.
 *   4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
 *
 * NOTE про connection: Job's $connection — это queue connection, не DB. Используем
 * Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
 *
 * Spec:
 *   - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
 *   - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
 */
class SyncSupplierProjectsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public const TIME_BUDGET_CUTOFF = '20:55';

    public const MASS_FAIL_THRESHOLD = 50;

    public const DB_CONNECTION = 'pgsql_supplier';

    public function handle(?SupplierPortalClient $client = null): void
    {
        $client ??= app(SupplierPortalClient::class);
        $consecutiveTransient = 0;

        $projects = SupplierProject::on(self::DB_CONNECTION)
            ->whereNull('inactive_since')
            ->orderBy('id')
            ->get();

        foreach ($projects as $sp) {
            if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
                Log::warning('supplier.sync.time_budget_reached', [
                    'processed_until' => $sp->id,
                ]);
                break;
            }

            try {
                $this->syncOne($sp, $client);
                $consecutiveTransient = 0;
            } catch (SupplierAuthException $e) {
                Mail::to((string) config('services.supplier.alert_email'))
                    ->queue(new SupplierCriticalAlertMail(
                        alertType: 'sticky_auth',
                        details: $e->getMessage(),
                    ));
                report($e);
                throw $e;
            } catch (SupplierTransientException $e) {
                $consecutiveTransient++;
                $this->logSyncFailure($sp, $e);
                if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
                    Mail::to((string) config('services.supplier.alert_email'))
                        ->queue(new SupplierCriticalAlertMail(
                            alertType: 'mass_transient',
                            details: "Aborted after {$consecutiveTransient} consecutive transient failures.",
                        ));
                    report(new \RuntimeException('Supplier outage suspected: mass transient failures'));
                    break;
                }

                continue;
            } catch (SupplierClientException $e) {
                $this->logSyncFailure($sp, $e);
                report($e);

                continue;
            }
        }
    }

    private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
    {
        $fkColumn = $this->fkColumnForPlatform($sp->platform);

        /** @var EloquentCollection<int, Project> $liderraProjects */
        $liderraProjects = Project::on(self::DB_CONNECTION)
            ->where($fkColumn, $sp->id)
            ->where('is_active', true)
            ->get();

        if ($liderraProjects->isEmpty()) {
            return;
        }

        $adapted = $this->adaptProjectsForAllocator($liderraProjects);

        $allocation = SupplierQuotaAllocator::allocate(
            platform: $sp->platform,
            signalType: $sp->signal_type,
            uniqueKey: $sp->unique_key,
            activeLiderraProjects: $adapted,
            targetDate: Carbon::tomorrow('Europe/Moscow'),
        );

        if ($allocation === null) {
            return;
        }

        $current = SupplierProjectDto::fromModel($sp);
        if ($allocation->equals($current)) {
            return;
        }

        $isCreate = $sp->supplier_external_id === null;

        // NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за
        // границы транзакционного контекста, атомарности всё равно нет. Два DB-write
        // (supplier_project update + supplier_sync_log insert) на одной connection
        // выполняются последовательно; ошибка между ними — recoverable through retry
        // на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
        if ($isCreate) {
            $externalId = $client->saveProject($allocation);
            $sp->forceFill([
                'supplier_external_id' => (string) $externalId,
                'current_limit' => $allocation->limit,
                'current_workdays' => $allocation->workdays,
                'current_regions' => $allocation->regions,
                'sync_status' => 'ok',
                'last_synced_at' => now(),
            ])->save();
        } else {
            $client->updateProject((int) $sp->supplier_external_id, $allocation);
            $sp->forceFill([
                'current_limit' => $allocation->limit,
                'current_workdays' => $allocation->workdays,
                'current_regions' => $allocation->regions,
                'sync_status' => 'ok',
                'last_synced_at' => now(),
            ])->save();
        }

        SupplierSyncLog::on(self::DB_CONNECTION)->create([
            'supplier_project_id' => $sp->id,
            'action' => $isCreate ? 'create' : 'update',
            'http_status' => 200,
            'created_at' => now(),
        ]);
    }

    private function logSyncFailure(SupplierProject $sp, Throwable $e): void
    {
        $httpStatus = null;
        if ($e instanceof SupplierException) {
            $httpStatus = $e->httpStatus;
        }

        SupplierSyncLog::on(self::DB_CONNECTION)->create([
            'supplier_project_id' => $sp->id,
            'action' => $sp->supplier_external_id === null ? 'create' : 'update',
            'http_status' => $httpStatus,
            'error_message' => substr($e->getMessage(), 0, 1000),
            'created_at' => now(),
        ]);
    }

    /**
     * Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions,
     * которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent).
     *
     * Маппинг:
     *   daily_limit  ← daily_limit_target
     *   workdays     ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7
     *   regions      ← биты region_mask (bit 0=Центральный, …, bit 7=Дальневосточный) → 1..8
     *
     * @param  EloquentCollection<int, Project>  $projects
     * @return Collection<int, stdClass>
     */
    private function adaptProjectsForAllocator(EloquentCollection $projects): Collection
    {
        return $projects->map(function (Project $p): stdClass {
            $obj = new stdClass;
            $obj->daily_limit = (int) $p->daily_limit_target;
            $obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);

            // region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив
            // у supplier ("без региональных ограничений"). Иначе — список выставленных битов.
            $regionMask = (int) $p->region_mask;
            $obj->regions = $regionMask === 255
                ? []
                : $this->bitmaskToList($regionMask, 8);

            return $obj;
        })->values();
    }

    /**
     * Bitmask → ordered list 1..maxBits для bits, выставленных в 1.
     *
     * @return array<int, int>
     */
    private function bitmaskToList(int $mask, int $maxBits): array
    {
        $out = [];
        for ($i = 0; $i < $maxBits; $i++) {
            if (($mask & (1 << $i)) !== 0) {
                $out[] = $i + 1;
            }
        }

        return $out;
    }

    private function fkColumnForPlatform(string $platform): string
    {
        return match ($platform) {
            'B1' => 'supplier_b1_project_id',
            'B2' => 'supplier_b2_project_id',
            'B3' => 'supplier_b3_project_id',
            default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"),
        };
    }
}
