<?php

declare(strict_types=1);

use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Audit\AuditActionId;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

afterEach(function (): void {
    CarbonImmutable::setTestNow();
});

it('enforces the canonical transition matrix for service-driven status changes', function (): void {
    [$user, $tenant] = createUserWithTenant(role: 'owner');

    $service = app(FindingWorkflowService::class);

    $newFinding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
    $triagedFinding = $service->triage($newFinding, $tenant, $user);

    expect($triagedFinding->status)->toBe(Finding::STATUS_TRIAGED)
        ->and($this->latestFindingAudit($triagedFinding, AuditActionId::FindingTriaged))->not->toBeNull();

    $inProgressFinding = $service->startProgress($triagedFinding, $tenant, $user);

    expect($inProgressFinding->status)->toBe(Finding::STATUS_IN_PROGRESS)
        ->and($this->latestFindingAudit($inProgressFinding, AuditActionId::FindingInProgress))->not->toBeNull();

    $resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
    $resolvedAudit = $this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved);

    expect($resolvedFinding->status)->toBe(Finding::STATUS_RESOLVED)
        ->and($resolvedFinding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
        ->and($resolvedAudit)->not->toBeNull()
        ->and(data_get($resolvedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
        ->and(data_get($resolvedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
        ->and(data_get($resolvedAudit?->metadata, 'report_bucket'))->toBe('remediation_pending_verification');

    $reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
    $reopenedAudit = $this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened);

    expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED)
        ->and($reopenedFinding->reopened_at)->not->toBeNull()
        ->and($reopenedAudit)->not->toBeNull()
        ->and(data_get($reopenedAudit?->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT)
        ->and(data_get($reopenedAudit?->metadata, 'terminal_outcome_key'))->toBeNull();

    expect(fn () => $service->startProgress($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user))
        ->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
});

it('enforces tenant-member validation for owner and assignee reassignment', function (): void {
    [$owner, $tenant] = createUserWithTenant(role: 'owner');
    $assignee = User::factory()->create();
    createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
    $outsider = User::factory()->create();

    $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED);
    $service = app(FindingWorkflowService::class);

    $assignedFinding = $service->assign(
        finding: $finding,
        tenant: $tenant,
        actor: $owner,
        assigneeUserId: (int) $assignee->getKey(),
        ownerUserId: (int) $owner->getKey(),
    );

    $audit = $this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned);

    expect((int) $assignedFinding->assignee_user_id)->toBe((int) $assignee->getKey())
        ->and((int) $assignedFinding->owner_user_id)->toBe((int) $owner->getKey())
        ->and($audit)->not->toBeNull()
        ->and(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe('owner_and_assignee')
        ->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe('Updated the accountable owner and the active assignee.');

    expect(fn () => $service->assign(
        finding: $assignedFinding,
        tenant: $tenant,
        actor: $owner,
        assigneeUserId: (int) $outsider->getKey(),
        ownerUserId: (int) $owner->getKey(),
    ))->toThrow(\InvalidArgumentException::class, 'assignee_user_id must reference a workspace member with access to this environment.');
});

it('keeps 404 and 403 semantics distinct for assignment authorization', function (): void {
    [$owner, $tenant] = createUserWithTenant(role: 'owner');
    [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
    $outsider = User::factory()->create();
    $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);

    $service = app(FindingWorkflowService::class);

    expect(fn () => $service->assign(
        finding: $finding,
        tenant: $tenant,
        actor: $outsider,
        assigneeUserId: null,
        ownerUserId: (int) $owner->getKey(),
    ))->toThrow(NotFoundHttpException::class);

    expect(fn () => $service->assign(
        finding: $finding,
        tenant: $tenant,
        actor: $readonly,
        assigneeUserId: null,
        ownerUserId: (int) $owner->getKey(),
    ))->toThrow(AuthorizationException::class);
});

it('requires explicit reasons for resolve close and risk accept mutations', function (): void {
    [$user, $tenant] = createUserWithTenant(role: 'owner');

    $service = app(FindingWorkflowService::class);

    expect(fn () => $service->resolve($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, '   '))
        ->toThrow(\InvalidArgumentException::class, 'resolved_reason is required.');

    expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, '   '))
        ->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');

    expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, '   '))
        ->toThrow(\InvalidArgumentException::class, 'reopen_reason is required.');

    expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, '   '))
        ->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
});

it('enforces canonical manual reason keys for terminal workflow mutations', function (): void {
    [$user, $tenant] = createUserWithTenant(role: 'owner');

    $service = app(FindingWorkflowService::class);

    expect(fn () => $service->resolve($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'patched'))
        ->toThrow(\InvalidArgumentException::class, 'resolved_reason must be one of: remediated.');

    expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'not applicable'))
        ->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: false_positive, duplicate, no_longer_applicable.');

    expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, 'please re-open'))
        ->toThrow(\InvalidArgumentException::class, 'reopen_reason must be one of: recurred_after_resolution, verification_failed, manual_reassessment.');

    expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'accepted'))
        ->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: accepted_risk.');
});

it('records canonical close and risk-accept outcome metadata', function (): void {
    [$user, $tenant] = createUserWithTenant(role: 'owner');

    $service = app(FindingWorkflowService::class);

    $closed = $service->close(
        $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
        $tenant,
        $user,
        Finding::CLOSE_REASON_DUPLICATE,
    );
    $closedAudit = $this->latestFindingAudit($closed, AuditActionId::FindingClosed);

    expect($closed->status)->toBe(Finding::STATUS_CLOSED)
        ->and($closed->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
        ->and(data_get($closedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE)
        ->and(data_get($closedAudit?->metadata, 'report_bucket'))->toBe('administrative_closure');

    $riskAccepted = $service->riskAccept(
        $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
        $tenant,
        $user,
        Finding::CLOSE_REASON_ACCEPTED_RISK,
    );
    $riskAudit = $this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted);

    expect($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
        ->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK)
        ->and(data_get($riskAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED)
        ->and(data_get($riskAudit?->metadata, 'report_bucket'))->toBe('accepted_risk');
});

it('distinguishes verified clear from manual resolution in system transitions', function (): void {
    [$user, $tenant] = createUserWithTenant(role: 'owner');

    CarbonImmutable::setTestNow('2026-04-23T10:00:00Z');

    $service = app(FindingWorkflowService::class);

    $manualResolved = $service->resolve(
        $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
        $tenant,
        $user,
        Finding::RESOLVE_REASON_REMEDIATED,
    );

    $verified = $service->resolveBySystem(
        finding: $manualResolved,
        tenant: $tenant,
        reason: Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
        resolvedAt: CarbonImmutable::parse('2026-04-23T11:00:00Z'),
    );
    $verifiedAudit = $this->latestFindingAudit($verified, AuditActionId::FindingResolved);

    expect($verified->resolved_reason)->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING)
        ->and(data_get($verifiedAudit?->metadata, 'system_origin'))->toBeTrue()
        ->and(data_get($verifiedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
        ->and(data_get($verifiedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED)
        ->and(data_get($verifiedAudit?->metadata, 'report_bucket'))->toBe('remediation_verified');

    $verificationFailed = $service->reopenBySystem(
        finding: $service->resolve(
            $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
            $tenant,
            $user,
            Finding::RESOLVE_REASON_REMEDIATED,
        ),
        tenant: $tenant,
        reopenedAt: CarbonImmutable::parse('2026-04-23T12:00:00Z'),
    );
    $verificationFailedAudit = $this->latestFindingAudit($verificationFailed, AuditActionId::FindingReopened);

    expect(data_get($verificationFailedAudit?->metadata, 'reopened_reason'))
        ->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED);

    $recurred = $service->reopenBySystem(
        finding: $verified,
        tenant: $tenant,
        reopenedAt: CarbonImmutable::parse('2026-04-23T13:00:00Z'),
    );
    $recurredAudit = $this->latestFindingAudit($recurred, AuditActionId::FindingReopened);

    expect(data_get($recurredAudit?->metadata, 'reopened_reason'))
        ->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION);
});

it('returns 403 for in-scope members without the required workflow capability', function (): void {
    [$owner, $tenant] = createUserWithTenant(role: 'owner');
    [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
    $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);

    expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, Finding::RESOLVE_REASON_REMEDIATED))
        ->toThrow(AuthorizationException::class);

    expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED)->status)
        ->toBe(Finding::STATUS_RESOLVED);
});
