<?php

declare(strict_types=1);

use App\Jobs\ProcessWebhookJob;
use App\Models\ActivityLog;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\Project;
use App\Models\RejectedDealsLog;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\WebhookDedupKey;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
 *
 * Проверяет ключевую архитектурную инвариант: один и тот же vid должен
 * обновлять существующую сделку (а не создавать дубль), и баланс должен
 * списываться ровно один раз. См. narrative ТЗ §5.5.
 *
 * NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
 * trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
 * корректно обрабатывает nested через savepoints.
 */
uses(DatabaseTransactions::class);

function makePayload(int $vid = 432176649, ?int $time = null): array
{
    return [
        'vid' => $vid,
        'project' => 'B2_Caranga',  // префикс должен обрезаться до 'Caranga'
        'tag' => 'Caranga',
        'phone' => '79001234567',
        'phones' => ['79001234567'],
        'time' => $time ?? time(),
    ];
}

/**
 * Создаёт активного поставщика и привязывает его к проекту через project_suppliers.
 * Используется в тестах SupplierLeadCost-ветки.
 */
function seedSupplierForProject(Project $project, float $costRub = 50.00): int
{
    $supplierId = (int) DB::table('suppliers')->insertGetId([
        'code' => 'b1-test-'.Str::lower(Str::random(6)),
        'name' => 'B1 Test',
        'accepts_types' => '{websites,calls}',
        'cost_rub' => $costRub,
        'channel' => 'sites',
        'quality_score' => 1.00,
        'is_active' => true,
        'sort_order' => 0,
    ]);

    DB::table('project_suppliers')->insert([
        'project_id' => $project->id,
        'supplier_id' => $supplierId,
        'is_active' => true,
        'created_at' => now(),
    ]);

    return $supplierId;
}

test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle();

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9);

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    expect($deal)->not->toBeNull();
    expect($deal->source_crm_id)->toBe(100);
    expect($deal->phone)->toBe('79001234567');
    expect($deal->status)->toBe('new');
    expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан

    $dedup = WebhookDedupKey::query()
        ->where('tenant_id', $tenant->id)
        ->where('source_crm_id', 100)
        ->first();
    expect($dedup)->not->toBeNull();
    expect($dedup->deal_id)->toBe($deal->id);
});

test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $vid = 200;

    // Первый webhook
    (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9);
    $dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count();

    // Второй webhook с тем же vid (но новым phone — будет UPDATE)
    $payload2 = makePayload(vid: $vid);
    $payload2['phone'] = '79009999999';
    (new ProcessWebhookJob($tenant->id, $payload2))->handle();

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9); // баланс не изменился
    expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst);

    $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
    expect($deal->phone)->toBe('79009999999'); // обновлён phone

    // dedup-ключ всё ещё ровно один
    expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1);
});

test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 0]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle();

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(0);
    expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
    expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
});

test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () {
    $tenantA = Tenant::factory()->create(['balance_leads' => 10]);
    $tenantB = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle();
    (new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle();

    expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
    expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
    expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2);

    $tenantA->refresh();
    $tenantB->refresh();
    expect($tenantA->balance_leads)->toBe(9);
    expect($tenantB->balance_leads)->toBe(9);
});

test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle();
    (new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle();

    expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
});

test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle();

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    DB::table('deals')
        ->where('id', $deal->id)
        ->where('received_at', $deal->received_at)
        ->delete();

    expect(WebhookDedupKey::query()
        ->where('tenant_id', $tenant->id)
        ->where('source_crm_id', 700)
        ->count())->toBe(0);
});

test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle();

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    $tx = BalanceTransaction::query()
        ->where('tenant_id', $tenant->id)
        ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
        ->first();

    expect($tx)->not->toBeNull();
    expect($tx->amount_leads)->toBe(-1);
    expect($tx->balance_leads_after)->toBe(9);
    expect($tx->related_type)->toBe(Deal::class);
    expect($tx->related_id)->toBe($deal->id);
});

test('дубль vid НЕ создаёт BalanceTransaction', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $vid = 801;

    (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
    (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();

    expect(BalanceTransaction::query()
        ->where('tenant_id', $tenant->id)
        ->count())->toBe(1);
});

test('новая сделка создаёт ActivityLog event=deal.created', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle();

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    $log = ActivityLog::query()
        ->where('tenant_id', $tenant->id)
        ->where('deal_id', $deal->id)
        ->first();

    expect($log)->not->toBeNull();
    expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
    expect($log->user_id)->toBeNull();
    expect($log->context)->toBe(['source' => 'webhook']);
});

test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 0]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle();

    $rejected = RejectedDealsLog::query()
        ->where('tenant_id', $tenant->id)
        ->first();

    expect($rejected)->not->toBeNull();
    expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE);
    expect($rejected->payload['vid'])->toBe(803);
});

test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'name' => 'Caranga',  // совпадает с обрезанным project из payload
    ]);
    $supplierId = seedSupplierForProject($project, costRub: 75.50);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle();

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    $cost = SupplierLeadCost::query()
        ->where('deal_id', $deal->id)
        ->where('received_at', $deal->received_at)
        ->first();

    expect($cost)->not->toBeNull();
    expect($cost->supplier_id)->toBe($supplierId);
    expect((string) $cost->cost_rub)->toBe('75.50');
    expect($cost->supplier_lead_id)->toBe(804);
});

test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    (new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle();

    $deal = Deal::query()->where('tenant_id', $tenant->id)->first();
    expect(SupplierLeadCost::query()
        ->where('deal_id', $deal->id)
        ->count())->toBe(0);

    // Сделка всё равно создаётся, баланс списан, ActivityLog есть.
    expect($deal)->not->toBeNull();
    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9);
});

// =============================================================================
// Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1)
// =============================================================================

test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $phone = '79007770001';

    // Master: пришёл вчера в 12:00.
    $masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp);
    $masterPayload['phone'] = $phone;
    $masterPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $masterPayload))->handle();

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9);

    // Дубль: пришёл сейчас, в окне 24 ч.
    $dupPayload = makePayload(vid: 902, time: now()->timestamp);
    $dupPayload['phone'] = $phone;
    $dupPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $dupPayload))->handle();

    $master = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 901)->first();
    $dup = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 902)->first();

    expect($master->duplicate_of_id)->toBeNull();
    expect($dup->duplicate_of_id)->toBe($master->id);

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(9); // только master списан, дубль — нет
    expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
    expect(SupplierLeadCost::query()->where('deal_id', $dup->id)->count())->toBe(0);
});

test('Биз-19: master старше 24ч → НЕ дубль, баланс списывается дважды', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $phone = '79007770002';

    // Master: пришёл 25 часов назад — за окном.
    $masterPayload = makePayload(vid: 911, time: now()->subHours(25)->timestamp);
    $masterPayload['phone'] = $phone;
    $masterPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $masterPayload))->handle();

    // Новая сделка с тем же phone — master уже за окном.
    $newPayload = makePayload(vid: 912, time: now()->timestamp);
    $newPayload['phone'] = $phone;
    $newPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $newPayload))->handle();

    $deal911 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 911)->first();
    $deal912 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 912)->first();

    expect($deal911->duplicate_of_id)->toBeNull();
    expect($deal912->duplicate_of_id)->toBeNull();

    $tenant->refresh();
    expect($tenant->balance_leads)->toBe(8); // оба списаны
    expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
});

test('Биз-19: дубли изолированы по tenant_id', function () {
    $tenantA = Tenant::factory()->create(['balance_leads' => 10]);
    $tenantB = Tenant::factory()->create(['balance_leads' => 10]);
    $phone = '79007770003';

    $payloadA = makePayload(vid: 921);
    $payloadA['phone'] = $phone;
    $payloadA['phones'] = [$phone];
    (new ProcessWebhookJob($tenantA->id, $payloadA))->handle();

    // Тот же phone у tenantB — НЕ должен считаться дублем.
    $payloadB = makePayload(vid: 922);
    $payloadB['phone'] = $phone;
    $payloadB['phones'] = [$phone];
    (new ProcessWebhookJob($tenantB->id, $payloadB))->handle();

    $dealA = Deal::query()->where('tenant_id', $tenantA->id)->first();
    $dealB = Deal::query()->where('tenant_id', $tenantB->id)->first();

    expect($dealA->duplicate_of_id)->toBeNull();
    expect($dealB->duplicate_of_id)->toBeNull();
});

test('Биз-19: ActivityLog для дубля содержит context.duplicate_of', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $phone = '79007770004';

    $masterPayload = makePayload(vid: 931, time: now()->subHours(2)->timestamp);
    $masterPayload['phone'] = $phone;
    $masterPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $masterPayload))->handle();

    $dupPayload = makePayload(vid: 932, time: now()->timestamp);
    $dupPayload['phone'] = $phone;
    $dupPayload['phones'] = [$phone];
    (new ProcessWebhookJob($tenant->id, $dupPayload))->handle();

    $master = Deal::query()->where('source_crm_id', 931)->first();
    $dup = Deal::query()->where('source_crm_id', 932)->first();

    $masterLog = ActivityLog::query()->where('deal_id', $master->id)->first();
    $dupLog = ActivityLog::query()->where('deal_id', $dup->id)->first();

    expect($masterLog->context)->toBe(['source' => 'webhook']);
    expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]);
});

// =============================================================================
// failed() callback — финальная обработка после исчерпания ретраев
// =============================================================================

test('failed() пишет упавший job в failed_webhook_jobs', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $webhookLogId = (int) DB::table('webhook_log')->insertGetId([
        'tenant_id' => $tenant->id,
        'raw_payload' => json_encode(['vid' => 1001]),
        'received_at' => now(),
    ]);
    $payload = makePayload(vid: 1001);

    $job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId);
    $job->failed(new RuntimeException('boom: db down'));

    $row = DB::table('failed_webhook_jobs')
        ->where('tenant_id', $tenant->id)
        ->first();

    expect($row)->not->toBeNull();
    expect($row->webhook_log_id)->toBe($webhookLogId);
    expect($row->exception)->toBe('boom: db down');
    expect($row->retry_count)->toBe(3);
    expect($row->resolved_at)->toBeNull();
    expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001);
});

test('failed() работает БЕЗ webhookLogId (NULL ok)', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);

    $job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002));
    $job->failed(new RuntimeException('no webhook log id'));

    $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
    expect($row)->not->toBeNull();
    expect($row->webhook_log_id)->toBeNull();
});

test('failed() записывает payload с UTF-8 кириллицей корректно', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 10]);
    $payload = makePayload(vid: 1003);
    $payload['contact_name'] = 'Дмитрий Петров';

    $job = new ProcessWebhookJob($tenant->id, $payload);
    $job->failed(new RuntimeException('utf-8 test'));

    $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
    $decoded = json_decode($row->raw_payload, true);

    expect($decoded['contact_name'])->toBe('Дмитрий Петров');
});
