<?php

declare(strict_types=1);

use App\Mail\AuditChainBreachMail;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;

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

// ---------------------------------------------------------------------------
// Helper: ensure at least one active saas_admin_user row exists (FK for incidents_log)
// ---------------------------------------------------------------------------
function ensureAuditAdmin(): int
{
    $id = DB::table('saas_admin_users')
        ->where('is_active', true)
        ->whereNull('deleted_at')
        ->value('id');

    if ($id !== null) {
        return (int) $id;
    }

    return (int) DB::table('saas_admin_users')->insertGetId([
        'email' => 'audit-cron@liderra.ru',
        'full_name' => 'Audit Cron',
        'password_hash' => '$2y$12$placeholder',
        'role' => 'dev_oncall',
        'is_active' => true,
        'created_at' => now(),
    ]);
}

// ---------------------------------------------------------------------------
// Helper: insert N rows into auth_log via the normal path (trigger fills log_hash).
// Uses the 3rd constraint variant: actor_type='tenant_user', user_id=NULL,
// saas_admin_user_id=NULL, email IS NOT NULL (login attempt with unknown email).
// ---------------------------------------------------------------------------
function insertAuthLogRows(int $n, ?int $tenantId = null): void
{
    for ($i = 0; $i < $n; $i++) {
        DB::table('auth_log')->insert([
            'actor_type' => 'tenant_user',
            'tenant_id' => $tenantId,
            'email' => "test{$i}@example.com",
            'event' => 'login_failed',
            'ip_address' => '127.0.0.1',
            'created_at' => now(),
        ]);
    }
}

// ---------------------------------------------------------------------------
// Helper: ensure a tenant row exists, return its id.
// ---------------------------------------------------------------------------
function ensureTenant(int $seed): int
{
    $existing = DB::table('tenants')->where('subdomain', "test-chain-{$seed}")->value('id');
    if ($existing !== null) {
        return (int) $existing;
    }

    return (int) DB::table('tenants')->insertGetId([
        'organization_name' => "Test Chain {$seed}",
        'subdomain' => "test-chain-{$seed}",
        'contact_email' => "chain{$seed}@example.com",
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
}

// ---------------------------------------------------------------------------
// Helper: insert one row into tenant_operations_log under a specific tenant.
// The trigger fills log_hash based on what it can SELECT (see notes below).
// ---------------------------------------------------------------------------
function insertTenantOpsRow(int $tenantId, string $event = 'project.created'): int
{
    return (int) DB::table('tenant_operations_log')->insertGetId([
        'tenant_id' => $tenantId,
        'entity_type' => 'project',
        'entity_id' => 1,
        'event' => $event,
        'created_at' => now(),
    ]);
}

// ---------------------------------------------------------------------------
// Helper: build per-tenant chained hashes in SQL (mirrors the validator logic).
//
// This computes what the per-scope validator expects: for each row in
// tenant_operations_log, prev_hash = LAG(log_hash) OVER (PARTITION BY tenant_id
// ORDER BY id). We use this to manually seed correctly-chained rows when we
// need to bypass the trigger (which on dev/superuser chains globally).
//
// Returns array of ['id' => int, 'hash' => resource] sorted by id.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(function () {
    ensureAuditAdmin();
    Mail::fake();
});

// ===========================================================================
// TDD ANCHOR: clean chain must verify intact (serialization correctness gate)
// ===========================================================================

test('clean auth_log chain verifies intact', function () {
    insertAuthLogRows(3);

    $exitCode = Artisan::call('audit:verify-chains');
    $out = Artisan::output();
    if ($exitCode !== 0) {
        dump('OUTPUT:', $out);
    }
    expect($exitCode)->toBe(0);

    // No incident should be created for an intact chain
    $count = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('summary', 'like', '%chain%auth_log%')
        ->count();

    expect($count)->toBe(0);

    // No email should be sent
    Mail::assertNothingSent();
});

test('empty tables are skipped gracefully (no false positive)', function () {
    // auth_log might have rows from other tests but other tables are empty on dev.
    // The command must not raise an error on empty tables.
    $this->artisan('audit:verify-chains')->assertSuccessful();
    Mail::assertNothingSent();
});

test('multiple rows in auth_log all pass intact', function () {
    insertAuthLogRows(10);

    $this->artisan('audit:verify-chains')->assertSuccessful();

    Mail::assertNothingSent();
});

// ===========================================================================
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
// data with multiple tenants.
//
// Problem reproduced on prod: the old global validator computed LAG OVER
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
// but the global recompute gave the same thing — so it was fine globally.
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
// not off A_last. The global validator then reported a false breach at every
// tenant boundary.
//
// This test replicates PROD conditions by bypassing the trigger and manually
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
// would produce under RLS on prod). The per-scope validator must then report
// INTACT, confirming the partition logic is correct.
//
// Why bypass the trigger for this test:
//   On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
//   sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
//   chain regardless of the app.current_tenant_id GUC. To test the validator's
//   per-partition recompute, we need rows whose stored hashes were computed
//   with per-tenant prev_hash — exactly what the trigger produces on prod.
//   We reproduce that by disabling triggers and computing the hashes ourselves
//   using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
//   per-partition LAG, matching the validator's own recompute SQL.
// ===========================================================================

test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
    $tid1 = ensureTenant(1);
    $tid2 = ensureTenant(2);

    // Disable triggers so we can insert rows with manually computed hashes
    // that simulate per-tenant chaining (what the trigger produces on prod).
    DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');

    try {
        // Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
        // We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
        // (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)

        $rows = [
            ['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
            ['tenant_id' => $tid2, 'entity_type' => 'api_key',  'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
            ['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
            ['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
            ['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
        ];

        $ids = [];
        foreach ($rows as $row) {
            // log_hash left NULL; we will fill it below via SQL
            $ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
        }

        // Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
        // mirroring exactly what the trigger produces under RLS on prod:
        //   log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
        // where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
        //
        // We do this in id-order one row at a time so each hash feeds the next.
        $idList = implode(',', $ids);

        DB::statement(<<<SQL
            WITH ranked AS (
                SELECT
                    id,
                    tenant_id,
                    ROW(id, tenant_id, user_id, entity_type, entity_id, event,
                        payload_before, payload_after, ip_address, user_agent,
                        NULL::bytea, created_at) AS row_val,
                    ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
                FROM tenant_operations_log
                WHERE id IN ({$idList})
            )
            UPDATE tenant_operations_log tgt
            SET log_hash = (
                WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
                    -- Base: first row per tenant (rn=1), prev_hash = ''
                    SELECT r.id, r.tenant_id, r.rn,
                           digest(''::bytea || r.row_val::text::bytea, 'sha256')
                    FROM ranked r
                    WHERE r.rn = 1
                    UNION ALL
                    -- Recursive: each subsequent row chains off previous
                    SELECT r.id, r.tenant_id, r.rn,
                           digest(c.hash || r.row_val::text::bytea, 'sha256')
                    FROM chain c
                    JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
                )
                SELECT hash FROM chain WHERE id = tgt.id
            )
            WHERE id IN ({$idList})
        SQL);
    } finally {
        DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
    }

    // Verify all 5 rows got their hashes set (sanity check on the SQL above)
    $nullCount = DB::table('tenant_operations_log')
        ->whereIn('id', $ids)
        ->whereNull('log_hash')
        ->count();
    expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');

    // The per-scope validator must report INTACT — no false breach at tenant boundary
    $this->artisan('audit:verify-chains')->assertSuccessful();

    Mail::assertNothingSent();
});

// ===========================================================================
// Tampering detection
// ===========================================================================

test('tampered auth_log row raises incident and sends email', function () {
    insertAuthLogRows(3);

    // Sanity: intact before tampering
    $this->artisan('audit:verify-chains')->assertSuccessful();
    Mail::assertNothingSent();

    // Tamper: disable triggers, mutate the first row's ip_address, re-enable.
    // DISABLE TRIGGER USER disables all user-defined triggers (audit_block_mutation
    // is BEFORE UPDATE, so without disabling it the UPDATE would be blocked).
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')
        ->orderBy('id')
        ->limit(1)
        ->update(['ip_address' => '6.6.6.6']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    // Now the command must detect the breach
    $this->artisan('audit:verify-chains')->assertFailed();

    $incident = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('type', 'other')
        ->where('severity', 'high')
        ->where('summary', 'like', '%chain%auth_log%')
        ->first();

    expect($incident)->not->toBeNull();
    expect($incident->summary)->toContain('auth_log');

    Mail::assertSent(AuditChainBreachMail::class, function ($mail) {
        return $mail->tableName === 'auth_log';
    });
});

test('incident dedup: same table breach does not create duplicate within 24h', function () {
    insertAuthLogRows(3);

    // First tamper
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')
        ->orderBy('id')
        ->limit(1)
        ->update(['ip_address' => '5.5.5.5']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    $this->artisan('audit:verify-chains')->assertFailed();

    $countAfterFirst = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('summary', 'like', '%chain%auth_log%')
        ->count();
    expect($countAfterFirst)->toBe(1);

    // Second run (same ongoing breach) must NOT create a second incident (dedup)
    $this->artisan('audit:verify-chains')->assertFailed();

    $countAfterSecond = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('summary', 'like', '%chain%auth_log%')
        ->count();
    expect($countAfterSecond)->toBe(1);
});

// ===========================================================================
// PARTITION-AWARE: breach in one partition must record incident with
// partition_name; other partitions must remain intact (no false positive).
// ===========================================================================

test('breach in one partition is detected; other partitions reported intact', function () {
    // Insert 3 rows into auth_log — trigger assigns log_hash correctly.
    // After the migration, auth_log is partitioned by month; all test rows
    // go into the current month's partition (e.g. auth_log_y2026_m05).
    insertAuthLogRows(3);

    // Sanity: chain intact before tampering.
    $this->artisan('audit:verify-chains')->assertSuccessful();
    Mail::assertNothingSent();

    // Determine which partition the inserted rows landed in, so we can assert
    // the incident summary references that partition, not just "auth_log".
    //
    // Rows are inserted with created_at = now(), so they land in the partition
    // whose range covers the current month.  We derive the expected name the
    // same way buildPartitionDDL() does:  {table}_y{YYYY}_m{MM}.
    //
    // Fallback: if auth_log has no children (migration not applied), we expect
    // the incident to reference 'auth_log' itself (command's fallback path).
    $partitionCount = DB::selectOne(
        "SELECT COUNT(*) AS cnt
         FROM pg_inherits i
         JOIN pg_class p ON p.oid = i.inhparent
         WHERE p.relname = 'auth_log'",
    )->cnt ?? 0;

    $expectedPartition = $partitionCount > 0
        ? sprintf('auth_log_y%s_m%s', now()->format('Y'), now()->format('m'))
        : 'auth_log';

    // Tamper the first row in auth_log (which lands in $expectedPartition).
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')
        ->orderBy('id')
        ->limit(1)
        ->update(['ip_address' => '7.7.7.7']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    // Command must fail and reference the partition in the incident summary.
    $this->artisan('audit:verify-chains')->assertFailed();

    $incident = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('type', 'other')
        ->where('severity', 'high')
        ->where('summary', 'like', '%chain%auth_log%')
        ->orderByDesc('id')
        ->first();

    expect($incident)->not->toBeNull('An incident must be recorded on chain breach');
    // The incident summary must mention the specific partition (or the table if not yet partitioned).
    expect($incident->summary)->toContain($expectedPartition);

    // Email must be sent referencing the correct partition.
    Mail::assertSent(AuditChainBreachMail::class, function ($mail) use ($expectedPartition) {
        // partitionName is the 4th constructor arg (nullable); falls back to tableName.
        $actualPartition = $mail->partitionName ?? $mail->tableName;

        return $actualPartition === $expectedPartition && $mail->tableName === 'auth_log';
    });
});

// ===========================================================================
// EXIT CODE: breach must always return FAILURE regardless of incident write
// ===========================================================================

// ===========================================================================
// AUTO-RESOLVE: when a table's chain is fully intact, a previously-open
// chain incident for that table must be auto-resolved (stale-incident cleanup).
// Driven by FN-AUDIT (acceptance run 22.06): transient breaches on test-tenant
// rows left incidents open after teardown deleted the offending rows.
// ===========================================================================

test('intact table auto-resolves a previously-open chain incident', function () {
    $adminId = ensureAuditAdmin();
    insertAuthLogRows(3); // auth_log chain intact

    // Seed a stale open incident for auth_log, exactly as VerifyAuditChains writes it.
    $incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([
        'type' => 'other',
        'severity' => 'high',
        'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log). Первый сломанный id=1, всего несовпадений=1.',
        'root_cause' => null,
        'started_at' => now()->subDay(),
        'detected_at' => now()->subDay(),
        'resolved_at' => null,
        'created_by_admin_id' => $adminId,
        'created_at' => now()->subDay(),
        'updated_at' => now()->subDay(),
    ]);

    Artisan::call('audit:verify-chains');

    $incident = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('id', $incidentId)
        ->first();

    expect($incident->resolved_at)->not->toBeNull();
    expect((string) $incident->root_cause)->toContain('auth_log');
});

test('breached table does not auto-resolve its open chain incident', function () {
    $adminId = ensureAuditAdmin();
    insertAuthLogRows(3);

    // Seed an open incident for auth_log.
    $incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([
        'type' => 'other',
        'severity' => 'high',
        'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log).',
        'root_cause' => null,
        'started_at' => now()->subDay(),
        'detected_at' => now()->subDay(),
        'resolved_at' => null,
        'created_by_admin_id' => $adminId,
        'created_at' => now()->subDay(),
        'updated_at' => now()->subDay(),
    ]);

    // Tamper auth_log so its chain is broken at run time.
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')
        ->orderBy('id')
        ->limit(1)
        ->update(['ip_address' => '4.4.4.4']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    Artisan::call('audit:verify-chains');

    $incident = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('id', $incidentId)
        ->first();

    expect($incident->resolved_at)->toBeNull();
});

test('exit code is FAILURE on breach even when no active admin exists for incident FK', function () {
    // Remove all active admins so recordIncident cannot write the FK row
    DB::table('saas_admin_users')->update(['is_active' => false]);

    insertAuthLogRows(2);

    // Tamper
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')
        ->orderBy('id')
        ->limit(1)
        ->update(['ip_address' => '9.9.9.9']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    // Must FAIL even though the incident row cannot be written (no active admin)
    $this->artisan('audit:verify-chains')->assertFailed();

    // No incident row written (no active admin)
    $count = DB::connection('pgsql_supplier')
        ->table('incidents_log')
        ->where('summary', 'like', '%chain%auth_log%')
        ->count();
    expect($count)->toBe(0);

    // But email alert must still be sent
    Mail::assertSent(AuditChainBreachMail::class);
});
