<?php

declare(strict_types=1);

use App\Models\AlertRule;
use App\Models\Finding;
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function gaRoleDef(): array
{
    return [
        'id' => 'def-ga',
        'displayName' => 'Global Administrator',
        'templateId' => '62e90394-69f5-4237-9190-012177145e10',
        'isBuiltIn' => true,
    ];
}

function secAdminRoleDef(): array
{
    return [
        'id' => 'def-secadmin',
        'displayName' => 'Security Administrator',
        'templateId' => '194ae4cb-b126-40b2-bd5b-6091b380977d',
        'isBuiltIn' => true,
    ];
}

function readerRoleDef(): array
{
    return [
        'id' => 'def-reader',
        'displayName' => 'Directory Readers',
        'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601',
        'isBuiltIn' => true,
    ];
}

function makeEntraAssignment(string $id, string $roleDefId, string $principalId, string $odataType = '#microsoft.graph.user', string $displayName = 'User', string $scopeId = '/'): array
{
    return [
        'id' => $id,
        'roleDefinitionId' => $roleDefId,
        'principalId' => $principalId,
        'directoryScopeId' => $scopeId,
        'principal' => [
            '@odata.type' => $odataType,
            'displayName' => $displayName,
        ],
    ];
}

function buildPayload(array $roleDefinitions, array $roleAssignments, ?string $measuredAt = null): array
{
    return [
        'provider_key' => 'microsoft',
        'domain' => 'entra.admin_roles',
        'measured_at' => $measuredAt ?? CarbonImmutable::now('UTC')->toIso8601String(),
        'role_definitions' => $roleDefinitions,
        'role_assignments' => $roleAssignments,
        'totals' => [
            'roles_total' => count($roleDefinitions),
            'assignments_total' => count($roleAssignments),
            'high_privilege_assignments' => 0,
        ],
        'high_privilege' => [],
    ];
}

function makeGenerator(): EntraAdminRolesFindingGenerator
{
    return new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

it('creates findings for high-privilege assignments with correct attributes', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $measuredAt = '2026-02-24T10:00:00Z';
    $payload = buildPayload(
        [gaRoleDef(), secAdminRoleDef()],
        [
            makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin'),
            makeEntraAssignment('a2', 'def-secadmin', 'user-2', '#microsoft.graph.user', 'Bob SecAdmin'),
        ],
        $measuredAt,
    );

    $result = makeGenerator()->generate($tenant, $payload);

    expect($result->created)->toBe(2)
        ->and($result->resolved)->toBe(0)
        ->and($result->reopened)->toBe(0)
        ->and($result->unchanged)->toBe(0);

    $findings = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->get();

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

    $gaFinding = $findings->firstWhere('severity', Finding::SEVERITY_CRITICAL);
    expect($gaFinding)->not->toBeNull()
        ->and($gaFinding->source)->toBe('entra.admin_roles')
        ->and($gaFinding->subject_type)->toBe('role_assignment')
        ->and($gaFinding->subject_external_id)->toBe('user-1:def-ga')
        ->and($gaFinding->status)->toBe(Finding::STATUS_NEW);
    expect($gaFinding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
        ->and($gaFinding->last_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
        ->and($gaFinding->times_seen)->toBe(1)
        ->and($gaFinding->sla_days)->toBe(3)
        ->and($gaFinding->due_at?->toIso8601String())->toBe('2026-02-27T10:00:00+00:00');

    $secFinding = $findings->firstWhere('severity', Finding::SEVERITY_HIGH);
    expect($secFinding)->not->toBeNull()
        ->and($secFinding->subject_external_id)->toBe('user-2:def-secadmin')
        ->and($secFinding->sla_days)->toBe(7)
        ->and($secFinding->due_at?->toIso8601String())->toBe('2026-03-03T10:00:00+00:00');
});

it('maps severity: GA is critical, others are high', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [gaRoleDef(), secAdminRoleDef()],
        [
            makeEntraAssignment('a1', 'def-ga', 'user-1'),
            makeEntraAssignment('a2', 'def-secadmin', 'user-2'),
        ],
    );

    makeGenerator()->generate($tenant, $payload);

    $findings = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->orderBy('severity')
        ->get();

    $severities = $findings->pluck('severity')->toArray();

    expect($severities)->toContain(Finding::SEVERITY_CRITICAL)
        ->and($severities)->toContain(Finding::SEVERITY_HIGH);
});

it('is idempotent — same data produces no duplicates', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload1 = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
        '2026-02-24T10:00:00Z',
    );

    $generator = makeGenerator();
    $first = $generator->generate($tenant, $payload1);

    $payload2 = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
        '2026-02-24T11:00:00Z',
    );
    $second = $generator->generate($tenant, $payload2);

    expect($first->created)->toBe(1)
        ->and($second->created)->toBe(0)
        ->and($second->unchanged)->toBe(1);

    $count = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->count();

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

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->first();

    expect($finding->times_seen)->toBe(2)
        ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
});

it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
    $generator->generate($tenant, buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
        '2026-02-24T10:00:00Z',
    ));

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->firstOrFail();

    $expectedDueAt = $finding->due_at?->toIso8601String();

    expect($finding->sla_days)->toBe(3)
        ->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00');

    $finding->forceFill([
        'sla_days' => null,
        'due_at' => null,
    ])->save();

    CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
    $result = $generator->generate($tenant, buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
        '2026-02-24T11:00:00Z',
    ));

    $finding->refresh();

    expect($result->created)->toBe(0)
        ->and($result->unchanged)->toBe(1)
        ->and($finding->status)->toBe(Finding::STATUS_NEW)
        ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
        ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
        ->and($finding->times_seen)->toBe(2)
        ->and($finding->sla_days)->toBe(3)
        ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);

    CarbonImmutable::setTestNow();
});

it('auto-resolves when assignment is removed', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    // First scan: user-1 has GA
    $payload1 = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
    );
    $generator->generate($tenant, $payload1);

    // Second scan: user-1 GA removed
    $payload2 = buildPayload([gaRoleDef()], []);
    $result2 = $generator->generate($tenant, $payload2);

    expect($result2->resolved)->toBeGreaterThanOrEqual(1);

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->where('subject_external_id', 'user-1:def-ga')
        ->first();

    expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
        ->and($finding->resolved_reason)->toBe('role_assignment_removed');
});

it('re-opens resolved finding when role is re-assigned', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    // Scan 1: create
    $payload1 = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice')],
        '2026-02-24T10:00:00Z',
    );
    $generator->generate($tenant, $payload1);

    // Scan 2: remove → auto-resolve
    $payload2 = buildPayload([gaRoleDef()], [], '2026-02-25T10:00:00Z');
    $generator->generate($tenant, $payload2);

    // Scan 3: re-assign → re-open
    $payload3 = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Reactivated')],
        '2026-02-26T10:00:00Z',
    );
    $result3 = $generator->generate($tenant, $payload3);

    expect($result3->reopened)->toBe(1);

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->where('subject_external_id', 'user-1:def-ga')
        ->first();

    expect($finding->status)->toBe(Finding::STATUS_REOPENED)
        ->and($finding->reopened_at?->toIso8601String())->toBe('2026-02-26T10:00:00+00:00')
        ->and($finding->resolved_at)->toBeNull()
        ->and($finding->resolved_reason)->toBeNull()
        ->and($finding->evidence_jsonb['principal_display_name'])->toBe('Alice Reactivated');
});

it('creates aggregate finding when GA count exceeds threshold', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $assignments = [];

    for ($i = 1; $i <= 6; $i++) {
        $assignments[] = makeEntraAssignment("a{$i}", 'def-ga', "user-{$i}", '#microsoft.graph.user', "GA User {$i}");
    }

    $payload = buildPayload([gaRoleDef()], $assignments);

    $result = makeGenerator()->generate($tenant, $payload);

    // 6 individual + 1 aggregate = 7 findings
    $findings = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->get();

    expect($findings)->toHaveCount(7);

    $aggregate = $findings->firstWhere('subject_external_id', 'ga_aggregate');
    expect($aggregate)->not->toBeNull()
        ->and($aggregate->severity)->toBe(Finding::SEVERITY_HIGH)
        ->and($aggregate->evidence_jsonb['count'])->toBe(6)
        ->and($aggregate->evidence_jsonb['threshold'])->toBe(5)
        ->and($aggregate->evidence_jsonb['principals'])->toHaveCount(6);
});

it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    // Scan 1: 6 GAs → aggregate created
    $assignments = [];

    for ($i = 1; $i <= 6; $i++) {
        $assignments[] = makeEntraAssignment("a{$i}", 'def-ga', "user-{$i}");
    }

    $generator->generate($tenant, buildPayload([gaRoleDef()], $assignments));

    // Scan 2: 5 GAs → aggregate auto-resolved
    $smallerAssignments = array_slice($assignments, 0, 5);
    $result2 = $generator->generate($tenant, buildPayload([gaRoleDef()], $smallerAssignments));

    expect($result2->resolved)->toBeGreaterThanOrEqual(1);

    $aggregate = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('subject_external_id', 'ga_aggregate')
        ->first();

    expect($aggregate->status)->toBe(Finding::STATUS_RESOLVED)
        ->and($aggregate->resolved_reason)->toBe('ga_count_within_threshold');
});

it('produces alert events for new and re-opened findings with severity >= high', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
    );

    $generator->generate($tenant, $payload);

    $events = $generator->getAlertEvents();
    expect($events)->not->toBeEmpty();

    $event = $events[0];
    expect($event['event_type'])->toBe(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
        ->and($event['managed_environment_id'])->toBe((int) $tenant->getKey());
});

it('produces no alert events for unchanged or resolved findings', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
    );

    // Scan 1: alert events produced for new finding
    $generator->generate($tenant, $payload);

    // Scan 2: unchanged — no new alert events
    $generator2 = makeGenerator();
    $generator2->generate($tenant, $payload);
    $events = $generator2->getAlertEvents();

    expect($events)->toBeEmpty();
});

it('evidence contains all required fields', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin')],
    );

    makeGenerator()->generate($tenant, $payload);

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->where('subject_external_id', 'user-1:def-ga')
        ->first();

    $evidence = $finding->evidence_jsonb;

    expect($evidence)->toHaveKeys([
        'role_display_name',
        'principal_display_name',
        'principal_type',
        'principal_id',
        'role_definition_id',
        'role_template_id',
        'directory_scope_id',
        'is_built_in',
        'measured_at',
    ])
        ->and($evidence['role_display_name'])->toBe('Global Administrator')
        ->and($evidence['principal_display_name'])->toBe('Alice Admin')
        ->and($evidence['principal_type'])->toBe('user')
        ->and($evidence['principal_id'])->toBe('user-1')
        ->and($evidence['role_definition_id'])->toBe('def-ga')
        ->and($evidence['is_built_in'])->toBeTrue();
});

it('handles all principal types correctly', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [gaRoleDef()],
        [
            makeEntraAssignment('a1', 'def-ga', 'p-user', '#microsoft.graph.user', 'User'),
            makeEntraAssignment('a2', 'def-ga', 'p-group', '#microsoft.graph.group', 'Group'),
            makeEntraAssignment('a3', 'def-ga', 'p-sp', '#microsoft.graph.servicePrincipal', 'SP'),
        ],
    );

    makeGenerator()->generate($tenant, $payload);

    $findings = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->get();

    $types = $findings->pluck('evidence_jsonb.principal_type')->sort()->values()->toArray();

    expect($types)->toContain('user')
        ->toContain('group')
        ->toContain('servicePrincipal');
});

it('subject_type and subject_external_id set on every finding', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
    );

    makeGenerator()->generate($tenant, $payload);

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->first();

    expect($finding->subject_type)->toBe('role_assignment')
        ->and($finding->subject_external_id)->toBe('user-1:def-ga');
});

it('auto-resolve applies to triaged findings too', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $generator = makeGenerator();

    // Scan 1: create
    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1')],
    );
    $generator->generate($tenant, $payload);

    // Triage the finding
    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('subject_external_id', 'user-1:def-ga')
        ->first();
    $finding->forceFill([
        'status' => Finding::STATUS_TRIAGED,
        'triaged_at' => now(),
    ])->save();

    expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED);

    // Scan 2: remove -> should auto-resolve even though triaged
    $payload2 = buildPayload([gaRoleDef()], []);
    $result = $generator->generate($tenant, $payload2);

    expect($result->resolved)->toBeGreaterThanOrEqual(1);

    $resolved = $finding->fresh();
    expect($resolved->status)->toBe(Finding::STATUS_RESOLVED)
        ->and($resolved->resolved_reason)->toBe('role_assignment_removed');
});

it('scoped assignments do not downgrade severity', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [gaRoleDef()],
        [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice', '/administrativeUnits/au-123')],
    );

    makeGenerator()->generate($tenant, $payload);

    $finding = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->first();

    expect($finding->severity)->toBe(Finding::SEVERITY_CRITICAL)
        ->and($finding->evidence_jsonb['directory_scope_id'])->toBe('/administrativeUnits/au-123');
});

it('does not create findings for non-high-privilege roles', function (): void {
    [$user, $tenant] = createMinimalUserWithTenant();

    $payload = buildPayload(
        [readerRoleDef()],
        [makeEntraAssignment('a1', 'def-reader', 'user-1')],
    );

    $result = makeGenerator()->generate($tenant, $payload);

    expect($result->created)->toBe(0);

    $count = Finding::query()
        ->where('managed_environment_id', $tenant->getKey())
        ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
        ->count();

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