<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Mockery as M;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);

beforeEach(function (): void {
    // Plan 4 Task 4: LedgerService требует наличия активных PricingTier'ов для tier-resolve.
    $this->seed(PricingTierSeeder::class);
    DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});

function runRouteJob(int $supplierLeadId): void
{
    (new RouteSupplierLeadJob($supplierLeadId))->handle(
        app(LeadRouter::class),
        app(SupplierProjectResolver::class),
        app(NotificationService::class),
        app(LedgerService::class),
        app(LeadDistributor::class),
        app(RegionTagResolver::class),
    );
}

// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.

it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void {
    // Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1
    // бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs ->
    // RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей).
    // «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен.
    $missingId = 999999;
    expect(SupplierLead::find($missingId))->toBeNull();

    $countBefore = DB::table('deals')->count();

    // Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
    runRouteJob($missingId);

    // Никаких побочных эффектов — количество сделок не изменилось.
    expect(DB::table('deals')->count())->toBe($countBefore);
});

it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'vashinvestor.ru',
    ]);

    $tenants = collect();
    $projects = collect();
    for ($i = 0; $i < 3; $i++) {
        $t = Tenant::factory()->create(['balance_rub' => '100000.00']);
        $tenants->push($t);
        $projects->push(Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'vashinvestor.ru',
            'is_active' => true,
            'delivered_today' => 0,
            'delivered_in_month' => 0,
        ]));
        linkProjectToSupplier($projects->last(), $supplier);
        createRoutingSnapshotFromProject($projects->last());
    }

    $vid = 432176649;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => [
            'vid' => $vid,
            'project' => 'B1_vashinvestor.ru',
            'tag' => 'tag',
            'phone' => '79991234567',
            'phones' => ['79991234567'],
            'time' => now()->getTimestamp(),
        ],
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(3);
    expect($lead->supplier_project_id)->toBe($supplier->id);

    foreach ($projects as $i => $p) {
        $tenant = $tenants[$i];
        $p->refresh();
        expect($p->delivered_today)->toBe(1);
        expect($p->delivered_in_month)->toBe(1);

        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
        $deals = Deal::query()
            ->where('tenant_id', $tenant->id)
            ->where('source_crm_id', $vid)
            ->get();
        expect($deals)->toHaveCount(1);
    }
});

it('charges balance_rub for tenant after routing', function (): void {
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'test.ru',
    ]);
    $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'test.ru',
        'is_active' => true,
    ]);
    linkProjectToSupplier($project, $supplier);
    createRoutingSnapshotFromProject($project);

    $vid = 99;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
    ]);

    runRouteJob($lead->id);

    expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});

it('throws DomainException when payload encodes B1+SMS combo', function (): void {
    $vid = 1;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
    ]);

    expect(fn () => runRouteJob($lead->id))->toThrow(DomainException::class);
});

it('handles orphan supplier_project (no matching liderra-projects) — 0 deals, lead processed', function (): void {
    $vid = 777;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_orphan.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(0);
    expect($lead->supplier_project_id)->not->toBeNull();
});

it('same phone pre-existing does not suppress new delivery (Spec B)', function (): void {
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'mixed.ru',
    ]);

    $tenants = collect();
    $projects = collect();
    for ($i = 0; $i < 3; $i++) {
        $t = Tenant::factory()->create(['balance_rub' => '100000.00']);
        $tenants->push($t);
        $projects->push(Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'mixed.ru',
            'is_active' => true,
            'delivered_today' => 0,
            'delivered_in_month' => 0,
        ]));
        linkProjectToSupplier($projects->last(), $supplier);
        createRoutingSnapshotFromProject($projects->last());
    }

    // Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
    $firstTenant = $tenants[0];
    $firstProject = $projects[0];
    DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'");
    Deal::create([
        'tenant_id' => $firstTenant->id,
        'source_crm_id' => 555,
        'project_id' => $firstProject->id,
        'phone' => '79991234567',
        'phones' => ['79991234567'],
        'status' => 'new',
        'received_at' => now()->subHours(2),
    ]);

    $vid = 2222;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_mixed.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    // Spec B: pre-existing master does NOT suppress — all 3 charged.
    expect($lead->deals_created_count)->toBe(3);

    // All 3 tenants: balance decremented, delivered_today incremented.
    foreach (range(0, 2) as $i) {
        $t = $tenants[$i];
        $p = $projects[$i];
        expect((string) $t->fresh()->balance_rub)->toBe('99500.00');
        expect($p->fresh()->delivered_today)->toBe(1);
    }

    // 3 deal rows exist for this vid (one per tenant).
    expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3);
});

it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
    // BLOCKER #3 (CV.11 audit): RouteSupplierLeadJob::$tries = 3. На retry задачи (после
    // транзиентного сбоя — DB hiccup, queue worker restart) handle() запускался ПОВТОРНО
    // без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем
    // же vid (DuplicateDetector помечал его как duplicate, без charge — но deal-row в БД
    // оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал
    // счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики.
    //
    // Fix: в начале handle() — if ($lead->processed_at !== null) return; — early return.
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'retry-idempotent.ru',
    ]);
    $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'retry-idempotent.ru',
        'is_active' => true,
        'delivered_today' => 0,
    ]);
    linkProjectToSupplier($project, $supplier);
    createRoutingSnapshotFromProject($project);

    $vid = 7777;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => [
            'vid' => $vid,
            'project' => 'B1_retry-idempotent.ru',
            'phone' => '79991234567',
            'time' => now()->getTimestamp(),
        ],
    ]);

    // 1st run — нормальная обработка.
    runRouteJob($lead->id);
    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(1);
    expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
    expect($project->fresh()->delivered_today)->toBe(1);

    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);

    // 2nd run — должен быть no-op (idempotent guard на processed_at).
    runRouteJob($lead->id);
    $lead->refresh();

    // Лид остаётся помечен обработанным, deals_created_count НЕ сбросился.
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(1);

    // НИКАКИХ дублей не появилось: balance, counter, deal-row.
    expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
    expect($project->fresh()->delivered_today)->toBe(1);

    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
});

it('handles partial failure: one project throws, others continue routing', function (): void {
    // Тест полагается на Tenant SoftDeletes (см. App\Models\Tenant) — soft-delete
    // tenant'а в середине loop'а заставляет Tenant::firstOrFail() выкинуть
    // ModelNotFoundException, что симулирует per-Project failure без мокинга.
    // Если SoftDeletes когда-либо удалят с Tenant — этот тест нужно переписать
    // на runtime-mock или удалить (PHPStan не пропускает $this->markTestSkipped()
    // внутри Pest-closure).
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'partial-failure.ru',
    ]);

    $tenants = collect();
    $projects = collect();
    for ($i = 0; $i < 3; $i++) {
        $t = Tenant::factory()->create(['balance_rub' => '100000.00']);
        $tenants->push($t);
        $projects->push(Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'partial-failure.ru',
            'is_active' => true,
            'delivered_today' => 0,
        ]));
        linkProjectToSupplier($projects->last(), $supplier);
        createRoutingSnapshotFromProject($projects->last());
    }

    // Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
    $tenants[1]->delete();

    $vid = 3333;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_partial-failure.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал

    // Tenants 0 и 2 успешно списаны
    expect((string) $tenants[0]->fresh()->balance_rub)->toBe('99500.00');
    expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00');
});

it('fast-fails terminal-error lead without creating a deal or re-queueing', function (): void {
    // Finding 2 (2026-05-29, fast-fail plan): лид с terminal-error и processed_at=null
    // раньше прокручивался заново → failed_webhook_jobs storm. Fast-fail помечает его
    // processed с суффиксом [fast-failed...], без сделки и без повторной очереди.
    $countBefore = DB::table('deals')->count();

    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => 4444,
        'phone' => '79991234599',
        'raw_payload' => ['vid' => 4444, 'project' => 'B1_whatever.ru', 'phone' => '79991234599', 'time' => now()->getTimestamp()],
        'processed_at' => null,
        'error' => 'no matching supplier_project for B1/site/whatever.ru',
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->error)->toContain('[fast-failed by RouteSupplierLeadJob]');
    expect(DB::table('deals')->count())->toBe($countBefore); // сделок не создано
});

it('throws RuntimeException when ALL eligible projects fail routing', function (): void {
    // Терминальный all-fail (RouteSupplierLeadJob:188-193): selected непуст, но КАЖДЫЙ
    // createDealCopyForProject бросает → createdCount=0 → RuntimeException (очередь
    // ловит → retry → failed_webhook_jobs). Форсируем soft-delete'ом ВСЕХ тенантов:
    // Tenant::firstOrFail() внутри createDealCopyForProject падает для каждого проекта
    // (тот же приём, что и в partial-failure тесте, но на 100% проектов).
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'all-fail.ru',
    ]);

    $tenants = collect();
    for ($i = 0; $i < 2; $i++) {
        $t = Tenant::factory()->create(['balance_rub' => '100000.00']);
        $tenants->push($t);
        $project = Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'all-fail.ru',
            'is_active' => true,
            'delivered_today' => 0,
        ]);
        linkProjectToSupplier($project, $supplier);
        createRoutingSnapshotFromProject($project);
    }

    foreach ($tenants as $t) {
        $t->delete(); // soft-delete → firstOrFail упадёт для каждого проекта
    }

    $vid = 5555;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234588',
        'raw_payload' => ['vid' => $vid, 'project' => 'B1_all-fail.ru', 'phone' => '79991234588', 'time' => now()->getTimestamp()],
    ]);

    expect(fn () => runRouteJob($lead->id))->toThrow(RuntimeException::class);
});

it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
    // Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный
    // текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField
    // c anchored-regex '^[a-z0-9-]+(\.[a-z0-9-]+)+$' такой rest не матчил → классифицировал
    // как 'sms' → B1+sms → DomainException → 21 реальный лид застрял с error, 0 сделок.
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => $domain,
    ]);
    $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => $domain,
        'is_active' => true,
    ]);
    linkProjectToSupplier($project, $supplier);
    createRoutingSnapshotFromProject($project);

    $vid = random_int(100000, 999999);
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => [
            'vid' => $vid,
            'project' => $projectField,
            'phone' => '79991234567',
            'time' => now()->getTimestamp(),
        ],
    ]);

    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->supplier_project_id)->toBe($supplier->id);
    expect($lead->deals_created_count)->toBe(1);
})->with([
    'carmoney embedded in free text' => ['B1_заявка carmoney.ru/', 'carmoney.ru'],
    'caranga subdomain with path' => ['B1_Платежи cabinet.caranga.ru/login', 'cabinet.caranga.ru'],
    'krk-finance with auth path' => ['B1_krk-finance.ru/cabinet/auth', 'krk-finance.ru'],
]);

it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void {
    // BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit
    // БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое
    // инкрементит) — окно для concurrent webhook'а:
    //   worker A видит delivered_today=9, limit=10 → OK; createDealCopyForProject → 10.
    //   worker B параллельно видит то же 9 → OK; createDealCopyForProject → 11. OVERCOMMIT.
    //
    // Симуляция: project уже at-limit (delivered_today=1, daily_limit_target=1) к
    // моменту createDealCopyForProject — мокнутый LeadRouter возвращает его как eligible
    // (так, будто matchEligibleProjects делал SELECT когда delivered_today=0).
    //
    // Fix #2: внутри createDealCopyForProject под lockForUpdate(Project) — recheck
    // delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
    // Если уже at-limit → return false без charge / counter / deal-row.
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'race-recheck.ru',
    ]);
    $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'race-recheck.ru',
        'is_active' => true,
        'daily_limit_target' => 1,
        'effective_daily_limit_today' => null, // COALESCE → daily_limit_target=1
        'delivered_today' => 1,                // ALREADY AT LIMIT (race-window simulation)
        'delivered_in_month' => 5,
    ]);

    $vid = 8888;
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => [
            'vid' => $vid,
            'project' => 'B1_race-recheck.ru',
            'phone' => '79991234567',
            'time' => now()->getTimestamp(),
        ],
    ]);

    // Подсунуть LeadRouter mock, который игнорирует filter и возвращает project,
    // как будто SELECT'нул его при snapshot delivered_today=0.
    $routerMock = M::mock(LeadRouter::class);
    $routerMock->shouldReceive('matchEligibleProjects')
        ->andReturn(new Collection([$project]));
    app()->instance(LeadRouter::class, $routerMock);

    runRouteJob($lead->id);

    $lead->refresh();

    // После fix #2: deal НЕ создан (recheck под lock увидел limit) → 0 deals.
    expect($lead->deals_created_count)->toBe(0);

    // delivered_today остался 1 (НЕ инкрементнулся до 2).
    expect($project->fresh()->delivered_today)->toBe(1);
    // delivered_in_month НЕ инкрементнулся.
    expect($project->fresh()->delivered_in_month)->toBe(5);

    // balance_rub НЕ списан.
    expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');

    // Deal-row не создался.
    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
});

it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
    // seeded distributor — детерминизм
    app()->bind(LeadDistributor::class, fn () => new LeadDistributor(
        new Randomizer(new Mt19937(7))
    ));

    $sp = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    // 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
    foreach (range(1, 5) as $i) {
        $t = Tenant::factory()->create(['balance_rub' => '100000.00']);
        $p = Project::factory()->create([
            'tenant_id' => $t->id, 'is_active' => true,
            'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
        ]);
        linkProjectToSupplier($p, $sp);
        createRoutingSnapshotFromProject($p);
    }

    $lead = SupplierLead::factory()->create([
        'phone' => '79991234567',
        'vid' => 555111,
        'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
        'processed_at' => null,
        'supplier_project_id' => null,
        'platform' => 'B1',
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(
        app(LeadRouter::class),
        app(SupplierProjectResolver::class),
        app(NotificationService::class),
        app(LedgerService::class),
        app(LeadDistributor::class),
        app(RegionTagResolver::class),
    );

    $deals = Deal::query()->where('source_crm_id', 555111)->get();
    expect($deals)->toHaveCount(3)
        ->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
});

it('merges webhook into csv-recovered deal even when received_at differs (Phase 2 FK fix)', function (): void {
    // Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с
    // SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at.
    // Причина — lead_charges имеет FK на (deal_id, deal_received_at) с
    // ON DELETE CASCADE, но ON UPDATE NO ACTION (default). Даже DEFERRABLE INITIALLY
    // DEFERRED не помогает — проверка падает на COMMIT. Фикс: оставить received_at
    // CSV-recovered deal'а нетронутым (отличие на минуты несущественно).

    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'phase2-merge.ru',
    ]);
    $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'phase2-merge.ru',
        'is_active' => true,
    ]);
    linkProjectToSupplier($project, $supplier);
    createRoutingSnapshotFromProject($project);

    // CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
    $csvReceivedAt = now()->subMinutes(15);
    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    $csvDeal = Deal::create([
        'tenant_id' => $tenant->id,
        'source_crm_id' => null,
        'project_id' => $project->id,
        'phone' => '79991234567',
        'phones' => ['79991234567'],
        'status' => 'new',
        'received_at' => $csvReceivedAt,
    ]);

    // LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
    LeadCharge::factory()->create([
        'tenant_id' => $tenant->id,
        'deal_id' => $csvDeal->id,
        'deal_received_at' => $csvDeal->received_at,
        'charge_source' => 'rub',
    ]);

    // Webhook lead: реальный vid, тот же phone+project, received_at позже CSV.
    $webhookVid = 999111;
    $webhookReceivedAt = now(); // > csvReceivedAt → старый код триггерил UPDATE received_at.
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'vid' => $webhookVid,
        'phone' => '79991234567',
        'received_at' => $webhookReceivedAt,
        'raw_payload' => [
            'vid' => $webhookVid,
            'project' => 'B1_phase2-merge.ru',
            'phone' => '79991234567',
            'phones' => ['79991234567'],
            'time' => $webhookReceivedAt->getTimestamp(),
        ],
    ]);

    // Не должно бросать FK violation — merge обновляет ТОЛЬКО source_crm_id.
    runRouteJob($lead->id);

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();

    // Deal обновлён: source_crm_id заполнен webhook vid, received_at не тронут.
    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    $merged = Deal::query()
        ->whereKey($csvDeal->id)
        ->where('received_at', $csvReceivedAt)
        ->first();
    expect($merged)->not->toBeNull();
    expect($merged->source_crm_id)->toBe($webhookVid);

    // Без второго списания — balance не изменился (chargeForDelivery в merge-ветке не вызывается).
    expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');

    // supplier_lead_deliveries — линк создан.
    $deliveryCount = DB::table('supplier_lead_deliveries')
        ->where('supplier_lead_id', $lead->id)
        ->where('tenant_id', $tenant->id)
        ->count();
    expect($deliveryCount)->toBe(1);

    // Никаких дублей deals — только один с этим vid.
    expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
});

it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
    Http::fake(['cleaner.dadata.ru/*' => Http::response([[
        'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
    ]], 200)]);
    config([
        'services.dadata.enabled' => true,
        'services.dadata.api_key' => 'k',
        'services.dadata.secret' => 's',
        'services.dadata.daily_cap_rub' => 100000,
    ]);
    [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');

    runRouteJob($lead->id);

    // deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
    $deal = dealFor($tenant->id, $project->id);
    expect($deal)->not->toBeNull()
        ->and($deal->city)->toBe('Москва');
});

it('leaves deal city null when region is unknown', function (): void {
    config(['services.dadata.enabled' => false]);
    // Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
    [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');

    runRouteJob($lead->id);

    $deal = dealFor($tenant->id, $project->id);
    expect($deal)->not->toBeNull()
        ->and($deal->city)->toBeNull();
});
