<?php

declare(strict_types=1);

namespace App\Services\Project;

use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Audit\OperationsLogger;
use App\Services\Billing\BalancePreflightService;
use App\Services\NotificationService;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class ProjectService
{
    public function __construct(
        private readonly OperationsLogger $ops = new OperationsLogger,
        private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard,
    ) {}

    public function update(Project $project, array $data): Project
    {
        // Immutable fields — silently drop (don't 422)
        // signal_identifier — теперь editable (18.05.2026 ux), валидируется в UpdateProjectRequest.
        unset(
            $data['tenant_id'], $data['signal_type'],
            $data['delivered_today'], $data['delivered_in_month'],
            $data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
        );

        // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
        // Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard.
        // ВАЖНО: дроуэр для site/call ВСЕГДА шлёт signal_identifier (даже неизменённый при
        // правке только лимита) — поэтому смотрим на ФАКТИЧЕСКОЕ изменение значения, а не на
        // присутствие ключа. Иначе смена лимита/региона/дней на защищённом проекте ложно
        // блокируется как «смена источника» (найдено приёмкой 25.06.2026).
        $sourceFieldsTouched = $this->sourceValueChanged($project, $data);
        // Эпик 6.2: захватываем «была ли защита» ДО правки — для in-app уведомления
        // о хвосте старого источника (только если по проекту реально летят лиды).
        $wasProtected = $sourceFieldsTouched && $this->snapshotGuard->isProtected($project);
        if ($sourceFieldsTouched) {
            $this->snapshotGuard->assertCanMutateSource($project, 'change_source');
        }

        if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
            throw new HttpResponseException(response()->json([
                'errors' => [
                    'daily_limit_target' => [
                        "Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).",
                    ],
                ],
            ], 422));
        }

        // Resync на смену источник-несущих полей, регионов, лимита и дней недели —
        // поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча.
        $needsResync = array_key_exists('sms_senders', $data)
            || array_key_exists('sms_keyword', $data)
            || array_key_exists('signal_identifier', $data)
            || array_key_exists('regions', $data)
            || array_key_exists('daily_limit_target', $data)
            || array_key_exists('delivery_days_mask', $data);

        if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
            $this->assertSourceUnique($project->tenant_id, array_merge([
                'signal_type' => $project->signal_type,
                'signal_identifier' => $project->signal_identifier,
                'sms_senders' => $project->sms_senders,
                'sms_keyword' => $project->sms_keyword,
            ], $data), exceptId: $project->id);
        }
        if (array_key_exists('name', $data)) {
            $this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
        }

        // #8/#9: capture the OLD source identifier BEFORE update so we can detach the
        // project from supplier_projects keyed by the old source (otherwise they orphan).
        $identifierFieldsTouched = array_key_exists('signal_identifier', $data)
            || array_key_exists('sms_senders', $data)
            || array_key_exists('sms_keyword', $data);
        $oldAgnostic = $identifierFieldsTouched ? SupplierProjectGrouping::buildUniqueKeyAgnostic($project) : null;

        // Snapshot before mutation for audit diff
        $before = array_intersect_key($project->toArray(), $data);

        $project->update($data);

        if ($oldAgnostic !== null) {
            $newAgnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project->fresh());
            if ($oldAgnostic !== $newAgnostic) {
                $this->detachOldSourceSupplierProjects($project, $oldAgnostic);
            }
        }

        $this->ops->record(
            tenantId: $project->tenant_id,
            userId: auth()->id(),
            entityType: 'project',
            entityId: $project->id,
            event: 'project.updated',
            payloadBefore: $before,
            payloadAfter: $data,
            ip: request()->ip(),
            userAgent: request()->userAgent(),
        );

        // G (балансовый блок): заблокированный за нехваткой баланса проект не уходит
        // заказом к поставщику — зеркалит create-гард и фильтр ночного sweep.
        if ($needsResync && $project->preflight_blocked_at === null) {
            SyncSupplierProjectJob::dispatch($project->id);
        }

        // Task 2.8 (Spec §4.2.5): для каждого изменённого slepok-sensitive поля
        // вычислить applies_from — момент, с которого правка реально вступит в силу
        // (slepok-инвариант: до 18:00 МСК → сегодня 21:00 МСК, после → завтра 21:00 МСК).
        // Берём максимум среди затронутых полей. NULL = применяется немедленно.
        $appliesFrom = null;
        foreach (SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS as $field) {
            if (! array_key_exists($field, $data)) {
                continue;
            }
            $candidate = $this->snapshotGuard->appliesFrom($project, $field);
            if ($candidate !== null && ($appliesFrom === null || $candidate->gt($appliesFrom))) {
                $appliesFrom = $candidate;
            }
        }

        // Эпик 6.2: in-app уведомление-объяснение правила (единый текст из ProjectRuleMessages).
        // Смена источника по защищённому проекту → объясняем хвост; иначе slepok-правка →
        // когда вступит. Через notifyInApp (колокольчик), не синхронный email (§9c).
        $tenant = Tenant::find((int) $project->tenant_id);
        if ($tenant !== null) {
            $rules = app(ProjectRuleMessages::class);
            $notifications = app(NotificationService::class);
            if ($sourceFieldsTouched && $wasProtected) {
                $notifications->notifyProjectRule(
                    $tenant,
                    'Источник проекта изменён',
                    $rules->sourceChanged($this->snapshotGuard->computeGraceUntil(now())),
                    ['project_id' => $project->id],
                );
            } elseif ($appliesFrom !== null) {
                $notifications->notifyProjectRule(
                    $tenant,
                    'Изменения сохранены',
                    $rules->changesApplyFrom($appliesFrom),
                    ['project_id' => $project->id],
                );
            }
        }

        $fresh = $project->fresh();
        // Dynamic attribute — не в БД, сериализуется ProjectResource (Task 2.11).
        $fresh->applies_from = $appliesFrom;

        return $fresh;
    }

    /**
     * Изменилось ли РЕАЛЬНО источник-несущее поле (значение, не просто присутствие ключа).
     * Дроуэр всегда присылает signal_identifier для site/call — поэтому без сравнения
     * значений guard ложно срабатывал бы на смену только лимита/региона/дней.
     */
    private function sourceValueChanged(Project $project, array $data): bool
    {
        if (array_key_exists('signal_identifier', $data)
            && (string) ($data['signal_identifier'] ?? '') !== (string) ($project->signal_identifier ?? '')) {
            return true;
        }
        if (array_key_exists('sms_keyword', $data)
            && (string) ($data['sms_keyword'] ?? '') !== (string) ($project->sms_keyword ?? '')) {
            return true;
        }
        if (array_key_exists('sms_senders', $data)) {
            $norm = fn ($v) => collect((array) ($v ?? []))
                ->map(fn ($s) => mb_strtolower(trim((string) $s)))
                ->sort()->values()->all();
            if ($norm($data['sms_senders']) !== $norm($project->sms_senders)) {
                return true;
            }
        }

        return false;
    }

    /**
     * #8/#9: при смене источника отвязать старые supplier_projects этого проекта (по
     * старому ключу) и запустить их чистку. DeleteSupplierProjectJob удалит их у
     * поставщика, если других потребителей не осталось; иначе — пересинк агрегата.
     */
    private function detachOldSourceSupplierProjects(Project $project, string $oldAgnostic): void
    {
        $oldSpIds = SupplierProject::where('unique_key', $oldAgnostic)
            ->where('signal_type', $project->signal_type)
            ->pluck('id')
            ->all();

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

        // Linked to THIS project via pivot — those are the ones we're detaching.
        $linkedIds = DB::table('project_supplier_links')
            ->where('project_id', $project->id)
            ->whereIn('supplier_project_id', $oldSpIds)
            ->pluck('supplier_project_id')
            ->map(fn ($v) => (int) $v)
            ->all();

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

        DB::table('project_supplier_links')
            ->where('project_id', $project->id)
            ->whereIn('supplier_project_id', $linkedIds)
            ->delete();

        // Clear legacy FKs that point at old sps (they no longer belong to this project).
        $dirty = false;
        foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) {
            if (in_array((int) $project->{$col}, $linkedIds, true)) {
                $project->{$col} = null;
                $dirty = true;
            }
        }
        if ($dirty) {
            $project->save();
        }

        DeleteSupplierProjectJob::dispatch($linkedIds);
    }

    public function delete(Project $project): void
    {
        // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
        // Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен
        // увидеть формулировку про «уже заказали лиды», а не «есть сделки».
        $this->snapshotGuard->assertCanMutateSource($project, 'delete');

        $hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
        if ($hasDeals) {
            throw new HttpResponseException(response()->json([
                'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
            ], 422));
        }

        // Капчим доноров ДО удаления — pivot уйдёт каскадом.
        $supplierProjectIds = DB::table('project_supplier_links')
            ->where('project_id', $project->id)
            ->pluck('supplier_project_id')
            ->all();

        // Snapshot ДО удаления — после delete() модель недоступна.
        $tenantId = $project->tenant_id;
        $projectId = $project->id;
        $snapshot = $project->toArray();

        $project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.

        $this->ops->record(
            tenantId: $tenantId,
            userId: auth()->id(),
            entityType: 'project',
            entityId: $projectId,
            event: 'project.deleted',
            payloadBefore: $snapshot,
            payloadAfter: null,
            ip: request()->ip(),
            userAgent: request()->userAgent(),
        );

        if ($supplierProjectIds !== []) {
            DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
        }
    }

    public function triggerSync(Project $project): void
    {
        // G (балансовый блок): ручная «Синхронизировать» не отправляет заблокированный проект.
        if ($project->preflight_blocked_at !== null) {
            return;
        }
        SyncSupplierProjectJob::dispatch($project->id);
    }

    public const BULK_MAX = 500;

    public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
    {
        if (! empty($ids)) {
            return array_values(array_unique($ids));
        }

        $query = Project::where('tenant_id', $tenantId);

        if (! empty($filter['signal_type'])) {
            $query->where('signal_type', $filter['signal_type']);
        }
        if (! empty($filter['status'])) {
            match ($filter['status']) {
                'active' => $query->where('is_active', true),
                'paused' => $query->where('is_active', false),
                default => null,
            };
        }
        if (! empty($filter['search'])) {
            $query->where('name', 'ilike', '%'.$filter['search'].'%');
        }

        return $query->pluck('id')->all();
    }

    public function bulkAction(int $tenantId, string $action, array $payload): array
    {
        $ids = $payload['ids'] ?? [];
        if (empty($ids)) {
            return ['updated' => 0, 'skipped' => [], 'warnings' => []];
        }

        $query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);

        $result = match ($action) {
            'pause' => $this->bulkPauseResume($query, false),
            'resume' => $this->bulkPauseResume($query, true),
            'delete' => $this->bulkDelete($query),
            'update_regions' => $this->bulkUpdateRegions($query, $payload),
            'update_days' => $this->bulkUpdateDays($query, $payload),
            'update_limit' => $this->bulkUpdateLimit($query, $payload, $tenantId),
        };

        $this->ops->record(
            tenantId: $tenantId,
            userId: auth()->id(),
            entityType: 'project',
            entityId: null,
            event: 'project.bulk_'.$action,
            payloadBefore: null,
            payloadAfter: array_merge(['ids' => $ids], $result),
            ip: request()->ip(),
            userAgent: request()->userAgent(),
        );

        return $result;
    }

    /**
     * Pause/resume + supplier sync per affected project (#10).
     *
     * Without the dispatch, pause never reached the supplier (status stayed active).
     * The job's group recompute then pushes status=paused when no active project of
     * the group remains, or rebalances the order when some siblings are still active.
     */
    private function bulkPauseResume($query, bool $isActive): array
    {
        $ids = (clone $query)->pluck('id')->all();
        // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
        // paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
        // триггерит model events, поэтому пишем явно в одном UPDATE.
        $updated = $query->update([
            'is_active' => $isActive,
            'paused_at' => $isActive ? null : DB::raw('NOW()'),
        ]);
        foreach ($ids as $id) {
            SyncSupplierProjectJob::dispatch((int) $id);
        }

        return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
    }

    private function bulkSimpleUpdate($query, array $update): array
    {
        $updated = $query->update($update);

        return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
    }

    private function bulkDelete($query): array
    {
        $projects = (clone $query)->get(['id']);
        $deleted = 0;
        $skipped = [];

        foreach ($projects as $p) {
            $model = Project::find($p->id);
            if ($model === null) {
                continue;
            }

            try {
                $this->delete($model);
                $deleted++;
            } catch (HttpResponseException $e) {
                // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 12).
                // Разделяем причину: guard поставщика (нужно подождать) vs has-deals.
                $body = json_decode((string) $e->getResponse()->getContent(), true);
                $message = (string) ($body['errors']['project'][0] ?? '');
                $reason = str_contains($message, 'Мы уже начали сбор лидов')
                    ? 'supplier_snapshot_locked'
                    : 'has_deals';
                $skipped[] = ['id' => $p->id, 'reason' => $reason];
            }
        }

        return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
    }

    /**
     * Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
     *
     * Для каждого проекта: regions := unique(regions ∪ add_regions) \ remove_regions,
     * отсортировано по возрастанию. `regions[]` — источник истины региональной
     * фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
     * Legacy `region_mask` здесь не трогается — как и в одиночном PATCH
     * /api/projects/{id}; его удаление — Plan 6.5 cleanup.
     *
     * NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
     * субъектов — это осознанное действие оператора bulk-диалога.
     *
     * Обновление идёт через model-инстанс (не query-builder mass update): каст
     * PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
     * mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо.
     */
    private function bulkUpdateRegions($query, array $payload): array
    {
        $add = array_map('intval', $payload['add_regions'] ?? []);
        $remove = array_map('intval', $payload['remove_regions'] ?? []);

        if ($add === [] && $remove === []) {
            return ['updated' => 0, 'skipped' => [], 'warnings' => []];
        }

        $projects = (clone $query)->get(['id', 'regions']);
        $updated = 0;

        foreach ($projects as $project) {
            $next = array_values(array_unique([...($project->regions ?? []), ...$add]));
            $next = array_values(array_diff($next, $remove));
            sort($next);
            $project->update(['regions' => $next]);
            $updated++;
        }

        return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
    }

    private function bulkUpdateDays($query, array $payload): array
    {
        $add = (int) ($payload['add'] ?? 0);
        $remove = (int) ($payload['remove'] ?? 0);

        $updated = $query->update([
            'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
        ]);

        return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
    }

    private function bulkUpdateLimit($query, array $payload, int $tenantId): array
    {
        $delta = $payload['delta'] ?? null;
        $replace = $payload['replace'] ?? null;

        $projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today', 'is_active', 'preflight_blocked_at'])->get();

        $updatableIds = [];
        $skipped = [];

        foreach ($projects as $p) {
            $newValue = $replace !== null
                ? (int) $replace
                : (int) $p->daily_limit_target + (int) $delta;

            if ($newValue < (int) $p->delivered_today) {
                $skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
            } else {
                $updatableIds[$p->id] = $newValue;
            }
        }

        // N (балансовый блок): массовое повышение лимита не должно поднять суммарный
        // дневной лимит активных не-заблокированных проектов выше ёмкости баланса —
        // зеркалит преfflight одиночной правки (ProjectController::update Spec C §3.4).
        // Снимаем только ПОВЫШЕНИЯ активных не-blocked проектов (именно они толкают за
        // ёмкость); понижения и правки paused/blocked проектов всегда применяются.
        $this->applyBalancePreflightToBulkLimit($tenantId, $projects, $updatableIds, $skipped);

        $updated = 0;

        if (! empty($updatableIds)) {
            if ($replace !== null) {
                $updated = Project::whereIn('id', array_keys($updatableIds))
                    ->update(['daily_limit_target' => (int) $replace]);
            } else {
                // delta — обновляем по одному (count bounded by MAX 500).
                foreach ($updatableIds as $id => $newValue) {
                    Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
                    $updated++;
                }
            }
        }

        return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
    }

    /**
     * N (балансовый блок): снимает из bulk-правки лимита те ПОВЫШЕНИЯ активных
     * не-заблокированных проектов, которые подняли бы суммарный дневной лимит выше
     * ёмкости баланса. Мутирует $updatableIds (убирает снятые) и $skipped (дописывает
     * 'balance_insufficient'). Без активных pricing_tiers биллинг не настроен —
     * проверка пропускается (зеркалит fallback ProjectController::runPreflight).
     *
     * @param  Collection<int,Project>  $projects  проекты bulk-набора (с is_active/preflight_blocked_at)
     * @param  array<int,int>  $updatableIds  id => новое значение лимита (by ref)
     * @param  array<int,array{id:int,reason:string}>  $skipped  (by ref)
     */
    private function applyBalancePreflightToBulkLimit(int $tenantId, $projects, array &$updatableIds, array &$skipped): void
    {
        // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
        $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
        if ($tiers->isEmpty()) {
            return;
        }

        $tenant = Tenant::find($tenantId);
        if ($tenant === null) {
            return;
        }

        // Прогнозный суммарный лимит активных не-blocked проектов клиента с учётом правок.
        $prospectiveTotal = (int) Project::where('tenant_id', $tenantId)
            ->where('is_active', true)
            ->whereNull('preflight_blocked_at')
            ->get(['id', 'daily_limit_target'])
            ->reduce(fn (int $sum, $pr): int => $sum + (array_key_exists($pr->id, $updatableIds)
                ? $updatableIds[$pr->id]
                : (int) $pr->daily_limit_target), 0);

        $preflight = (new BalancePreflightService)->evaluate(
            balanceRub: (string) $tenant->balance_rub,
            deliveredInMonth: (int) $tenant->delivered_in_month,
            requiredLeads: $prospectiveTotal,
            tiers: $tiers,
        );

        if ($preflight->passes) {
            return;
        }

        foreach ($projects as $p) {
            if (! array_key_exists($p->id, $updatableIds)) {
                continue;
            }
            $isIncrease = $updatableIds[$p->id] > (int) $p->daily_limit_target;
            $countsToActive = $p->is_active && $p->preflight_blocked_at === null;
            if ($isIncrease && $countsToActive) {
                unset($updatableIds[$p->id]);
                $skipped[] = ['id' => $p->id, 'reason' => 'balance_insufficient'];
            }
        }
    }

    private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
    {
        $q = Project::where('tenant_id', $tenantId)->where('name', $name);
        if ($exceptId !== null) {
            $q->where('id', '!=', $exceptId);
        }
        if ($q->exists()) {
            throw new HttpResponseException(response()->json([
                'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
            ], 422));
        }
    }

    /** @param array<string,mixed> $data */
    private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
    {
        $signalType = $data['signal_type'] ?? null;
        $q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
        if ($exceptId !== null) {
            $q->where('id', '!=', $exceptId);
        }

        if (in_array($signalType, ['call', 'site'], true)) {
            $identifier = (string) ($data['signal_identifier'] ?? '');
            if ($identifier === '') {
                return;
            }
            $q->where('signal_identifier', $identifier);
        } elseif ($signalType === 'sms') {
            $senders = (array) ($data['sms_senders'] ?? []);
            $norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
            if ($norm === []) {
                return;
            }
            $keyword = $data['sms_keyword'] ?? null;
            $q->where('sms_keyword', $keyword)
                ->whereJsonContains('sms_senders', $norm)
                ->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
        } else {
            return;
        }

        $existing = $q->first();
        if ($existing !== null) {
            throw new HttpResponseException(response()->json([
                'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
            ], 422));
        }
    }

    public function create(Tenant $tenant, array $data): Project
    {
        // Лимита по числу проектов нет — ограничение только по балансу/заказанным
        // лидам (балансовый префлайт в ProjectController::store). Прежний гейт
        // tenants.limits['max_projects'] убран как противоречащий правилу продукта.
        $data['tenant_id'] = $tenant->id;
        $data['is_active'] = true;
        $data['regions'] = $data['regions'] ?? [];
        // Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
        // PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
        $data['region_mask'] = 255;
        $data['region_mode'] = 'include';

        $this->assertNameUnique($tenant->id, (string) $data['name']);
        $this->assertSourceUnique($tenant->id, $data);

        $project = Project::create($data);

        $this->ops->record(
            tenantId: $project->tenant_id,
            userId: auth()->id(),
            entityType: 'project',
            entityId: $project->id,
            event: 'project.created',
            payloadBefore: null,
            payloadAfter: $project->toArray(),
            ip: request()->ip(),
            userAgent: request()->userAgent(),
        );

        // Заблокированный по балансу проект (preflight_blocked_at, Spec C §3.4) НЕ
        // заказываем у поставщика — зеркалит фильтр
        // BalancePreflightSweepJob::dispatchSupplierSyncIfOnline (->whereNull('preflight_blocked_at')).
        // Без гарда продавленный force_save_blocked-проект всё равно уезжал к поставщику
        // полным daily_limit_target, хотя лидов он не получает (слепок его исключает).
        if ($project->preflight_blocked_at === null) {
            SyncSupplierProjectJob::dispatch($project->id);
        }

        return $project->fresh();
    }
}
