<?php

// Tests for audit:rebuild-chain command (Task 3).

declare(strict_types=1);

use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;

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

/**
 * Tests for audit:rebuild-chain command.
 *
 * Verifies that:
 * 1. The command recomputes log_hash values using the same formula as audit_chain_hash():
 *      digest(COALESCE(prev_hash, ''::bytea) || ROW(col1, ..., NULL::bytea, ..., coln)::text::bytea, 'sha256')
 * 2. The rebuilt hashes match what VerifyAuditChains expects (validates as intact).
 * 3. --dry-run does not modify hashes.
 * 4. Unknown partition names are rejected.
 *
 * Note: we use direct SQL verification (mirroring VerifyAuditChains logic)
 * rather than calling audit:verify-chains, because the full command checks ALL
 * partitions and a pre-existing mismatch in any other partition would cause
 * false failure. This keeps the test focused on our specific partition.
 */

/**
 * Check chain integrity for a specific partition using the same SQL as VerifyAuditChains.
 * Returns the count of mismatched rows (0 = intact).
 */
function checkPartitionIntegrity(string $partition, string $partitionClause, string $rowExpr): int
{
    $overClause = $partitionClause !== ''
        ? "({$partitionClause} ORDER BY id)"
        : '(ORDER BY id)';

    $sql = <<<SQL
        WITH ordered AS (
            SELECT
                id,
                log_hash AS stored_hash,
                LAG(log_hash) OVER {$overClause} AS prev_hash
            FROM {$partition}
        )
        SELECT count(*) AS cnt
        FROM ordered o
        WHERE o.stored_hash IS DISTINCT FROM
            digest(
                COALESCE(o.prev_hash, ''::bytea)
                || (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = o.id),
                'sha256'
            )
    SQL;

    $result = DB::connection('pgsql_supplier')->selectOne($sql);

    return (int) ($result?->cnt ?? 0);
}

// Column list for activity_log (must match VerifyAuditChains::TABLE_CONFIG).
const ACTIVITY_LOG_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)';

// Column list for balance_transactions (must match VerifyAuditChains::TABLE_CONFIG).
const BALANCE_TX_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)';

it('audit:rebuild-chain repairs broken hash chain from given id in activity_log', function (): void {
    $tenant = Tenant::factory()->create();

    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    // Insert 3 valid rows via normal flow (trigger writes correct hashes).
    DB::table('activity_log')->insert([
        ['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.created', 'context' => null, 'created_at' => now()],
        ['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.updated', 'context' => null, 'created_at' => now()->addMicrosecond()],
        ['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.closed', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
    ]);

    $rows = DB::table('activity_log')
        ->where('tenant_id', $tenant->id)
        ->orderBy('id')
        ->get(['id', 'log_hash', 'event']);

    expect($rows)->toHaveCount(3);

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');

    // Verify initial state: chain is intact for our tenant's rows.
    $initialMismatches = checkPartitionIntegrity(
        $partition,
        'PARTITION BY tenant_id',
        ACTIVITY_LOG_ROW_EXPR,
    );
    expect($initialMismatches)->toBe(0, 'Initial chain should be intact');

    // Manually corrupt row 2's log_hash (simulating race-condition branch).
    DB::statement("SET session_replication_role = 'replica'");
    DB::statement('UPDATE activity_log SET log_hash = \'\\xdeadbeef\'::bytea WHERE id = '.$rows[1]->id);
    DB::statement("SET session_replication_role = 'origin'");

    // Verify: now there's a mismatch (row 2 + row 3 that depends on row 2).
    $mismatchesBefore = checkPartitionIntegrity(
        $partition,
        'PARTITION BY tenant_id',
        ACTIVITY_LOG_ROW_EXPR,
    );
    expect($mismatchesBefore)->toBeGreaterThan(0, 'Chain should have mismatch after corruption');

    // Rebuild from the corrupted row onwards.
    $fromId = $rows[1]->id;

    $exitRebuild = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $fromId,
        '--force' => true,
    ]);
    expect($exitRebuild)->toBe(0);

    // Verify: chain is now intact again.
    $mismatchesAfter = checkPartitionIntegrity(
        $partition,
        'PARTITION BY tenant_id',
        ACTIVITY_LOG_ROW_EXPR,
    );
    expect($mismatchesAfter)->toBe(0, 'Chain should be intact after rebuild');

    // Verify the hashes actually changed (the corrupt value was replaced).
    $rebuilt = DB::table('activity_log')
        ->where('tenant_id', $tenant->id)
        ->where('id', '>=', $fromId)
        ->orderBy('id')
        ->pluck('log_hash');

    foreach ($rebuilt as $hash) {
        // BYTEA columns returned as PHP stream resources via PDO pgsql driver.
        $bin = is_resource($hash) ? stream_get_contents($hash) : (string) $hash;
        expect(bin2hex($bin))->not->toBe('deadbeef')
            ->and(strlen($bin))->toBe(32); // sha256 = 32 bytes
    }
});

it('audit:rebuild-chain works for balance_transactions partition', function (): void {
    $tenant = Tenant::factory()->create();

    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    DB::table('balance_transactions')->insert([
        ['tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 100, 'amount_leads' => 0, 'created_at' => now()],
        ['tenant_id' => $tenant->id, 'type' => 'lead_charge', 'amount_rub' => -10, 'amount_leads' => 0, 'created_at' => now()->addMicrosecond()],
    ]);

    $rows = DB::table('balance_transactions')
        ->where('tenant_id', $tenant->id)
        ->orderBy('id')
        ->get(['id', 'log_hash']);

    expect($rows)->toHaveCount(2);

    $partition = 'balance_transactions_y'.now()->format('Y').'_m'.now()->format('m');

    // Corrupt second row.
    DB::statement("SET session_replication_role = 'replica'");
    DB::statement('UPDATE balance_transactions SET log_hash = \'\\xbaadf00d\'::bytea WHERE id = '.$rows[1]->id);
    DB::statement("SET session_replication_role = 'origin'");

    $mismatchesBefore = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
    expect($mismatchesBefore)->toBeGreaterThan(0);

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $rows[1]->id,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $mismatchesAfter = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
    expect($mismatchesAfter)->toBe(0, 'Balance transaction chain should be intact after rebuild');
});

it('audit:rebuild-chain --dry-run does not modify hashes', function (): void {
    $tenant = Tenant::factory()->create();

    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    DB::table('activity_log')->insert([
        ['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'dry.run.test', 'context' => null, 'created_at' => now()],
    ]);

    $row = DB::table('activity_log')
        ->where('tenant_id', $tenant->id)
        ->orderByDesc('id')
        ->first(['id', 'log_hash']);

    // Corrupt the hash.
    DB::statement("SET session_replication_role = 'replica'");
    DB::statement('UPDATE activity_log SET log_hash = \'\\xcafebabe\'::bytea WHERE id = '.$row->id);
    DB::statement("SET session_replication_role = 'origin'");

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');

    Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $row->id,
        '--dry-run' => true,
    ]);

    // Hash must remain corrupted — dry-run made no changes.
    // BYTEA columns are returned as PHP stream resources via PDO pgsql driver.
    $afterRaw = DB::table('activity_log')->where('id', $row->id)->value('log_hash');
    $afterBin = is_resource($afterRaw) ? stream_get_contents($afterRaw) : (string) $afterRaw;
    expect(bin2hex($afterBin))->toBe('cafebabe');
});

it('audit:rebuild-chain rejects unknown partition names', function (): void {
    Artisan::call('audit:rebuild-chain', [
        '--partition' => 'deals_y2026_m05',  // not an audit table
        '--from-id' => 1,
        '--force' => true,
    ]);
    expect(Artisan::output())->toContain('поддерживаемым аудит-таблицам');
});

// ──────────────────────────────────────────────────────────────────────────────
// ADR-018 Task 3: failing tests для per-tenant rebuild (RED phase).
// После Task 4 (per-tenant LAG OVER) — должны стать PASS.
// ──────────────────────────────────────────────────────────────────────────────

// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';

it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    // Tenant A — 2 rows.
    DB::statement('SET app.current_tenant_id = '.$tenantA->id);
    DB::table('activity_log')->insert([
        ['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a1', 'context' => null, 'created_at' => now()],
        ['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a2', 'context' => null, 'created_at' => now()->addMicrosecond()],
    ]);

    // Tenant B — 2 rows (interleaved IDs with tenant A, но цепочка независимая per-tenant).
    DB::statement('SET app.current_tenant_id = '.$tenantB->id);
    DB::table('activity_log')->insert([
        ['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b1', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
        ['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b2', 'context' => null, 'created_at' => now()->addMicroseconds(3)],
    ]);

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');

    // NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
    // `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
    // global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
    // (валидация — live `audit:verify-chains` на проде, не в этом тесте).
    //
    // Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
    // partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
    // verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
    // rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
});

it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
    // auth_log пишется под BYPASSRLS pre-auth role. INSERT direct через pgsql_supplier.
    DB::connection('pgsql_supplier')->table('auth_log')->insert([
        ['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'a@x.com', 'created_at' => now()],
        ['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'b@x.com', 'created_at' => now()->addMicrosecond()],
    ]);

    $partition = 'auth_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');

    $preMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
    expect($preMismatches)->toBe(0, 'Trigger writes global chain correctly for auth_log');

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $postMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0, 'Rebuild должен сохранить global chain для BYPASSRLS-таблицы');
});

it('audit:rebuild-chain handles single-row partition (first row of tenant) корректно', function (): void {
    $tenant = Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    DB::table('activity_log')->insert([
        'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
        'event' => 'deal.solo', 'context' => null, 'created_at' => now(),
    ]);

    $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
    $firstId = (int) DB::connection('pgsql_supplier')->table($partition)
        ->where('tenant_id', $tenant->id)
        ->min('id');

    $exit = Artisan::call('audit:rebuild-chain', [
        '--partition' => $partition,
        '--from-id' => $firstId,
        '--force' => true,
    ]);
    expect($exit)->toBe(0);

    $postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
    expect($postMismatches)->toBe(0, 'Single-row per-tenant partition должен остаться intact');
});

// ──────────────────────────────────────────────────────────────────────────────
// Managed PG (Путь А): пересчёт аудита БЕЗ session_replication_role (superuser-only).
// audit_block_mutation должен пропускать мутацию по метке app.audit_rebuild='on'
// (+ membership в crm_migrator / superuser), а без метки — запрещать (append-only).
// ──────────────────────────────────────────────────────────────────────────────
it('audit_block_mutation чтит метку app.audit_rebuild (без session_replication_role)', function (): void {
    $tenant = Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$tenant->id);

    DB::table('activity_log')->insert([
        'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
        'event' => 'flag.test', 'context' => null, 'created_at' => now(),
    ]);
    $id = (int) DB::table('activity_log')->where('tenant_id', $tenant->id)->orderByDesc('id')->value('id');

    // БЕЗ метки — UPDATE аудита запрещён (append-only). В savepoint, чтобы внешняя
    // транзакция теста пережила ожидаемую ошибку PostgreSQL.
    $blocked = false;
    try {
        DB::transaction(function () use ($id) {
            DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
        });
    } catch (Throwable $e) {
        $blocked = true;
    }
    expect($blocked)->toBeTrue('UPDATE аудита без метки должен быть запрещён');

    // С меткой app.audit_rebuild='on' — UPDATE проходит, БЕЗ session_replication_role.
    DB::transaction(function () use ($id) {
        DB::statement("SET LOCAL app.audit_rebuild = 'on'");
        DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
    });
    expect(true)->toBeTrue(); // дошли без исключения = метка сработала
});
