<?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\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Services\Supplier\SupplierQuotaAllocator;
use App\Support\RussianRegions;
use App\Support\SupplierIdentifier;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
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\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

/**
 * Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru
 * (расписание перенесено 20:30 → 18:00, см. routes/console.php).
 *
 * Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier):
 *   1. Загрузить активные Лидерра-projects (is_active=true).
 *   2. Сгруппировать по (signal_type, identifier) — БЕЗ subject_code:
 *      - identifier = buildUniqueKeyAgnostic() (site/call → signal_identifier; sms+keyword → sender+keyword; sms → sender).
 *      - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
 *      - merged_regions = union(project.regions) по всем проектам группы.
 *        Если хотя бы один проект имеет regions=[] («Вся РФ»), merged_regions=[].
 *   3. Для каждой группы:
 *      - eligible-today проекты группы (workday-маска на завтра).
 *      - order = computeOrder($eligibleLimits); workdays = union.
 *      - tag = name региона если один, иначе «РФ».
 *      - Найти существующие supplier_projects (unique_key, signal_type, platform) — без subject_code-фильтра:
 *          - Нет → saveProjectMultiFlag → [platform → id] → upsert supplier_projects (subject_code=null).
 *          - Есть → partial-set recovery + updateProject каждого с актуальными regions/limit.
 *      - Pivot: project × supplier_project → INSERT ... ON CONFLICT DO NOTHING (subject_code=null).
 *   4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены.
 *
 * Портальное ограничение: один identifier = одна группа B1/B2/B3 (status=Doubles на дублирование).
 * Поэтому все регионы проекта передаются одним списком — portal фильтрует оба одновременно.
 *
 * NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
 *
 * Spec:
 *   - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
 *   - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5
 */
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';

    private SupplierProjectChannel $channel;

    private SupplierPortalClient $client;

    public function handle(?SupplierProjectChannel $channel = null): void
    {
        $this->channel = $channel ?? app(SupplierProjectChannel::class);
        $this->client = app(SupplierPortalClient::class);
        $consecutiveTransient = 0;

        // Эпик 5: сводка по завершении заливки (для экрана SaaS-admin).
        $startedAt = now();
        $syncedOk = 0;
        $manualQueued = 0;
        $deferred = 0;
        $failed = 0;
        $aborted = false;

        // 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
        $projects = $this->collectEligibleProjects();

        // 2. Group by (signal_type, identifier) — no subject_code split.
        // Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
        // group key => [ 'signal_type', 'identifier', 'merged_regions', 'platforms', 'projects' => [...] ]
        /** @var array<string, array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}> $groups */
        $groups = [];

        foreach ($projects as $project) {
            $platforms = SupplierProjectGrouping::resolvePlatforms($project);
            if ($platforms === []) {
                continue;
            }
            $identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);

            $key = $project->signal_type.'|'.$identifier;
            if (! isset($groups[$key])) {
                $groups[$key] = [
                    'signal_type' => (string) $project->signal_type,
                    'identifier' => $identifier,
                    'merged_regions' => [],
                    'has_all_russia' => false,
                    'platforms' => $platforms,
                    'projects' => [],
                ];
            }
            // Merge regions — union across all projects in this group.
            // If any project has empty regions ("Вся РФ"), the whole group becomes "Вся РФ".
            if (! $groups[$key]['has_all_russia']) {
                $projectRegions = array_map('intval', (array) ($project->regions ?? []));
                if ($projectRegions === []) {
                    $groups[$key]['has_all_russia'] = true;
                    $groups[$key]['merged_regions'] = [];
                } else {
                    $groups[$key]['merged_regions'] = array_values(array_unique(
                        array_merge($groups[$key]['merged_regions'], $projectRegions)
                    ));
                }
            }
            $groups[$key]['projects'][] = $project;
        }

        // 3. Sync each group
        try {
            foreach ($groups as $group) {
                if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
                    Log::warning('supplier.sync.time_budget_reached', [
                        'group' => $group['identifier'],
                    ]);
                    $aborted = true;
                    break;
                }

                try {
                    $this->syncGroup($group);
                    $consecutiveTransient = 0;
                    $syncedOk++;
                } catch (TierEscalatedException $e) {
                    $manualQueued++;
                    Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");

                    continue;
                } catch (WindowDeferredException) {
                    $deferred++;
                    Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");

                    continue;
                } catch (SupplierAuthException $e) {
                    $aborted = true;
                    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++;
                    $failed++;
                    $this->logGroupFailure($group, $e);
                    if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
                        $aborted = true;
                        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) {
                    $failed++;
                    $this->logGroupFailure($group, $e);
                    report($e);

                    continue;
                }
            }
        } finally {
            $this->recordRunSummary(
                startedAt: $startedAt,
                groupsTotal: count($groups),
                syncedOk: $syncedOk,
                manualQueued: $manualQueued,
                deferred: $deferred,
                failed: $failed,
                aborted: $aborted,
            );
        }
    }

    /**
     * Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
     * Пишется и при раннем abort (time-budget / mass-fail / auth) — через finally.
     */
    private function recordRunSummary(
        Carbon $startedAt,
        int $groupsTotal,
        int $syncedOk,
        int $manualQueued,
        int $deferred,
        int $failed,
        bool $aborted,
    ): void {
        if ($aborted) {
            $status = 'aborted';
        } elseif ($failed > 0 && $syncedOk === 0) {
            $status = 'failed';
        } elseif ($failed > 0 || $manualQueued > 0) {
            $status = 'partial';
        } else {
            $status = 'ok';
        }

        DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
            'started_at' => $startedAt,
            'finished_at' => now(),
            'groups_total' => $groupsTotal,
            'synced_ok' => $syncedOk,
            'manual_queued' => $manualQueued,
            'deferred' => $deferred,
            'failed' => $failed,
            'status' => $status,
            'created_at' => now(),
        ]);
    }

    /**
     * Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
     *
     * Фильтры (Billing v2 Spec C §3.10 — преfflight баланса):
     * — is_active = true (базовый);
     * — preflight_blocked_at IS NULL (точечная блокировка проекта при «перегрузе» лимита);
     * — tenant.frozen_by_balance_at IS NULL (пассивная заморозка тенанта по пустому балансу).
     *
     * Запрос через pgsql_supplier (BYPASSRLS) — джоб бегает в системном контексте.
     * Метод публичный для unit-теста; никто из caller'ов кроме handle() его не зовёт.
     *
     * @return Collection<int, Project>
     */
    public function collectEligibleProjects(): Collection
    {
        // Task 2.9 (Spec §4.2.4b): читаем проекты ИЗ snapshot за завтра, не live
        // projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync) —
        // клиент мог paus'нуть проект между двумя cron'ами, но мы должны докатить
        // зафиксированный slepok поставщику (slepok-инвариант).
        //
        // Snapshot уже отфильтрован по is_active=true, preflight_blocked_at IS NULL,
        // tenants.frozen_by_balance_at IS NULL (см. SnapshotProjectRoutingJob /
        // SnapshotBackfillCommand WHERE). Здесь повторяем frozen-фильтр на случай
        // если tenant заморожен между 18:02 и 18:05 (rare safety net).
        //
        // Переопределяем live поля проекта значениями snapshot'а: daily_limit_target,
        // delivery_days_mask, regions. Downstream код syncGroup() читает эти поля как
        // обычно — без изменений в логике группировки/распределения.
        $tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();

        // Eloquent JOIN — casts (PostgresIntArray для regions) применяются автоматически.
        // Raw DB::table возвращал regions как PostgreSQL-string '{1,2,3}' и ломал PostgresIntArray cast.
        $projects = Project::on(self::DB_CONNECTION)
            ->join('project_routing_snapshots AS snap', 'snap.project_id', '=', 'projects.id')
            ->whereIn('snap.tenant_id', function ($q): void {
                $q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
            })
            ->where('snap.snapshot_date', $tomorrow)
            ->select(
                'projects.*',
                'snap.daily_limit AS snap_daily_limit',
                'snap.delivery_days_mask AS snap_delivery_days_mask',
                'snap.regions AS snap_regions',
                // Task 2.6: источник тоже из слепка — закрыт рассинхрон 18:02→18:05.
                'snap.signal_identifier AS snap_signal_identifier',
                'snap.sms_senders AS snap_sms_senders',
                'snap.sms_keyword AS snap_sms_keyword',
            )
            ->orderBy('projects.id')
            ->get();

        // Override live fields with snapshot values — slepok semantic.
        // snap_regions приходит как PostgreSQL-array string ('{77,99}') через append
        // (не Eloquent-cast), парсим вручную.
        foreach ($projects as $project) {
            $project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit');
            $project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask');
            $project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions'));

            // Task 2.6: источник из слепка (signal_identifier / sms_senders / sms_keyword).
            // snap_sms_senders приходит как JSONB-строка ('["Caranga"]') или null —
            // декодируем в массив под cast 'array' модели Project.
            $project->signal_identifier = $project->getAttribute('snap_signal_identifier');
            $project->sms_keyword = $project->getAttribute('snap_sms_keyword');
            $rawSenders = $project->getAttribute('snap_sms_senders');
            $project->sms_senders = $rawSenders === null
                ? null
                : json_decode((string) $rawSenders, true);
        }

        return $projects;
    }

    /**
     * Парсит PostgreSQL int-array literal `'{1,2,3}'` или `'{}'` в PHP `[1,2,3]` / `[]`.
     * Используется для snap_regions (через raw select), который не подхватывается
     * Eloquent PostgresIntArray cast'ом (тот цастит только реальное regions column).
     *
     * @return list<int>
     */
    private function parsePostgresIntArray(string $literal): array
    {
        $trimmed = trim($literal, "{} \t\n\r\0\x0B");
        if ($trimmed === '') {
            return [];
        }

        return array_values(array_map('intval', explode(',', $trimmed)));
    }

    /**
     * @param  array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}  $group
     */
    private function syncGroup(array $group): void
    {
        $signalType = $group['signal_type'];
        $identifier = $group['identifier'];
        $platforms = $group['platforms'];

        /** @var list<Project> $groupProjects */
        $groupProjects = $group['projects'];

        // Eligible-today: workday-mask for tomorrow
        $targetDate = Carbon::tomorrow('Europe/Moscow');
        $targetWeekday = $targetDate->isoWeekday();

        /** @var list<Project> $eligible */
        $eligible = array_values(array_filter(
            $groupProjects,
            fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
        ));

        if ($eligible === []) {
            return;
        }

        // Compute order and union workdays
        $eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
        $order = SupplierQuotaAllocator::computeOrder($eligibleLimits);

        // Split the group order across platforms so Σ per-platform == order. The portal does
        // NOT divide (verified live 2026-05-21) — the full order on each B = order ×N overspend.
        $shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);

        $workdaysUnion = [];
        foreach ($eligible as $p) {
            foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
                $workdaysUnion[$d] = $d;
            }
        }
        sort($workdaysUnion);
        $workdays = $workdaysUnion;

        // Portal constraint: one identifier = one B1/B2/B3 group — pass all regions as a single list.
        $allRegions = $group['merged_regions'];
        sort($allRegions);
        // count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
        $tag = count($allRegions) === 1
            ? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
            : 'РФ';

        // Find existing supplier_projects for this group (no subject_code filter)
        $existingSps = SupplierProject::on(self::DB_CONNECTION)
            ->where('unique_key', $identifier)
            ->where('signal_type', $signalType)
            ->whereIn('platform', $platforms)
            ->get();

        if ($existingSps->isEmpty()) {
            // Create path: one save PER platform with that platform's divided share
            // (single-flag save → exactly one rt-project, reliable id via listProjects match).
            // Throws propagate to handle() catch (failover-counter); rows persisted for earlier
            // platforms before a throw are recovered next run via the missing-set recovery below.
            foreach ($platforms as $platform) {
                $dto = new SupplierProjectDto(
                    platform: $platform,
                    signalType: $signalType,
                    uniqueKey: $identifier,
                    limit: $shares[$platform] ?? 0,
                    workdays: $workdays,
                    regions: $allRegions,
                    regionsReverse: false,
                    status: 'active',
                    tag: $tag,
                    platforms: [$platform],
                );

                $idMap = $this->client->saveProjectMultiFlag($dto);
                $externalId = $idMap[$platform] ?? null;
                if ($externalId === null) {
                    continue;
                }

                $sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
                    'platform' => $platform,
                    'signal_type' => $signalType,
                    'unique_key' => $identifier,
                    'subject_code' => null,
                    'supplier_external_id' => (string) $externalId,
                    'current_limit' => $shares[$platform] ?? 0,
                    'current_workdays' => $workdays,
                    'current_regions' => $allRegions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ]);

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

                $existingSps->push($sp);
            }
        } else {
            // External-deletion recovery: донор мог быть удалён на портале → external_id
            // в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых
            // проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них
            // могут висеть лиды/списания). Throws пропагируют в outer handle() catch
            // (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется.
            $livePortalIds = collect($this->client->listProjects())
                ->map(fn ($p) => (string) ($p['id'] ?? ''))
                ->filter()
                ->all();

            $deadSps = $existingSps->filter(
                fn (SupplierProject $sp) => $sp->supplier_external_id !== null
                    && ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
            );

            if ($deadSps->isNotEmpty()) {
                foreach ($deadSps as $sp) {
                    $recreateDto = new SupplierProjectDto(
                        platform: $sp->platform,
                        signalType: $signalType,
                        uniqueKey: $identifier,
                        limit: $shares[$sp->platform] ?? 0,
                        workdays: $workdays,
                        regions: $allRegions,
                        regionsReverse: false,
                        status: 'active',
                        tag: $tag,
                        platforms: [$sp->platform],
                    );

                    $recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
                    $newId = $recreatedIdMap[$sp->platform] ?? null;
                    if ($newId !== null) {
                        $sp->forceFill(['supplier_external_id' => (string) $newId])->save();
                        SupplierSyncLog::on(self::DB_CONNECTION)->create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'create',
                            'http_status' => 200,
                            'created_at' => now(),
                        ]);
                    }
                }
            }

            // Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
            // не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
            // save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
            // (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
            $existingPlatforms = $existingSps->pluck('platform')->all();
            $missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));

            if ($missingPlatforms !== []) {
                foreach ($missingPlatforms as $platform) {
                    $missingDto = new SupplierProjectDto(
                        platform: $platform,
                        signalType: $signalType,
                        uniqueKey: $identifier,
                        limit: $shares[$platform] ?? 0,
                        workdays: $workdays,
                        regions: $allRegions,
                        regionsReverse: false,
                        status: 'active',
                        tag: $tag,
                        platforms: [$platform],
                    );

                    $missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
                    $externalId = $missingIdMap[$platform] ?? null;
                    if ($externalId === null) {
                        continue;
                    }
                    $sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
                        'platform' => $platform,
                        'signal_type' => $signalType,
                        'unique_key' => $identifier,
                        'subject_code' => null,
                        'supplier_external_id' => (string) $externalId,
                        'current_limit' => $shares[$platform] ?? 0,
                        'current_workdays' => $workdays,
                        'current_regions' => $allRegions,
                        'sync_status' => 'ok',
                        'last_synced_at' => now(),
                    ]);
                    SupplierSyncLog::on(self::DB_CONNECTION)->create([
                        'supplier_project_id' => $sp->id,
                        'action' => 'create',
                        'http_status' => 200,
                        'created_at' => now(),
                    ]);
                    $existingSps->push($sp);
                }
            }

            // per-platform DTO в update-loop: portal получает правильные srcrt/srcbl/srcmt для
            // конкретной строки + её долю лимита ($shares), чтобы Σ по площадкам == order
            // (а не order на каждой). Regions/workdays общие для группы.
            foreach ($existingSps as $sp) {
                if ($sp->supplier_external_id === null) {
                    continue;
                }
                $perPlatformDto = new SupplierProjectDto(
                    platform: $sp->platform,
                    signalType: $signalType,
                    uniqueKey: $identifier,
                    limit: $shares[$sp->platform] ?? 0,
                    workdays: $workdays,
                    regions: $allRegions,
                    regionsReverse: false,
                    status: 'active',
                    tag: $tag,
                    platforms: [$sp->platform],
                );
                $this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
                $sp->forceFill([
                    'current_limit' => $shares[$sp->platform] ?? 0,
                    'current_workdays' => $workdays,
                    'current_regions' => $allRegions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ])->save();

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

        // Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING
        foreach ($groupProjects as $lp) {
            foreach ($existingSps as $sp) {
                DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
                    'project_id' => $lp->id,
                    'supplier_project_id' => $sp->id,
                    'platform' => $sp->platform,
                    'subject_code' => null,
                ]);
            }
        }

        // Auto-link to root domain (spec 2026-05-22-root-domain-auto-link-design §4.2).
        // groupProjects шарят identifier — root один на всю группу.
        $firstProject = $groupProjects[0] ?? null;
        if ($firstProject !== null && $firstProject->signal_type === 'site') {
            $rootIdentifier = SupplierIdentifier::extractRootDomain(
                (string) $firstProject->signal_identifier
            );
            if ($rootIdentifier !== null) {
                $rootSps = SupplierProject::on(self::DB_CONNECTION)
                    ->where('unique_key', $rootIdentifier)
                    ->where('signal_type', 'site')
                    ->get();
                foreach ($groupProjects as $lp) {
                    foreach ($rootSps as $rootSp) {
                        DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
                            'project_id' => $lp->id,
                            'supplier_project_id' => $rootSp->id,
                            'platform' => $rootSp->platform,
                            'subject_code' => null,
                        ]);
                    }
                }
            }
        }
    }

    /**
     * Log failure for a group (before any supplier_project is created/updated we don't have sp id,
     * so we look up existing or skip — best-effort audit).
     *
     * @param  array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}  $group
     */
    private function logGroupFailure(array $group, Throwable $e): void
    {
        $httpStatus = null;
        if ($e instanceof SupplierException) {
            $httpStatus = $e->httpStatus;
        }

        // Find any existing sp row for the group to link log entry (no subject_code filter)
        $sp = SupplierProject::on(self::DB_CONNECTION)
            ->where('unique_key', $group['identifier'])
            ->where('signal_type', $group['signal_type'])
            ->first();

        if ($sp !== null) {
            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(),
            ]);
        }
    }

    /**
     * Bitmask → ordered list 1..maxBits.
     *
     * @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;
    }
}
