<?php

declare(strict_types=1);

/**
 * Verification tests — ScenarioG5G6_SpecialLeadsTest (Task 11, Phase 1 imitation).
 *
 * PROVING tests against existing production code.
 * Differences vs plan → FINDINGS captured in test comments.
 *
 * Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 11
 * Spec: §6.4 G5a/b/c + G6
 *
 * Key verified facts (from reading prod code and RegionResolverCascadeTest findings):
 *
 * G5a FINDING: Plan says qc=2/7 → source='tag' immediately.
 *   Actual: LeadRegionResolver::doResolve() line 101-103 calls tagFallback() for qc=2/7.
 *   tagFallback() returns source='tag' ONLY when tagCode !== null (i.e. tag is a valid region).
 *   Empty/null tag → tagCode=null → source='unknown'. Confirmed by RegionResolverCascadeTest F2.
 *   This test asserts REAL behaviour: empty tag → 'unknown', valid tag → 'tag'.
 *
 * G5b: DaData throws (stubThrows) OR qc=1 → falls through to Россвязь.
 *   Source='rossvyaz' when phone_ranges row seeded and subscriber in range.
 *   Phone parsing: phone=7{defCode}{subscriber}, e.g. 79885550011 → defCode=988, subscriber=5550011.
 *
 * G5c: qc=2 + no seeded range + empty tag → source='unknown'.
 *   (Россвязь is not called at all for qc=2 — it goes straight to tagFallback.)
 *
 * G6 (dedup — the key new case in this file):
 *   Dedup is implemented in SupplierWebhookController::receive(), NOT in LeadInjector.
 *   Two HTTP POST requests with the same vid:
 *     First  → 202 + body.status='accepted'  + SupplierLead created.
 *     Second → 200 + body.status='already_processed' + body.supplier_lead_id = existing id.
 *   Verified from controller code lines 94-100.
 *   supplier_leads count for that vid stays 1.
 *
 * G5 tests use LeadRegionResolver directly (unit-style cascade tests).
 * G6 test uses HTTP POST through the real controller route (the only dedup path).
 *
 * Dedup response shape (exact, from controller lines 94-100):
 *   HTTP 200
 *   { "status": "already_processed", "supplier_lead_id": <int> }
 *
 * First-request response shape (from controller lines 116-119):
 *   HTTP 202
 *   { "status": "accepted", "supplier_lead_id": <int> }
 */

use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;

uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation');

// ---------------------------------------------------------------------------
// Webhook secret for G6 HTTP-layer dedup tests
// ---------------------------------------------------------------------------
const G5G6_SECRET = 'g5g6-test-secret-32chars-aaaaaab'; // exactly 32 chars (controller requires strlen >= 32)

// ---------------------------------------------------------------------------
// beforeEach — shared state for all tests in this file
// ---------------------------------------------------------------------------
beforeEach(function (): void {
    // Tenant context bypass for cross-tenant reads during seeding.
    DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");

    // Default: DaData disabled (individual tests enable it as needed).
    config(['services.dadata.enabled' => false]);

    // Fix for worktree-local .env placeholder APP_KEY — the default value
    // 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo=' is not a valid AES-256-CBC key
    // (wrong decoded length). Inject a valid 32-byte base64-encoded key so that
    // Laravel's Encrypter does not throw on HTTP test requests.
    // The main project's .env has a real key; phpunit.xml in this worktree has no APP_KEY.
    // This fix is scoped to this file's tests only (config() changes are per-request).
    if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
        config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]);
        app('encrypter')->__construct(
            str_repeat('a', 32),
            config('app.cipher', 'AES-256-CBC')
        );
    }

    // Set a known webhook secret for G6 tests.
    SystemSetting::query()
        ->where('key', 'supplier_webhook_secret')
        ->update(['value' => G5G6_SECRET]);

    // IP allowlist empty → fail-open in testing env (verifyIpAllowlist returns true
    // for non-production environments when allowlist is empty — controller line 177).
    SystemSetting::query()
        ->where('key', 'supplier_ip_allowlist')
        ->update(['value' => '[]']);

    // Seed pricing tiers (required by RouteSupplierLeadJob/LedgerService path).
    try {
        (new PricingTierSeeder)->run();
    } catch (Throwable) {
        // Already seeded or not needed for this specific test.
    }
});

// ---------------------------------------------------------------------------
// Helper: create a SupplierLead with a given phone + tag for resolver tests
// ---------------------------------------------------------------------------
function makeG5Lead(string $phone, string $tag = ''): SupplierLead
{
    $sp = SupplierProject::factory()->create();

    return SupplierLead::factory()->create([
        'supplier_project_id' => $sp->id,
        'phone' => $phone,
        'raw_payload' => ['tag' => $tag],
    ]);
}

// ---------------------------------------------------------------------------
// Helper: insert a phone_ranges row correctly (same as RegionResolverCascadeTest)
// ---------------------------------------------------------------------------
function insertG5PhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
    $importId = DB::table('phone_ranges_imports')->insertGetId([
        'imported_at' => now(),
        'source_url' => 'test://rossvyaz-g5g6',
        'rows_inserted' => 1,
        'rows_updated' => 0,
        'checksum_sha256' => hash('sha256', "g5g6-{$defCode}-{$from}-{$to}-{$subjectCode}"),
        'status' => 'completed',
        'completed_at' => now(),
    ]);

    DB::table('phone_ranges')->insert([
        'def_code' => $defCode,
        'from_num' => $from,
        'to_num' => $to,
        'operator' => 'test-operator',
        'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
        'region_normalized' => null,
        'subject_code' => $subjectCode,
        'imported_at' => now(),
        'import_id' => $importId,
    ]);
}

// ===========================================================================
// G5a — qc=2 (мусор) / qc=7 (иностранец) → tag-fallback immediately
//        (Россвязь is NOT consulted)
// ===========================================================================

it('G5a: qc=2 + empty tag → source=unknown (FINDING: plan says tag, actual is unknown for empty tag)', function (): void {
    // FINDING: The plan §6.4 G5a says "source='tag' immediately" for qc=2/7.
    // Reality: resolver calls tagFallback(); with empty tag, tagCode=null → source='unknown'.
    // This was also found and documented as F2 in RegionResolverCascadeTest.
    // We assert REAL behaviour.
    config(['services.dadata.enabled' => true]);

    $fake = (new FakeDaDataPhoneClient)->stub('79990000201', qc: 2, region: null, provider: null);
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79990000201', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    // REAL behaviour: empty tag → tagCode=null → source='unknown', NOT 'tag'
    expect($res->source)->toBe('unknown')
        ->and($res->subjectCode)->toBeNull()
        ->and($res->qc)->toBe(2)
        ->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2
})->group('imitation');

it('G5a: qc=2 + valid tag → source=tag (Россвязь still skipped)', function (): void {
    // When qc=2 and tag resolves to a known region: source='tag'.
    // Россвязь is never consulted for qc=2 (the code path jumps to tagFallback).
    config(['services.dadata.enabled' => true]);

    $fake = (new FakeDaDataPhoneClient)->stub('79990000202', qc: 2, region: null, provider: null);
    app()->instance(DaDataPhoneClient::class, $fake);

    $moscowCode = RussianRegions::nameToCode()['Москва'];
    $lead = makeG5Lead('79990000202', tag: 'Москва');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('tag')
        ->and($res->subjectCode)->toBe($moscowCode)
        ->and($res->qc)->toBe(2)
        ->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2
})->group('imitation');

it('G5a: qc=7 + empty tag → source=unknown (same as qc=2, Россвязь skipped)', function (): void {
    // qc=7 (иностранец) behaves identically to qc=2: goes straight to tagFallback().
    config(['services.dadata.enabled' => true]);

    $fake = (new FakeDaDataPhoneClient)->stub('79990000203', qc: 7, region: null, provider: null);
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79990000203', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('unknown')
        ->and($res->subjectCode)->toBeNull()
        ->and($res->qc)->toBe(7)
        ->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');

// ===========================================================================
// G5b — DaData unavailable (stubThrows → DaDataException) OR qc=1
//        + seeded phone_ranges range → source='rossvyaz'
// ===========================================================================

it('G5b: DaData throws DaDataException + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void {
    // DaData network failure / 5xx → resolver catches DaDataException → falls to Россвязь.
    // With a seeded phone_ranges row matching the phone → source='rossvyaz'.
    //
    // Phone 79885550211: def_code=988, subscriber=5550211
    config(['services.dadata.enabled' => true]);

    $tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // ordinal 77
    insertG5PhoneRange(defCode: 988, from: 5550000, to: 5559999, subjectCode: $tyumenCode);

    $fake = (new FakeDaDataPhoneClient)->stubThrows('79885550211');
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79885550211', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('rossvyaz')
        ->and($res->rossvyazMatched)->toBeTrue()
        ->and($res->subjectCode)->toBe($tyumenCode);
})->group('imitation');

it('G5b: qc=1 (не уточнён) + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void {
    // qc=1 falls through the qc=0/3 block and the qc=2/7 block → arrives at Россвязь step.
    // With a seeded phone range → source='rossvyaz'.
    //
    // Phone 79886660311: def_code=988, subscriber=6660311
    config(['services.dadata.enabled' => true]);

    $voronezCode = RussianRegions::nameToCode()['Воронежская область']; // ordinal 42
    insertG5PhoneRange(defCode: 988, from: 6660000, to: 6669999, subjectCode: $voronezCode);

    $fake = (new FakeDaDataPhoneClient)->stub('79886660311', qc: 1, region: null, provider: null);
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79886660311', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('rossvyaz')
        ->and($res->rossvyazMatched)->toBeTrue()
        ->and($res->subjectCode)->toBe($voronezCode);
})->group('imitation');

// ===========================================================================
// G5c — neither DaData (qc=2 routes to tagFallback) nor Россвязь range seeded,
//        and empty tag → source='unknown'
// ===========================================================================

it('G5c: qc=2 + no phone range seeded + empty tag → source=unknown', function (): void {
    // For qc=2, Россвязь is not consulted at all (code jumps straight to tagFallback).
    // Empty tag → tagCode=null → source='unknown'.
    // This is a pure tagFallback outcome with no external source resolved.
    config(['services.dadata.enabled' => true]);

    // No phone_ranges seeded for this phone.
    $fake = (new FakeDaDataPhoneClient)->stub('79990000304', qc: 2, region: null, provider: null);
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79990000304', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('unknown')
        ->and($res->subjectCode)->toBeNull()
        ->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');

it('G5c: DaData throws + no phone range seeded + empty tag → source=unknown', function (): void {
    // DaData exception path: resolver falls to Россвязь, but no range is seeded.
    // Россвязь returns null → falls to tagFallback with empty tag → source='unknown'.
    config(['services.dadata.enabled' => true]);

    // No phone_ranges seeded for this phone.
    $fake = (new FakeDaDataPhoneClient)->stubThrows('79990000305');
    app()->instance(DaDataPhoneClient::class, $fake);

    $lead = makeG5Lead('79990000305', tag: '');
    $res = app(LeadRegionResolver::class)->resolve($lead);

    expect($res->source)->toBe('unknown')
        ->and($res->subjectCode)->toBeNull()
        ->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');

// ===========================================================================
// G6 — VID dedup: same vid injected twice via HTTP controller
//      First  → 202 + status='accepted'
//      Second → 200 + status='already_processed' + same supplier_lead_id
//      supplier_leads count for that vid stays exactly 1
// ===========================================================================

it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_processed, count stays 1', function (): void {
    // Dedup is enforced in SupplierWebhookController::receive() lines 94-100.
    // The UNIQUE INDEX on supplier_leads.vid prevents a second INSERT.
    // The controller checks for existence BEFORE INSERT and returns early:
    //   if ($existing !== null) { return response()->json(['status' => 'already_processed', ...], 200); }
    //
    // We use the HTTP layer (not LeadInjector) because LeadInjector bypasses the controller.
    // RouteSupplierLeadJob is faked to prevent actual routing which requires full snapshot setup.

    Bus::fake();

    $vid = 987654321; // Deterministic vid, outside auto-generated ranges from LeadInjector

    $payload = [
        'vid' => $vid,
        'project' => 'B1_g6test.example.com',
        'phone' => '79991112233',
        'time' => time(),
        'tag' => 'Москва',
    ];

    // First request → should be accepted (202).
    $first = $this->postJson(
        '/api/webhook/supplier/'.G5G6_SECRET,
        $payload
    );

    $first->assertStatus(202);
    $first->assertJson(['status' => 'accepted']);
    $firstLeadId = $first->json('supplier_lead_id');
    expect($firstLeadId)->toBeInt()->toBeGreaterThan(0);

    // Verify exactly 1 SupplierLead row exists for this vid.
    expect(SupplierLead::where('vid', $vid)->count())->toBe(1);

    // Second request — same vid, same payload → dedup path (200).
    $second = $this->postJson(
        '/api/webhook/supplier/'.G5G6_SECRET,
        $payload
    );

    $second->assertStatus(200);

    // Exact response shape from controller lines 96-99:
    // { "status": "already_processed", "supplier_lead_id": <existing id> }
    $second->assertJson([
        'status' => 'already_processed',
        'supplier_lead_id' => $firstLeadId,
    ]);

    // supplier_leads count MUST still be 1 — no second row was created.
    expect(SupplierLead::where('vid', $vid)->count())->toBe(1);

    // RouteSupplierLeadJob dispatched exactly once (for the first request).
    // The second request returns early before any dispatch.
    Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
})->group('imitation');

it('G6: second request returns the SAME supplier_lead_id as the first', function (): void {
    // Focused assertion: the already_processed response echoes back the original id,
    // not a new one. This guards against a hypothetical bug where a new row was inserted
    // despite the dedup check (e.g. race) — though the UNIQUE INDEX prevents it at DB level.

    Bus::fake();

    $vid = 987654322; // Different deterministic vid from G6 test above

    $payload = [
        'vid' => $vid,
        'project' => 'B1_g6test.example.com',
        'phone' => '79991112244',
        'time' => time(),
    ];

    $first = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload);
    $second = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload);

    $first->assertStatus(202);
    $second->assertStatus(200);

    expect($second->json('supplier_lead_id'))->toBe($first->json('supplier_lead_id'));
    expect(SupplierLead::where('vid', $vid)->count())->toBe(1);
})->group('imitation');
