<?php

declare(strict_types=1);

namespace App\Services\Findings;

use App\Models\Finding;
use App\Models\AlertRule;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorType;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final class FindingWorkflowService
{
    public function __construct(
        private readonly FindingSlaPolicy $slaPolicy,
        private readonly AuditLogger $auditLogger,
        private readonly CapabilityResolver $capabilityResolver,
        private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
        private readonly FindingNotificationService $findingNotificationService,
        private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
    ) {}

    /**
     * @return array<int, string>
     */
    public static function meaningfulActivityActionValues(): array
    {
        return [
            AuditActionId::FindingAssigned->value,
            AuditActionId::FindingInProgress->value,
            AuditActionId::FindingReopened->value,
        ];
    }

    public function triage(Finding $finding, ManagedEnvironment $tenant, User $actor): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);

        $currentStatus = (string) $finding->status;

        if (! in_array($currentStatus, [
            Finding::STATUS_NEW,
            Finding::STATUS_REOPENED,
        ], true)) {
            throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
        }

        $now = CarbonImmutable::now();

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingTriaged,
            context: [
                'metadata' => [
                    'triaged_at' => $now->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($now): void {
                $record->status = Finding::STATUS_TRIAGED;
                $record->triaged_at = $record->triaged_at ?? $now;
            },
        );
    }

    public function startProgress(Finding $finding, ManagedEnvironment $tenant, User $actor): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);

        if ((string) $finding->status !== Finding::STATUS_TRIAGED) {
            throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
        }

        $now = CarbonImmutable::now();

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingInProgress,
            context: [
                'metadata' => [
                    'in_progress_at' => $now->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($now): void {
                $record->status = Finding::STATUS_IN_PROGRESS;
                $record->in_progress_at = $record->in_progress_at ?? $now;
                $record->triaged_at = $record->triaged_at ?? $now;
            },
        );
    }

    public function assign(
        Finding $finding,
        ManagedEnvironment $tenant,
        User $actor,
        ?int $assigneeUserId = null,
        ?int $ownerUserId = null,
    ): Finding {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]);

        if (! $finding->hasOpenStatus()) {
            throw new InvalidArgumentException('Only open findings can be assigned.');
        }

        $beforeAssigneeUserId = is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null;
        $this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
        $this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');

        $changeClassification = $this->responsibilityChangeClassification(
            beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
            beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
            afterOwnerUserId: $ownerUserId,
            afterAssigneeUserId: $assigneeUserId,
        );
        $changeSummary = $this->responsibilityChangeSummary(
            beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
            beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
            afterOwnerUserId: $ownerUserId,
            afterAssigneeUserId: $assigneeUserId,
        );

        $updatedFinding = $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingAssigned,
            context: [
                'metadata' => [
                    'assignee_user_id' => $assigneeUserId,
                    'owner_user_id' => $ownerUserId,
                    'responsibility_change_classification' => $changeClassification,
                    'responsibility_change_summary' => $changeSummary,
                ],
            ],
            mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void {
                $record->assignee_user_id = $assigneeUserId;
                $record->owner_user_id = $ownerUserId;
            },
        );

        if ($assigneeUserId !== null && $assigneeUserId !== $beforeAssigneeUserId) {
            $this->findingNotificationService->dispatch(
                $updatedFinding,
                AlertRule::EVENT_FINDINGS_ASSIGNED,
                ['assignee_user_id' => $assigneeUserId],
            );
        }

        return $updatedFinding;
    }

    public function claim(Finding $finding, ManagedEnvironment $tenant, User $actor): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]);

        $assigneeUserId = (int) $actor->getKey();
        $ownerUserId = is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null;
        $changeClassification = $this->responsibilityChangeClassification(
            beforeOwnerUserId: $ownerUserId,
            beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
            afterOwnerUserId: $ownerUserId,
            afterAssigneeUserId: $assigneeUserId,
        );
        $changeSummary = $this->responsibilityChangeSummary(
            beforeOwnerUserId: $ownerUserId,
            beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
            afterOwnerUserId: $ownerUserId,
            afterAssigneeUserId: $assigneeUserId,
        );

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingAssigned,
            context: [
                'metadata' => [
                    'assignee_user_id' => $assigneeUserId,
                    'owner_user_id' => $ownerUserId,
                    'responsibility_change_classification' => $changeClassification,
                    'responsibility_change_summary' => $changeSummary,
                    'claim_self_service' => true,
                ],
            ],
            mutate: function (Finding $record) use ($assigneeUserId): void {
                if (! in_array((string) $record->status, Finding::openStatuses(), true)) {
                    throw new ConflictHttpException('Finding is no longer claimable.');
                }

                if ($record->assignee_user_id !== null) {
                    throw new ConflictHttpException('Finding is already assigned.');
                }

                $record->assignee_user_id = $assigneeUserId;
            },
        );
    }

    public function responsibilityChangeClassification(
        ?int $beforeOwnerUserId,
        ?int $beforeAssigneeUserId,
        ?int $afterOwnerUserId,
        ?int $afterAssigneeUserId,
    ): ?string {
        $ownerChanged = $beforeOwnerUserId !== $afterOwnerUserId;
        $assigneeChanged = $beforeAssigneeUserId !== $afterAssigneeUserId;

        if ($ownerChanged && $assigneeChanged) {
            return 'owner_and_assignee';
        }

        if ($ownerChanged) {
            return $afterOwnerUserId === null
                ? 'clear_owner'
                : 'owner_only';
        }

        if ($assigneeChanged) {
            return $afterAssigneeUserId === null
                ? 'clear_assignee'
                : 'assignee_only';
        }

        return null;
    }

    public function responsibilityChangeSummary(
        ?int $beforeOwnerUserId,
        ?int $beforeAssigneeUserId,
        ?int $afterOwnerUserId,
        ?int $afterAssigneeUserId,
    ): string {
        return match ($this->responsibilityChangeClassification(
            beforeOwnerUserId: $beforeOwnerUserId,
            beforeAssigneeUserId: $beforeAssigneeUserId,
            afterOwnerUserId: $afterOwnerUserId,
            afterAssigneeUserId: $afterAssigneeUserId,
        )) {
            'owner_only' => 'Updated the accountable owner and kept the active assignee unchanged.',
            'assignee_only' => 'Updated the active assignee and kept the accountable owner unchanged.',
            'owner_and_assignee' => 'Updated the accountable owner and the active assignee.',
            'clear_owner' => 'Cleared the accountable owner and kept the active assignee unchanged.',
            'clear_assignee' => 'Cleared the active assignee and kept the accountable owner unchanged.',
            default => 'No responsibility changes were needed.',
        };
    }

    public function resolve(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]);

        if (! $finding->hasOpenStatus()) {
            throw new InvalidArgumentException('Only open findings can be resolved.');
        }

        $reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
        $now = CarbonImmutable::now();

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingResolved,
            context: [
                'metadata' => [
                    'resolved_reason' => $reason,
                    'resolved_at' => $now->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($reason, $now): void {
                $record->status = Finding::STATUS_RESOLVED;
                $record->resolved_reason = $reason;
                $record->resolved_at = $now;
            },
        );
    }

    public function close(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);

        $reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
        $now = CarbonImmutable::now();

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingClosed,
            context: [
                'metadata' => [
                    'closed_reason' => $reason,
                    'closed_at' => $now->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($reason, $now, $actor): void {
                $record->status = Finding::STATUS_CLOSED;
                $record->closed_reason = $reason;
                $record->closed_at = $now;
                $record->closed_by_user_id = (int) $actor->getKey();
            },
        );
    }

    public function riskAccept(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RISK_ACCEPT]);

        return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
    }

    public function riskAcceptFromException(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        $this->assertFindingOwnedByTenant($finding, $tenant);

        return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
    }

    private function riskAcceptWithoutAuthorization(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RISK_ACCEPTED) {
            throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
        }

        $reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
        $now = CarbonImmutable::now();

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingRiskAccepted,
            context: [
                'metadata' => [
                    'closed_reason' => $reason,
                    'closed_at' => $now->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($reason, $now, $actor): void {
                $record->status = Finding::STATUS_RISK_ACCEPTED;
                $record->closed_reason = $reason;
                $record->closed_at = $now;
                $record->closed_by_user_id = (int) $actor->getKey();
            },
        );
    }

    public function reopen(Finding $finding, ManagedEnvironment $tenant, User $actor, string $reason): Finding
    {
        $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);

        if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
            throw new InvalidArgumentException('Only terminal findings can be reopened.');
        }

        $reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
        $now = CarbonImmutable::now();
        $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
        $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: $actor,
            action: AuditActionId::FindingReopened,
            context: [
                'metadata' => [
                    'reopened_at' => $now->toIso8601String(),
                    'reopened_reason' => $reason,
                    'sla_days' => $slaDays,
                    'due_at' => $dueAt->toIso8601String(),
                ],
            ],
            mutate: function (Finding $record) use ($now, $slaDays, $dueAt): void {
                $record->status = Finding::STATUS_REOPENED;
                $record->reopened_at = $now;
                $record->resolved_at = null;
                $record->resolved_reason = null;
                $record->closed_at = null;
                $record->closed_reason = null;
                $record->closed_by_user_id = null;
                $record->sla_days = $slaDays;
                $record->due_at = $dueAt;
            },
        );
    }

    public function resolveBySystem(
        Finding $finding,
        ManagedEnvironment $tenant,
        string $reason,
        CarbonImmutable $resolvedAt,
        ?int $operationRunId = null,
        ?callable $mutate = null,
    ): Finding {
        $this->assertFindingOwnedByTenant($finding, $tenant);

        if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
            throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
        }

        $reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());

        return $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: null,
            action: AuditActionId::FindingResolved,
            context: [
                'metadata' => [
                    'resolved_reason' => $reason,
                    'resolved_at' => $resolvedAt->toIso8601String(),
                    'system_origin' => true,
                ],
            ],
            mutate: function (Finding $record) use ($mutate, $reason, $resolvedAt): void {
                if ($mutate !== null) {
                    $mutate($record);
                }

                $record->status = Finding::STATUS_RESOLVED;
                $record->resolved_reason = $reason;
                $record->resolved_at = $resolvedAt;
            },
            actorType: AuditActorType::System,
            operationRunId: $operationRunId,
        );
    }

    public function reopenBySystem(
        Finding $finding,
        ManagedEnvironment $tenant,
        CarbonImmutable $reopenedAt,
        ?int $operationRunId = null,
        ?callable $mutate = null,
        ?string $reason = null,
    ): Finding {
        $this->assertFindingOwnedByTenant($finding, $tenant);

        if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
            throw new InvalidArgumentException('Only terminal findings can be reopened.');
        }

        $reason = $this->validatedReason(
            $reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
            'reopen_reason',
            Finding::reopenReasonKeys(),
        );
        $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
        $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);

        $reopenedFinding = $this->mutateAndAudit(
            finding: $finding,
            tenant: $tenant,
            actor: null,
            action: AuditActionId::FindingReopened,
            context: [
                'metadata' => [
                    'reopened_at' => $reopenedAt->toIso8601String(),
                    'reopened_reason' => $reason,
                    'sla_days' => $slaDays,
                    'due_at' => $dueAt->toIso8601String(),
                    'system_origin' => true,
                ],
            ],
            mutate: function (Finding $record) use ($mutate, $reopenedAt, $slaDays, $dueAt): void {
                if ($mutate !== null) {
                    $mutate($record);
                }

                $record->status = Finding::STATUS_REOPENED;
                $record->reopened_at = $reopenedAt;
                $record->resolved_at = null;
                $record->resolved_reason = null;
                $record->closed_at = null;
                $record->closed_reason = null;
                $record->closed_by_user_id = null;
                $record->sla_days = $slaDays;
                $record->due_at = $dueAt;
            },
            actorType: AuditActorType::System,
            operationRunId: $operationRunId,
        );

        $this->findingNotificationService->dispatch($reopenedFinding, AlertRule::EVENT_FINDINGS_REOPENED);

        return $reopenedFinding;
    }

    public function lastMeaningfulActivityAt(Finding $finding, mixed $latestWorkflowAuditAt = null): ?CarbonImmutable
    {
        $timestamps = array_filter([
            $this->normalizeActivityTimestamp($finding->in_progress_at),
            $this->normalizeActivityTimestamp($finding->reopened_at),
            $this->normalizeActivityTimestamp($latestWorkflowAuditAt),
        ]);

        if ($timestamps === []) {
            return null;
        }

        usort(
            $timestamps,
            static fn (CarbonImmutable $left, CarbonImmutable $right): int => $left->greaterThan($right) ? -1 : ($left->equalTo($right) ? 0 : 1),
        );

        return $timestamps[0];
    }

    /**
     * @param  array<int, string>  $capabilities
     */
    private function authorize(Finding $finding, ManagedEnvironment $tenant, User $actor, array $capabilities): void
    {
        if (! $actor->canAccessTenant($tenant)) {
            throw new NotFoundHttpException;
        }

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

        foreach ($capabilities as $capability) {
            if ($this->capabilityResolver->can($actor, $tenant, $capability)) {
                return;
            }
        }

        throw new AuthorizationException('Missing capability for finding workflow action.');
    }

    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 assertTenantMemberOrNull(ManagedEnvironment $tenant, ?int $userId, string $field): void
    {
        if ($userId === null) {
            return;
        }

        if ($userId <= 0) {
            throw new InvalidArgumentException(sprintf('%s must be a positive user id.', $field));
        }

        $member = User::query()->whereKey($userId)->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.', $field));
        }
    }

    /**
     * @param  array<int, string>  $allowedReasons
     */
    private function validatedReason(string $reason, string $field, array $allowedReasons): string
    {
        $reason = trim($reason);

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

        if (mb_strlen($reason) > 255) {
            throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
        }

        if (! in_array($reason, $allowedReasons, true)) {
            throw new InvalidArgumentException(sprintf(
                '%s must be one of: %s.',
                $field,
                implode(', ', $allowedReasons),
            ));
        }

        return $reason;
    }

    private function normalizeActivityTimestamp(mixed $value): ?CarbonImmutable
    {
        if ($value instanceof CarbonImmutable) {
            return $value;
        }

        if ($value instanceof \DateTimeInterface) {
            return CarbonImmutable::instance($value);
        }

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

        try {
            return CarbonImmutable::parse($value);
        } catch (\Throwable) {
            return null;
        }
    }

    /**
     * @param  array<string, mixed>  $context
     */
    private function mutateAndAudit(
        Finding $finding,
        ManagedEnvironment $tenant,
        ?User $actor,
        string|AuditActionId $action,
        array $context,
        callable $mutate,
        ?AuditActorType $actorType = null,
        ?int $operationRunId = null,
    ): Finding {
        $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
        $resolvedFinding = DB::transaction(function () use ($finding, $tenant, $actor, $action, $metadata, $mutate, $actorType, $operationRunId): Finding {
            /** @var Finding $record */
            $record = Finding::query()
                ->whereKey($finding->getKey())
                ->lockForUpdate()
                ->firstOrFail();

            $before = $this->auditSnapshot($record);

            $mutate($record);
            $record->save();

            $after = $this->auditSnapshot($record);
            $outcome = $this->findingOutcomeSemantics->describe($record);
            $auditMetadata = array_merge($metadata, [
                'finding_id' => (int) $record->getKey(),
                'before_status' => $before['status'] ?? null,
                'after_status' => $after['status'] ?? null,
                'before' => $before,
                'after' => $after,
                'terminal_outcome_key' => $outcome['terminal_outcome_key'],
                'terminal_outcome_label' => $outcome['label'],
                'verification_state' => $outcome['verification_state'],
                'report_bucket' => $outcome['report_bucket'],
                '_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
            ]);

            $this->auditLogger->log(
                tenant: $tenant,
                action: $action,
                actorId: $actor?->getKey() !== null ? (int) $actor->getKey() : null,
                actorEmail: $actor?->email,
                actorName: $actor?->name,
                resourceType: 'finding',
                resourceId: (string) $record->getKey(),
                context: ['metadata' => $auditMetadata],
                actorType: $actorType,
                operationRunId: $operationRunId,
            );

            return $record;
        });

        return $resolvedFinding->refresh();
    }

    /**
     * @return array<string, mixed>
     */
    private function auditSnapshot(Finding $finding): array
    {
        return [
            'status' => $finding->status,
            'severity' => $finding->severity,
            'due_at' => $finding->due_at?->toIso8601String(),
            'sla_days' => $finding->sla_days,
            'assignee_user_id' => $finding->assignee_user_id,
            'owner_user_id' => $finding->owner_user_id,
            'triaged_at' => $finding->triaged_at?->toIso8601String(),
            'in_progress_at' => $finding->in_progress_at?->toIso8601String(),
            'reopened_at' => $finding->reopened_at?->toIso8601String(),
            'resolved_at' => $finding->resolved_at?->toIso8601String(),
            'resolved_reason' => $finding->resolved_reason,
            'closed_at' => $finding->closed_at?->toIso8601String(),
            'closed_reason' => $finding->closed_reason,
            'closed_by_user_id' => $finding->closed_by_user_id,
        ];
    }

    /**
     * @param  array<string, mixed>  $before
     * @param  array<string, mixed>  $after
     * @param  array<string, mixed>  $metadata
     */
    private function dedupeKey(
        string|AuditActionId $action,
        Finding $finding,
        array $before,
        array $after,
        array $metadata,
        ?User $actor,
        ?AuditActorType $actorType = null,
    ): string {
        $payload = [
            'action' => $action instanceof AuditActionId ? $action->value : $action,
            'finding_id' => (int) $finding->getKey(),
            'actor_id' => $actor?->getKey() !== null ? (int) $actor->getKey() : null,
            'actor_type' => $actorType?->value,
            'before' => $before,
            'after' => $after,
            'assignee_user_id' => $metadata['assignee_user_id'] ?? null,
            'owner_user_id' => $metadata['owner_user_id'] ?? null,
            'resolved_reason' => $metadata['resolved_reason'] ?? null,
            'closed_reason' => $metadata['closed_reason'] ?? null,
            'reopened_reason' => $metadata['reopened_reason'] ?? null,
        ];

        $encoded = json_encode($payload);

        return hash('sha256', is_string($encoded) ? $encoded : serialize($payload));
    }
}
