<?php

declare(strict_types=1);

namespace App\Services\Findings;

use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final class FindingExceptionService
{
    public function __construct(
        private readonly CapabilityResolver $capabilityResolver,
        private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
        private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
        private readonly FindingWorkflowService $findingWorkflowService,
        private readonly FindingRiskGovernanceResolver $governanceResolver,
        private readonly AuditLogger $auditLogger,
    ) {}

    /**
     * @param  array{
     *     owner_user_id?: mixed,
     *     request_reason?: mixed,
     *     review_due_at?: mixed,
     *     expires_at?: mixed,
     *     evidence_references?: mixed
     * }  $payload
     */
    public function request(Finding $finding, ManagedEnvironment $tenant, User $actor, array $payload): FindingException
    {
        $this->authorizeRequest($finding, $tenant, $actor);

        $ownerUserId = $this->validatedTenantMemberId(
            tenant: $tenant,
            userId: $payload['owner_user_id'] ?? null,
            field: 'owner_user_id',
            required: true,
        );
        $requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
        $reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
        $expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
        $evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
        $requestedAt = CarbonImmutable::now();

        /** @var FindingException $exception */
        $exception = DB::transaction(function () use ($finding, $tenant, $actor, $ownerUserId, $requestReason, $reviewDueAt, $expiresAt, $evidenceReferences, $requestedAt): FindingException {
            $exception = FindingException::query()
                ->where('finding_id', (int) $finding->getKey())
                ->lockForUpdate()
                ->first();

            if ($exception instanceof FindingException && $exception->isPending()) {
                throw new InvalidArgumentException('An exception request is already pending for this finding.');
            }

            if ($exception instanceof FindingException && $exception->isActiveLike()) {
                throw new InvalidArgumentException('This finding already has an active exception.');
            }

            $exception ??= new FindingException([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'finding_id' => (int) $finding->getKey(),
            ]);

            $before = $this->exceptionSnapshot($exception);

            $exception->fill([
                'requested_by_user_id' => (int) $actor->getKey(),
                'owner_user_id' => $ownerUserId,
                'approved_by_user_id' => null,
                'status' => FindingException::STATUS_PENDING,
                'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
                'request_reason' => $requestReason,
                'approval_reason' => null,
                'rejection_reason' => null,
                'revocation_reason' => null,
                'requested_at' => $requestedAt,
                'approved_at' => null,
                'rejected_at' => null,
                'revoked_at' => null,
                'effective_from' => null,
                'expires_at' => $expiresAt,
                'review_due_at' => $reviewDueAt,
                'evidence_summary' => $this->evidenceSummary($evidenceReferences),
            ]);
            $exception->save();

            $this->replaceEvidenceReferences($exception, $evidenceReferences);

            $decision = $exception->decisions()->create([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'actor_user_id' => (int) $actor->getKey(),
                'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
                'reason' => $requestReason,
                'expires_at' => $expiresAt,
                'metadata' => [
                    'review_due_at' => $reviewDueAt->toIso8601String(),
                    'evidence_reference_count' => count($evidenceReferences),
                ],
                'decided_at' => $requestedAt,
            ]);

            $exception->forceFill([
                'current_decision_id' => (int) $decision->getKey(),
            ])->save();

            $after = $this->exceptionSnapshot($exception->fresh($this->exceptionRelationships()) ?? $exception);

            $this->auditLogger->log(
                tenant: $tenant,
                action: AuditActionId::FindingExceptionRequested,
                actorId: (int) $actor->getKey(),
                actorEmail: $actor->email,
                actorName: $actor->name,
                resourceType: 'finding_exception',
                resourceId: (string) $exception->getKey(),
                targetLabel: 'Finding exception #'.$exception->getKey(),
                context: [
                    'metadata' => [
                        'finding_id' => (int) $finding->getKey(),
                        'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
                        'before' => $before,
                        'after' => $after,
                    ],
                ],
            );

            return $exception;
        });

        return $this->governanceResolver->syncExceptionState(
            $exception->fresh($this->exceptionRelationships()) ?? $exception,
        );
    }

    /**
     * @param  array{
     *     effective_from?: mixed,
     *     expires_at?: mixed,
     *     approval_reason?: mixed
     * }  $payload
     */
    public function approve(FindingException $exception, User $actor, array $payload): FindingException
    {
        $tenant = $this->tenantForException($exception);
        $workspace = $this->workspaceForTenant($tenant);

        $this->authorizeApproval($exception, $tenant, $workspace, $actor);

        $effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
        $expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
        $approvedAt = CarbonImmutable::now();

        /** @var FindingException $approvedException */
        $approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $effectiveFrom, $expiresAt, $approvedAt): FindingException {
            /** @var FindingException $lockedException */
            $lockedException = FindingException::query()
                ->with(['finding', 'tenant', 'requester', 'currentDecision'])
                ->whereKey((int) $exception->getKey())
                ->lockForUpdate()
                ->firstOrFail();

            if (! $lockedException->isPending()) {
                throw new InvalidArgumentException('Only pending exception requests can be approved.');
            }

            if ((int) $lockedException->requested_by_user_id === (int) $actor->getKey()) {
                throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
            }

            $approvalReason = $this->validatedReason($payload['approval_reason'] ?? null, 'approval_reason');

            $isRenewalApproval = $lockedException->isPendingRenewal();
            $before = $this->exceptionSnapshot($lockedException);

            $lockedException->fill([
                'status' => FindingException::STATUS_ACTIVE,
                'current_validity_state' => FindingException::VALIDITY_VALID,
                'approved_by_user_id' => (int) $actor->getKey(),
                'approval_reason' => $approvalReason,
                'approved_at' => $approvedAt,
                'effective_from' => $effectiveFrom,
                'expires_at' => $expiresAt,
                'rejection_reason' => null,
                'rejected_at' => null,
                'revocation_reason' => null,
            ]);
            $lockedException->save();

            $decision = $lockedException->decisions()->create([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'actor_user_id' => (int) $actor->getKey(),
                'decision_type' => $isRenewalApproval
                    ? FindingExceptionDecision::TYPE_RENEWED
                    : FindingExceptionDecision::TYPE_APPROVED,
                'reason' => $approvalReason,
                'effective_from' => $effectiveFrom,
                'expires_at' => $expiresAt,
                'metadata' => [
                    'request_type' => $isRenewalApproval ? 'renewal' : 'initial',
                ],
                'decided_at' => $approvedAt,
            ]);

            $lockedException->forceFill([
                'current_decision_id' => (int) $decision->getKey(),
            ])->save();

            $finding = $lockedException->finding;

            if (! $finding instanceof Finding) {
                throw new InvalidArgumentException('The linked finding could not be resolved.');
            }

            if (! $isRenewalApproval) {
                $this->findingWorkflowService->riskAcceptFromException(
                    finding: $finding,
                    tenant: $tenant,
                    actor: $actor,
                    reason: $this->findingRiskAcceptedReason($approvalReason),
                );
            }

            $resolvedException = $this->governanceResolver->syncExceptionState(
                $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
            );

            $after = $this->exceptionSnapshot($resolvedException);

            $this->auditLogger->log(
                tenant: $tenant,
                action: $isRenewalApproval
                    ? AuditActionId::FindingExceptionRenewed
                    : AuditActionId::FindingExceptionApproved,
                actorId: (int) $actor->getKey(),
                actorEmail: $actor->email,
                actorName: $actor->name,
                resourceType: 'finding_exception',
                resourceId: (string) $resolvedException->getKey(),
                targetLabel: 'Finding exception #'.$resolvedException->getKey(),
                context: [
                    'metadata' => [
                        'finding_id' => (int) $finding->getKey(),
                        'decision_type' => $isRenewalApproval
                            ? FindingExceptionDecision::TYPE_RENEWED
                            : FindingExceptionDecision::TYPE_APPROVED,
                        'before' => $before,
                        'after' => $after,
                    ],
                ],
            );

            return $resolvedException;
        });

        return $approvedException;
    }

    /**
     * @param  array{
     *     rejection_reason?: mixed
     * }  $payload
     */
    public function reject(FindingException $exception, User $actor, array $payload): FindingException
    {
        $tenant = $this->tenantForException($exception);
        $workspace = $this->workspaceForTenant($tenant);

        $this->authorizeApproval($exception, $tenant, $workspace, $actor);

        $rejectionReason = $this->validatedReason($payload['rejection_reason'] ?? null, 'rejection_reason');
        $rejectedAt = CarbonImmutable::now();

        /** @var FindingException $rejectedException */
        $rejectedException = DB::transaction(function () use ($exception, $tenant, $actor, $rejectionReason, $rejectedAt): FindingException {
            /** @var FindingException $lockedException */
            $lockedException = FindingException::query()
                ->with(['finding', 'currentDecision'])
                ->whereKey((int) $exception->getKey())
                ->lockForUpdate()
                ->firstOrFail();

            if (! $lockedException->isPending()) {
                throw new InvalidArgumentException('Only pending exception requests can be rejected.');
            }

            $isRenewalRejection = $lockedException->isPendingRenewal();
            $before = $this->exceptionSnapshot($lockedException);

            if ($isRenewalRejection) {
                $lockedException->fill([
                    'status' => FindingException::STATUS_ACTIVE,
                    'rejection_reason' => $rejectionReason,
                    'rejected_at' => $rejectedAt,
                    'review_due_at' => $this->metadataDate($lockedException, 'previous_review_due_at') ?? $lockedException->review_due_at,
                ]);
            } else {
                $lockedException->fill([
                    'status' => FindingException::STATUS_REJECTED,
                    'current_validity_state' => FindingException::VALIDITY_REJECTED,
                    'rejection_reason' => $rejectionReason,
                    'rejected_at' => $rejectedAt,
                    'approved_by_user_id' => null,
                    'approved_at' => null,
                    'approval_reason' => null,
                    'effective_from' => null,
                ]);
            }
            $lockedException->save();

            $decision = $lockedException->decisions()->create([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'actor_user_id' => (int) $actor->getKey(),
                'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
                'reason' => $rejectionReason,
                'metadata' => [
                    'request_type' => $isRenewalRejection ? 'renewal' : 'initial',
                ],
                'decided_at' => $rejectedAt,
            ]);

            $lockedException->forceFill([
                'current_decision_id' => (int) $decision->getKey(),
            ])->save();

            $resolvedException = $this->governanceResolver->syncExceptionState(
                $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
            );

            $after = $this->exceptionSnapshot($resolvedException);

            $this->auditLogger->log(
                tenant: $tenant,
                action: AuditActionId::FindingExceptionRejected,
                actorId: (int) $actor->getKey(),
                actorEmail: $actor->email,
                actorName: $actor->name,
                resourceType: 'finding_exception',
                resourceId: (string) $resolvedException->getKey(),
                targetLabel: 'Finding exception #'.$resolvedException->getKey(),
                context: [
                    'metadata' => [
                        'finding_id' => (int) $resolvedException->finding_id,
                        'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
                        'before' => $before,
                        'after' => $after,
                    ],
                ],
            );

            return $resolvedException;
        });

        return $rejectedException;
    }

    /**
     * @param  array{
     *     owner_user_id?: mixed,
     *     request_reason?: mixed,
     *     review_due_at?: mixed,
     *     expires_at?: mixed,
     *     evidence_references?: mixed
     * }  $payload
     */
    public function renew(FindingException $exception, User $actor, array $payload): FindingException
    {
        $tenant = $this->tenantForException($exception);

        $this->authorizeManagement($exception, $tenant, $actor);

        $requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
        $reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
        $requestedExpiry = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
        $evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
        $requestedAt = CarbonImmutable::now();

        /** @var FindingException $renewedException */
        $renewedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $requestReason, $reviewDueAt, $requestedExpiry, $evidenceReferences, $requestedAt): FindingException {
            /** @var FindingException $lockedException */
            $lockedException = FindingException::query()
                ->with(['currentDecision', 'finding'])
                ->whereKey((int) $exception->getKey())
                ->lockForUpdate()
                ->firstOrFail();

            if (! $lockedException->canBeRenewed()) {
                throw new InvalidArgumentException('Only active, expiring, or expired exceptions can be renewed.');
            }

            $ownerUserId = array_key_exists('owner_user_id', $payload)
                ? $this->validatedTenantMemberId($tenant, $payload['owner_user_id'], 'owner_user_id')
                : (is_numeric($lockedException->owner_user_id) ? (int) $lockedException->owner_user_id : null);

            $before = $this->exceptionSnapshot($lockedException);

            $lockedException->fill([
                'requested_by_user_id' => (int) $actor->getKey(),
                'owner_user_id' => $ownerUserId,
                'status' => FindingException::STATUS_PENDING,
                'request_reason' => $requestReason,
                'requested_at' => $requestedAt,
                'review_due_at' => $reviewDueAt,
                'rejection_reason' => null,
                'rejected_at' => null,
                'revocation_reason' => null,
                'evidence_summary' => $this->evidenceSummary($evidenceReferences),
            ]);
            $lockedException->save();

            $this->replaceEvidenceReferences($lockedException, $evidenceReferences);

            $decision = $lockedException->decisions()->create([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'actor_user_id' => (int) $actor->getKey(),
                'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
                'reason' => $requestReason,
                'expires_at' => $requestedExpiry,
                'metadata' => [
                    'review_due_at' => $reviewDueAt->toIso8601String(),
                    'requested_expires_at' => $requestedExpiry?->toIso8601String(),
                    'previous_review_due_at' => $lockedException->getOriginal('review_due_at'),
                    'previous_expires_at' => $lockedException->getOriginal('expires_at'),
                    'evidence_reference_count' => count($evidenceReferences),
                ],
                'decided_at' => $requestedAt,
            ]);

            $lockedException->forceFill([
                'current_decision_id' => (int) $decision->getKey(),
            ])->save();

            $resolvedException = $this->governanceResolver->syncExceptionState(
                $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
            );

            $after = $this->exceptionSnapshot($resolvedException);

            $this->auditLogger->log(
                tenant: $tenant,
                action: AuditActionId::FindingExceptionRenewalRequested,
                actorId: (int) $actor->getKey(),
                actorEmail: $actor->email,
                actorName: $actor->name,
                resourceType: 'finding_exception',
                resourceId: (string) $resolvedException->getKey(),
                targetLabel: 'Finding exception #'.$resolvedException->getKey(),
                context: [
                    'metadata' => [
                        'finding_id' => (int) $resolvedException->finding_id,
                        'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
                        'before' => $before,
                        'after' => $after,
                    ],
                ],
            );

            return $resolvedException;
        });

        return $renewedException;
    }

    /**
     * @param  array{
     *     revocation_reason?: mixed
     * }  $payload
     */
    public function revoke(FindingException $exception, User $actor, array $payload): FindingException
    {
        $tenant = $this->tenantForException($exception);

        $this->authorizeManagement($exception, $tenant, $actor);

        $revocationReason = $this->validatedReason($payload['revocation_reason'] ?? null, 'revocation_reason');
        $revokedAt = CarbonImmutable::now();

        /** @var FindingException $revokedException */
        $revokedException = DB::transaction(function () use ($exception, $tenant, $actor, $revocationReason, $revokedAt): FindingException {
            /** @var FindingException $lockedException */
            $lockedException = FindingException::query()
                ->with(['currentDecision', 'finding'])
                ->whereKey((int) $exception->getKey())
                ->lockForUpdate()
                ->firstOrFail();

            if (! $lockedException->canBeRevoked()) {
                throw new InvalidArgumentException('Only active or pending-renewal exceptions can be revoked.');
            }

            $before = $this->exceptionSnapshot($lockedException);

            $lockedException->fill([
                'status' => FindingException::STATUS_REVOKED,
                'current_validity_state' => FindingException::VALIDITY_REVOKED,
                'revocation_reason' => $revocationReason,
                'revoked_at' => $revokedAt,
            ]);
            $lockedException->save();

            $decision = $lockedException->decisions()->create([
                'workspace_id' => (int) $tenant->workspace_id,
                'managed_environment_id' => (int) $tenant->getKey(),
                'actor_user_id' => (int) $actor->getKey(),
                'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
                'reason' => $revocationReason,
                'metadata' => [],
                'decided_at' => $revokedAt,
            ]);

            $lockedException->forceFill([
                'current_decision_id' => (int) $decision->getKey(),
            ])->save();

            $resolvedException = $this->governanceResolver->syncExceptionState(
                $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
            );

            $after = $this->exceptionSnapshot($resolvedException);

            $this->auditLogger->log(
                tenant: $tenant,
                action: AuditActionId::FindingExceptionRevoked,
                actorId: (int) $actor->getKey(),
                actorEmail: $actor->email,
                actorName: $actor->name,
                resourceType: 'finding_exception',
                resourceId: (string) $resolvedException->getKey(),
                targetLabel: 'Finding exception #'.$resolvedException->getKey(),
                context: [
                    'metadata' => [
                        'finding_id' => (int) $resolvedException->finding_id,
                        'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
                        'before' => $before,
                        'after' => $after,
                    ],
                ],
            );

            return $resolvedException;
        });

        return $revokedException;
    }

    private function authorizeRequest(Finding $finding, ManagedEnvironment $tenant, User $actor): void
    {
        if (! $actor->canAccessTenant($tenant)) {
            throw new NotFoundHttpException;
        }

        $this->assertFindingOwnedByTenant($finding, $tenant);

        if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
            return;
        }

        throw new AuthorizationException('Missing capability for exception request.');
    }

    private function authorizeApproval(FindingException $exception, ManagedEnvironment $tenant, Workspace $workspace, User $actor): void
    {
        if (! $actor->canAccessTenant($tenant)) {
            throw new NotFoundHttpException;
        }

        if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) {
            throw new NotFoundHttpException;
        }

        if ((int) $exception->workspace_id !== (int) $workspace->getKey() || (int) $exception->managed_environment_id !== (int) $tenant->getKey()) {
            throw new NotFoundHttpException;
        }

        if ($this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
            return;
        }

        throw new AuthorizationException('Missing capability for exception approval.');
    }

    private function authorizeManagement(FindingException $exception, ManagedEnvironment $tenant, User $actor): void
    {
        if (! $actor->canAccessTenant($tenant)) {
            throw new NotFoundHttpException;
        }

        if ((int) $exception->workspace_id !== (int) $tenant->workspace_id || (int) $exception->managed_environment_id !== (int) $tenant->getKey()) {
            throw new NotFoundHttpException;
        }

        if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
            return;
        }

        throw new AuthorizationException('Missing capability for exception management.');
    }

    private function tenantForException(FindingException $exception): ManagedEnvironment
    {
        $tenant = $exception->tenant;

        if (! $tenant instanceof ManagedEnvironment) {
            $tenant = ManagedEnvironment::query()->findOrFail((int) $exception->managed_environment_id);
        }

        return $tenant;
    }

    private function workspaceForTenant(ManagedEnvironment $tenant): Workspace
    {
        $workspace = $tenant->workspace;

        if (! $workspace instanceof Workspace) {
            $workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id);
        }

        return $workspace;
    }

    private function assertFindingOwnedByTenant(Finding $finding, ManagedEnvironment $tenant): void
    {
        if ((int) $finding->managed_environment_id !== (int) $tenant->getKey()) {
            throw new NotFoundHttpException;
        }

        if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
            throw new NotFoundHttpException;
        }
    }

    private function validatedTenantMemberId(ManagedEnvironment $tenant, mixed $userId, string $field, bool $required = false): ?int
    {
        $label = $this->fieldLabel($field);

        if ($userId === null || $userId === '') {
            if ($required) {
                throw new InvalidArgumentException(sprintf('%s is required.', $label));
            }

            return null;
        }

        if (! is_numeric($userId) || (int) $userId <= 0) {
            throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $label));
        }

        $resolvedUserId = (int) $userId;

        $member = User::query()->whereKey($resolvedUserId)->first();

        if (! $member instanceof User || ! $this->managedEnvironmentAccessScopeResolver->canAccess($member, $tenant)) {
            throw new InvalidArgumentException(sprintf('%s must reference a workspace member with access to this environment.', $label));
        }

        return $resolvedUserId;
    }

    private function validatedReason(mixed $reason, string $field): string
    {
        $label = $this->fieldLabel($field);

        if (! is_string($reason)) {
            throw new InvalidArgumentException(sprintf('%s is required.', $label));
        }

        $resolved = trim($reason);

        if ($resolved === '') {
            throw new InvalidArgumentException(sprintf('%s is required.', $label));
        }

        if (mb_strlen($resolved) > 2000) {
            throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $label));
        }

        return $resolved;
    }

    private function validatedDate(mixed $value, string $field): CarbonImmutable
    {
        $label = $this->fieldLabel($field);

        try {
            return CarbonImmutable::parse((string) $value);
        } catch (\Throwable) {
            throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $label));
        }
    }

    private function validatedFutureDate(mixed $value, string $field): CarbonImmutable
    {
        $date = $this->validatedDate($value, $field);

        if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
            throw new InvalidArgumentException(sprintf('%s must be in the future.', $this->fieldLabel($field)));
        }

        return $date;
    }

    private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, bool $required = false): ?CarbonImmutable
    {
        if ($value === null || $value === '') {
            if ($required) {
                throw new InvalidArgumentException('expires_at is required.');
            }

            return null;
        }

        $expiresAt = $this->validatedDate($value, 'expires_at');

        if ($expiresAt->lessThanOrEqualTo($minimum)) {
            throw new InvalidArgumentException('expires_at must be after the related review or effective date.');
        }

        return $expiresAt;
    }

    private function fieldLabel(string $field): string
    {
        return match ($field) {
            'owner_user_id' => 'Exception owner',
            'request_reason' => 'Request reason',
            'review_due_at' => 'Review due at',
            'approval_reason' => 'Approval reason',
            'rejection_reason' => 'Rejection reason',
            'revocation_reason' => 'Revocation reason',
            'effective_from' => 'Effective from',
            'expires_at' => 'Expires at',
            default => str_replace('_', ' ', $field),
        };
    }

    /**
     * @return list<array{
     *     source_type: string,
     *     source_id: ?string,
     *     source_fingerprint: ?string,
     *     label: string,
     *     measured_at: ?CarbonImmutable,
     *     summary_payload: array<string, mixed>
     * }>
     */
    private function validatedEvidenceReferences(mixed $references): array
    {
        if (! is_array($references)) {
            return [];
        }

        $resolved = [];

        foreach ($references as $reference) {
            if (! is_array($reference)) {
                continue;
            }

            $sourceType = trim((string) ($reference['source_type'] ?? ''));
            $label = trim((string) ($reference['label'] ?? ''));

            if ($sourceType === '' || $label === '') {
                continue;
            }

            $measuredAt = null;

            if (($reference['measured_at'] ?? null) !== null && (string) $reference['measured_at'] !== '') {
                $measuredAt = $this->validatedDate($reference['measured_at'], 'measured_at');
            }

            $resolved[] = [
                'source_type' => $sourceType,
                'source_id' => filled($reference['source_id'] ?? null) ? trim((string) $reference['source_id']) : null,
                'source_fingerprint' => filled($reference['source_fingerprint'] ?? null) ? trim((string) $reference['source_fingerprint']) : null,
                'label' => mb_substr($label, 0, 255),
                'measured_at' => $measuredAt,
                'summary_payload' => is_array($reference['summary_payload'] ?? null) ? $reference['summary_payload'] : [],
            ];
        }

        return $resolved;
    }

    /**
     * @param  list<array{
     *     source_type: string,
     *     source_id: ?string,
     *     source_fingerprint: ?string,
     *     label: string,
     *     measured_at: ?CarbonImmutable,
     *     summary_payload: array<string, mixed>
     * }>  $references
     */
    private function replaceEvidenceReferences(FindingException $exception, array $references): void
    {
        $exception->evidenceReferences()->delete();

        foreach ($references as $reference) {
            $exception->evidenceReferences()->create([
                'workspace_id' => (int) $exception->workspace_id,
                'managed_environment_id' => (int) $exception->managed_environment_id,
                'source_type' => $reference['source_type'],
                'source_id' => $reference['source_id'],
                'source_fingerprint' => $reference['source_fingerprint'],
                'label' => $reference['label'],
                'measured_at' => $reference['measured_at'],
                'summary_payload' => $reference['summary_payload'],
            ]);
        }
    }

    /**
     * @param  list<array{
     *     source_type: string,
     *     source_id: ?string,
     *     source_fingerprint: ?string,
     *     label: string,
     *     measured_at: ?CarbonImmutable,
     *     summary_payload: array<string, mixed>
     * }>  $references
     * @return array<string, mixed>
     */
    private function evidenceSummary(array $references): array
    {
        return [
            'reference_count' => count($references),
            'labels' => array_values(array_map(
                static fn (array $reference): string => $reference['label'],
                array_slice($references, 0, 5),
            )),
        ];
    }

    private function findingRiskAcceptedReason(string $approvalReason): string
    {
        return Finding::CLOSE_REASON_ACCEPTED_RISK;
    }

    private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
    {
        $currentDecision = $exception->relationLoaded('currentDecision')
            ? $exception->currentDecision
            : $exception->currentDecision()->first();

        if (! $currentDecision instanceof FindingExceptionDecision) {
            return null;
        }

        $value = $currentDecision->metadata[$key] ?? null;

        if (! is_string($value) || trim($value) === '') {
            return null;
        }

        return CarbonImmutable::parse($value);
    }

    /**
     * @return array<string, mixed>
     */
    private function exceptionSnapshot(FindingException $exception): array
    {
        return [
            'status' => $exception->status,
            'current_validity_state' => $exception->current_validity_state,
            'current_decision_type' => $exception->currentDecisionType(),
            'finding_id' => $exception->finding_id,
            'requested_by_user_id' => $exception->requested_by_user_id,
            'owner_user_id' => $exception->owner_user_id,
            'approved_by_user_id' => $exception->approved_by_user_id,
            'requested_at' => $exception->requested_at?->toIso8601String(),
            'approved_at' => $exception->approved_at?->toIso8601String(),
            'rejected_at' => $exception->rejected_at?->toIso8601String(),
            'revoked_at' => $exception->revoked_at?->toIso8601String(),
            'effective_from' => $exception->effective_from?->toIso8601String(),
            'expires_at' => $exception->expires_at?->toIso8601String(),
            'review_due_at' => $exception->review_due_at?->toIso8601String(),
            'request_reason' => $exception->request_reason,
            'approval_reason' => $exception->approval_reason,
            'rejection_reason' => $exception->rejection_reason,
            'revocation_reason' => $exception->revocation_reason,
        ];
    }

    /**
     * @return array<int, string|array<int|string, mixed>>
     */
    private function exceptionRelationships(): array
    {
        return [
            'finding',
            'tenant',
            'requester',
            'owner',
            'approver',
            'currentDecision',
            'decisions.actor',
            'evidenceReferences',
        ];
    }
}
