<?php

declare(strict_types=1);

namespace App\Support\RestoreSafety;

use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Auth\Capabilities;

final readonly class RestoreSafetyResolver
{
    private const int DASHBOARD_RECOVERY_CANDIDATE_LIMIT = 10;

    public function __construct(
        private CapabilityResolver $capabilityResolver,
        private WriteGateInterface $writeGate,
        private TenantBackupHealthResolver $backupHealthResolver,
    ) {}

    /**
     * @param  array<string, mixed>  $data
     */
    public function scopeFingerprintFromData(array $data): RestoreScopeFingerprint
    {
        return RestoreScopeFingerprint::fromInputs(
            $data['backup_set_id'] ?? null,
            $data['scope_mode'] ?? null,
            $data['backup_item_ids'] ?? [],
            $data['group_mapping'] ?? [],
        );
    }

    /**
     * @param  array<string, mixed>  $data
     * @return array{
     *     backup_set_id: ?int,
     *     scope_mode: string,
     *     selected_item_ids: list<int>,
     *     group_mapping: array<string, string>,
     *     group_mapping_fingerprint: string,
     *     fingerprint: string,
     *     captured_at: string
     * }
     */
    public function scopeBasisFromData(array $data): array
    {
        $scope = $this->scopeFingerprintFromData($data);

        return $scope->toArray() + [
            'captured_at' => now('UTC')->toIso8601String(),
        ];
    }

    /**
     * @param  array<string, mixed>  $data
     * @return array{
     *     fingerprint: string,
     *     ran_at: string,
     *     blocking_count: int,
     *     warning_count: int,
     *     result_codes: list<string>
     * }|null
     */
    public function checksBasisFromData(array $data): ?array
    {
        $summary = $data['check_summary'] ?? null;
        $ranAt = $data['checks_ran_at'] ?? null;

        if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
            return is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
        }

        $scope = $this->scopeFingerprintFromData($data);
        $results = is_array($data['check_results'] ?? null) ? $data['check_results'] : [];

        return [
            'fingerprint' => $scope->fingerprint,
            'ran_at' => $ranAt,
            'blocking_count' => (int) ($summary['blocking'] ?? 0),
            'warning_count' => (int) ($summary['warning'] ?? 0),
            'result_codes' => array_values(array_filter(array_map(static function (mixed $result): ?string {
                $code = is_array($result) ? ($result['code'] ?? null) : null;

                return is_string($code) && $code !== '' ? $code : null;
            }, $results))),
        ];
    }

    /**
     * @param  array<string, mixed>  $data
     * @return array{
     *     fingerprint: string,
     *     generated_at: string,
     *     summary: array<string, mixed>
     * }|null
     */
    public function previewBasisFromData(array $data): ?array
    {
        $summary = $data['preview_summary'] ?? null;
        $ranAt = $data['preview_ran_at'] ?? null;

        if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
            return is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
        }

        $scope = $this->scopeFingerprintFromData($data);

        return [
            'fingerprint' => $scope->fingerprint,
            'generated_at' => $ranAt,
            'summary' => $summary,
        ];
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function previewIntegrityFromData(array $data): PreviewIntegrityState
    {
        $scope = $this->scopeFingerprintFromData($data);
        $basis = is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
        $generatedAt = is_string($data['preview_ran_at'] ?? null) ? $data['preview_ran_at'] : null;
        $hasPreviewEvidence = (is_array($data['preview_summary'] ?? null) && $data['preview_summary'] !== [])
            || ($basis !== null && $basis !== [])
            || (is_string($generatedAt) && $generatedAt !== '');

        if (! $hasPreviewEvidence) {
            return new PreviewIntegrityState(
                state: PreviewIntegrityState::STATE_NOT_GENERATED,
                freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
                fingerprint: null,
                generatedAt: null,
                invalidationReasons: [],
                rerunRequired: true,
                displaySummary: 'Generate a preview for the current scope before claiming calm execution readiness.',
            );
        }

        $basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
        $reasons = $this->invalidationReasonsForBasis(
            currentScope: $scope,
            basis: $basis,
            explicitReasons: $data['preview_invalidation_reasons'] ?? null,
        );

        if ($reasons !== []) {
            return new PreviewIntegrityState(
                state: PreviewIntegrityState::STATE_INVALIDATED,
                freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
                fingerprint: $basisFingerprint,
                generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
                invalidationReasons: $reasons,
                rerunRequired: true,
                displaySummary: 'The last preview no longer matches the current restore scope. Regenerate it before real execution.',
            );
        }

        if ($basisFingerprint === null || ! is_string($basis['generated_at'] ?? null)) {
            return new PreviewIntegrityState(
                state: PreviewIntegrityState::STATE_STALE,
                freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
                fingerprint: $basisFingerprint,
                generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
                invalidationReasons: [],
                rerunRequired: true,
                displaySummary: 'Preview evidence exists, but it cannot prove it still belongs to the current scope.',
            );
        }

        return new PreviewIntegrityState(
            state: PreviewIntegrityState::STATE_CURRENT,
            freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
            fingerprint: $basisFingerprint,
            generatedAt: $basis['generated_at'],
            invalidationReasons: [],
            rerunRequired: false,
            displaySummary: 'Preview evidence is current for the selected restore scope.',
        );
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function checksIntegrityFromData(array $data): ChecksIntegrityState
    {
        $scope = $this->scopeFingerprintFromData($data);
        $basis = is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
        $summary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
        $ranAt = is_string($data['checks_ran_at'] ?? null) ? $data['checks_ran_at'] : null;
        $blockingCount = (int) ($summary['blocking'] ?? ($basis['blocking_count'] ?? 0));
        $warningCount = (int) ($summary['warning'] ?? ($basis['warning_count'] ?? 0));

        $hasCheckEvidence = $summary !== []
            || ($basis !== null && $basis !== [])
            || (is_string($ranAt) && $ranAt !== '');

        if (! $hasCheckEvidence) {
            return new ChecksIntegrityState(
                state: ChecksIntegrityState::STATE_NOT_RUN,
                freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
                fingerprint: null,
                ranAt: null,
                blockingCount: 0,
                warningCount: 0,
                invalidationReasons: [],
                rerunRequired: true,
                displaySummary: 'Run safety checks for the current scope before offering real execution calmly.',
            );
        }

        $basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
        $reasons = $this->invalidationReasonsForBasis(
            currentScope: $scope,
            basis: $basis,
            explicitReasons: $data['check_invalidation_reasons'] ?? null,
        );

        if ($reasons !== []) {
            return new ChecksIntegrityState(
                state: ChecksIntegrityState::STATE_INVALIDATED,
                freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
                fingerprint: $basisFingerprint,
                ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
                blockingCount: $blockingCount,
                warningCount: $warningCount,
                invalidationReasons: $reasons,
                rerunRequired: true,
                displaySummary: 'The last checks no longer match the current restore scope. Run them again before real execution.',
            );
        }

        if ($basisFingerprint === null || ! is_string($basis['ran_at'] ?? null)) {
            return new ChecksIntegrityState(
                state: ChecksIntegrityState::STATE_STALE,
                freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
                fingerprint: $basisFingerprint,
                ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
                blockingCount: $blockingCount,
                warningCount: $warningCount,
                invalidationReasons: [],
                rerunRequired: true,
                displaySummary: 'Checks evidence exists, but it cannot prove it still belongs to the current scope.',
            );
        }

        return new ChecksIntegrityState(
            state: ChecksIntegrityState::STATE_CURRENT,
            freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
            fingerprint: $basisFingerprint,
            ranAt: $basis['ran_at'],
            blockingCount: $blockingCount,
            warningCount: $warningCount,
            invalidationReasons: [],
            rerunRequired: false,
            displaySummary: 'Checks evidence is current for the selected restore scope.',
        );
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function executionReadiness(ManagedEnvironment $tenant, User $user, array $data, bool $dryRun = false): ExecutionReadinessState
    {
        $blockingReasons = [];

        if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
            $blockingReasons[] = 'missing_capability';
        }

        if (! $dryRun) {
            try {
                $this->writeGate->evaluate($tenant, 'restore.execute');
            } catch (ProviderAccessHardeningRequired $exception) {
                $blockingReasons[] = $exception->reasonCode;
            }
        }

        $checkSummary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
        $blockingCount = (int) ($checkSummary['blocking'] ?? 0);
        $hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blockingCount > 0));

        if ($hasBlockers) {
            $blockingReasons[] = 'risk_blocker';
        }

        $blockingReasons = array_values(array_unique($blockingReasons));
        $allowed = $blockingReasons === [];

        $displaySummary = $allowed
            ? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
            : 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';

        return new ExecutionReadinessState(
            allowed: $allowed,
            blockingReasons: $blockingReasons,
            mutationScope: $dryRun ? 'simulation_only' : 'microsoft_tenant',
            requiredCapability: Capabilities::TENANT_MANAGE,
            displaySummary: $displaySummary,
        );
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $data): RestoreSafetyAssessment
    {
        $previewIntegrity = $this->previewIntegrityFromData($data);
        $checksIntegrity = $this->checksIntegrityFromData($data);
        $executionReadiness = $this->executionReadiness($tenant, $user, $data, false);

        if (! $executionReadiness->allowed) {
            return new RestoreSafetyAssessment(
                state: RestoreSafetyAssessment::STATE_BLOCKED,
                executionReadiness: $executionReadiness,
                previewIntegrity: $previewIntegrity,
                checksIntegrity: $checksIntegrity,
                positiveClaimSuppressed: true,
                primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
                primaryNextAction: 'resolve_blockers',
                summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
            );
        }

        if (! $previewIntegrity->isCurrent()) {
            return new RestoreSafetyAssessment(
                state: RestoreSafetyAssessment::STATE_RISKY,
                executionReadiness: $executionReadiness,
                previewIntegrity: $previewIntegrity,
                checksIntegrity: $checksIntegrity,
                positiveClaimSuppressed: true,
                primaryIssueCode: $previewIntegrity->state,
                primaryNextAction: 'regenerate_preview',
                summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
            );
        }

        if (! $checksIntegrity->isCurrent()) {
            return new RestoreSafetyAssessment(
                state: RestoreSafetyAssessment::STATE_RISKY,
                executionReadiness: $executionReadiness,
                previewIntegrity: $previewIntegrity,
                checksIntegrity: $checksIntegrity,
                positiveClaimSuppressed: true,
                primaryIssueCode: $checksIntegrity->state,
                primaryNextAction: 'rerun_checks',
                summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
            );
        }

        if ($checksIntegrity->warningCount > 0) {
            return new RestoreSafetyAssessment(
                state: RestoreSafetyAssessment::STATE_READY_WITH_CAUTION,
                executionReadiness: $executionReadiness,
                previewIntegrity: $previewIntegrity,
                checksIntegrity: $checksIntegrity,
                positiveClaimSuppressed: true,
                primaryIssueCode: 'warnings_present',
                primaryNextAction: 'review_warnings',
                summary: 'Current preview and checks exist, but warnings remain. The restore can start, yet calm safety claims stay suppressed.',
            );
        }

        return new RestoreSafetyAssessment(
            state: RestoreSafetyAssessment::STATE_READY,
            executionReadiness: $executionReadiness,
            previewIntegrity: $previewIntegrity,
            checksIntegrity: $checksIntegrity,
            positiveClaimSuppressed: false,
            primaryIssueCode: null,
            primaryNextAction: 'execute',
            summary: 'Current preview and checks support real execution for the selected scope.',
        );
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function executionSafetySnapshot(ManagedEnvironment $tenant, User $user, array $data): RestoreExecutionSafetySnapshot
    {
        $scope = $this->scopeFingerprintFromData($data);
        $assessment = $this->safetyAssessment($tenant, $user, $data);

        return new RestoreExecutionSafetySnapshot(
            evaluatedAt: now('UTC')->toIso8601String(),
            scopeFingerprint: $scope->fingerprint,
            previewState: $assessment->previewIntegrity->state,
            checksState: $assessment->checksIntegrity->state,
            safetyState: $assessment->state,
            blockingCount: $assessment->checksIntegrity->blockingCount,
            warningCount: $assessment->checksIntegrity->warningCount,
            primaryIssueCode: $assessment->primaryIssueCode,
            followUpBoundary: 'run_completed_not_recovery_proven',
        );
    }

    public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAttention
    {
        $status = strtolower((string) $restoreRun->status);
        $results = is_array($restoreRun->results) ? $restoreRun->results : [];
        $items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
        $foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : [];
        $operationOutcome = strtolower((string) ($restoreRun->operationRun?->outcome ?? ''));

        $itemStatuses = array_values(array_filter(array_map(static function (mixed $item): ?string {
            $status = is_array($item) ? ($item['status'] ?? null) : null;

            return is_string($status) && $status !== '' ? strtolower($status) : null;
        }, $items)));

        $failedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'failed'));
        $partialItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => in_array($itemStatus, ['partial', 'manual_required'], true)));
        $skippedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'skipped'));
        $failedAssignments = $restoreRun->getFailedAssignmentsCount();
        $skippedAssignments = $restoreRun->getSkippedAssignmentsCount();
        $foundationSkips = count(array_filter($foundations, static function (mixed $entry): bool {
            return is_array($entry) && in_array(($entry['decision'] ?? null), ['failed', 'skipped'], true);
        }));

        if ($restoreRun->is_dry_run || in_array($status, ['draft', 'scoped', 'checked', 'previewed'], true)) {
            return new RestoreResultAttention(
                state: RestoreResultAttention::STATE_NOT_EXECUTED,
                followUpRequired: false,
                primaryCauseFamily: 'none',
                summary: 'This record proves preview truth, not tenant recovery.',
                primaryNextAction: 'review_preview',
                recoveryClaimBoundary: 'preview_only_no_execution_proven',
                tone: 'gray',
            );
        }

        if ($status === 'failed' || $operationOutcome === 'failed') {
            return new RestoreResultAttention(
                state: RestoreResultAttention::STATE_FAILED,
                followUpRequired: true,
                primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
                summary: 'The restore did not complete successfully. Follow-up is still required.',
                primaryNextAction: 'review_failures',
                recoveryClaimBoundary: 'execution_failed_no_recovery_claim',
                tone: 'danger',
            );
        }

        if ($failedItems > 0 || $partialItems > 0 || $failedAssignments > 0 || in_array($operationOutcome, ['partially_succeeded', 'blocked'], true)) {
            return new RestoreResultAttention(
                state: RestoreResultAttention::STATE_PARTIAL,
                followUpRequired: true,
                primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
                summary: 'The restore reached a terminal state, but some items or assignments still need follow-up.',
                primaryNextAction: 'review_partial_items',
                recoveryClaimBoundary: 'run_completed_not_recovery_proven',
                tone: 'warning',
            );
        }

        if ($skippedItems > 0 || $skippedAssignments > 0 || $foundationSkips > 0 || (int) (($restoreRun->metadata ?? [])['non_applied'] ?? 0) > 0) {
            return new RestoreResultAttention(
                state: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
                followUpRequired: true,
                primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
                summary: 'The restore completed, but follow-up remains for skipped or non-applied work.',
                primaryNextAction: 'review_skipped_items',
                recoveryClaimBoundary: 'run_completed_not_recovery_proven',
                tone: 'warning',
            );
        }

        return new RestoreResultAttention(
            state: RestoreResultAttention::STATE_COMPLETED,
            followUpRequired: false,
            primaryCauseFamily: 'none',
            summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
            primaryNextAction: 'review_result',
            recoveryClaimBoundary: 'run_completed_not_recovery_proven',
            tone: 'success',
        );
    }

    /**
     * @return array{
     *     backup_posture: string,
     *     overview_state: string,
     *     headline: string,
     *     summary: string,
     *     claim_boundary: string,
     *     latest_relevant_restore_run_id: ?int,
     *     latest_relevant_attention_state: ?string,
     *     latest_relevant_restore_run: ?RestoreRun,
     *     latest_relevant_attention: ?RestoreResultAttention,
     *     reason: string
     * }
     */
    public function dashboardRecoveryEvidence(ManagedEnvironment $tenant): array
    {
        $backupHealth = $this->backupHealthResolver->assess($tenant);
        $relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
        return $this->dashboardRecoveryEvidencePayload(
            backupHealth: $backupHealth,
            relevantRun: $relevantRestoreHistory['run'],
            relevantAttention: $relevantRestoreHistory['attention'],
        );
    }

    /**
     * @param  iterable<int, ManagedEnvironment|int>  $tenants
     * @param  array<int, TenantBackupHealthAssessment>|null  $backupHealthAssessments
     * @return array<int, array{
     *     backup_posture: string,
     *     overview_state: string,
     *     headline: string,
     *     summary: string,
     *     claim_boundary: string,
     *     latest_relevant_restore_run_id: ?int,
     *     latest_relevant_attention_state: ?string,
     *     latest_relevant_restore_run: ?RestoreRun,
     *     latest_relevant_attention: ?RestoreResultAttention,
     *     reason: string
     * }>
     */
    public function dashboardRecoveryEvidenceForTenants(
        iterable $tenants,
        ?array $backupHealthAssessments = null,
    ): array {
        $tenantIds = $this->normalizeTenantIds($tenants);

        if ($tenantIds === []) {
            return [];
        }

        $resolvedBackupHealthAssessments = $backupHealthAssessments ?? $this->backupHealthResolver->assessMany($tenantIds);
        $candidatesByTenant = $this->dashboardRecoveryCandidatesForTenants($tenantIds)->groupBy('managed_environment_id');
        $evidence = [];

        foreach ($tenantIds as $tenantId) {
            $backupHealth = $resolvedBackupHealthAssessments[$tenantId] ?? $this->backupHealthResolver->assess($tenantId);
            $relevantRestoreHistory = $this->latestRelevantRestoreHistoryFromCandidates(
                $candidatesByTenant->get($tenantId, collect()),
            );

            $evidence[$tenantId] = $this->dashboardRecoveryEvidencePayload(
                backupHealth: $backupHealth,
                relevantRun: $relevantRestoreHistory['run'],
                relevantAttention: $relevantRestoreHistory['attention'],
            );
        }

        return $evidence;
    }

    /**
     * @param  array<string, mixed>|null  $basis
     * @return list<string>
     */
    public function invalidationReasonsForBasis(
        RestoreScopeFingerprint $currentScope,
        ?array $basis,
        mixed $explicitReasons = null,
    ): array {
        $reasons = $this->normalizeReasons($explicitReasons);

        if ($basis === null) {
            return $reasons;
        }

        if ($reasons !== []) {
            return $reasons;
        }

        $basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;

        if ($basisFingerprint !== null && $currentScope->matches($basisFingerprint)) {
            return [];
        }

        $basisBackupSetId = is_numeric($basis['backup_set_id'] ?? null) ? (int) $basis['backup_set_id'] : null;
        $basisScopeMode = $basis['scope_mode'] ?? null;
        $basisSelectedItemIds = is_array($basis['selected_item_ids'] ?? null) ? $basis['selected_item_ids'] : [];
        $basisGroupMappingFingerprint = is_string($basis['group_mapping_fingerprint'] ?? null)
            ? $basis['group_mapping_fingerprint']
            : null;

        $derivedReasons = [];

        if ($basisBackupSetId !== null && $basisBackupSetId !== $currentScope->backupSetId) {
            $derivedReasons[] = 'backup_set_changed';
        }

        if (is_string($basisScopeMode) && $basisScopeMode !== $currentScope->scopeMode) {
            $derivedReasons[] = 'scope_mode_changed';
        }

        if ($this->normalizeIds($basisSelectedItemIds) !== $currentScope->selectedItemIds) {
            $derivedReasons[] = 'selected_items_changed';
        }

        if ($basisGroupMappingFingerprint !== null && $basisGroupMappingFingerprint !== $currentScope->groupMappingFingerprint) {
            $derivedReasons[] = 'group_mapping_changed';
        }

        if ($derivedReasons === [] && $basisFingerprint !== null && ! $currentScope->matches($basisFingerprint)) {
            $derivedReasons[] = 'scope_mismatch';
        }

        return $derivedReasons;
    }

    /**
     * @return array{run: ?RestoreRun, attention: ?RestoreResultAttention}
     */
    private function latestRelevantRestoreHistory(ManagedEnvironment $tenant): array
    {
        return $this->latestRelevantRestoreHistoryFromCandidates($this->dashboardRecoveryCandidates($tenant));
    }

    /**
     * @param  iterable<int, RestoreRun>  $candidates
     * @return array{run: ?RestoreRun, attention: ?RestoreResultAttention}
     */
    private function latestRelevantRestoreHistoryFromCandidates(iterable $candidates): array
    {
        foreach ($candidates as $candidate) {
            $attention = $this->resultAttentionForRun($candidate);

            if ($attention->state === RestoreResultAttention::STATE_NOT_EXECUTED) {
                continue;
            }

            return [
                'run' => $candidate,
                'attention' => $attention,
            ];
        }

        return [
            'run' => null,
            'attention' => null,
        ];
    }

    /**
     * @param  array<int, int>  $tenantIds
     * @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
     */
    private function dashboardRecoveryCandidatesForTenants(array $tenantIds)
    {
        if ($tenantIds === []) {
            return RestoreRun::query()->whereRaw('1 = 0')->get();
        }

        $candidateIds = RestoreRun::query()
            ->whereIn('managed_environment_id', $tenantIds)
            ->orderBy('managed_environment_id')
            ->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
            ->orderByDesc('id')
            ->get(['id', 'managed_environment_id'])
            ->groupBy('managed_environment_id')
            ->flatMap(static fn ($runs): array => $runs->take(self::DASHBOARD_RECOVERY_CANDIDATE_LIMIT)->pluck('id')->all())
            ->values()
            ->all();

        if ($candidateIds === []) {
            return RestoreRun::query()->whereRaw('1 = 0')->get();
        }

        return RestoreRun::query()
            ->whereIn('id', $candidateIds)
            ->with('operationRun:id,outcome,context')
            ->orderBy('managed_environment_id')
            ->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
            ->orderByDesc('id')
            ->get();
    }

    /**
     * @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
     */
    private function dashboardRecoveryCandidates(ManagedEnvironment $tenant)
    {
        return RestoreRun::query()
            ->where('managed_environment_id', (int) $tenant->getKey())
            ->with('operationRun:id,outcome')
            ->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
            ->orderByDesc('id')
            ->limit(self::DASHBOARD_RECOVERY_CANDIDATE_LIMIT)
            ->get();
    }

    /**
     * @return array{
     *     backup_posture: string,
     *     overview_state: string,
     *     headline: string,
     *     summary: string,
     *     claim_boundary: string,
     *     latest_relevant_restore_run_id: ?int,
     *     latest_relevant_attention_state: ?string,
     *     latest_relevant_restore_run: ?RestoreRun,
     *     latest_relevant_attention: ?RestoreResultAttention,
     *     reason: string
     * }
     */
    private function dashboardRecoveryEvidencePayload(
        TenantBackupHealthAssessment $backupHealth,
        ?RestoreRun $relevantRun,
        ?RestoreResultAttention $relevantAttention,
    ): array {
        if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) {
            return [
                'backup_posture' => $backupHealth->posture,
                'overview_state' => 'unvalidated',
                'headline' => 'Recovery evidence is unvalidated',
                'summary' => 'No executed restore history is visible in the latest tenant restore records.',
                'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'),
                'latest_relevant_restore_run_id' => null,
                'latest_relevant_attention_state' => null,
                'latest_relevant_restore_run' => null,
                'latest_relevant_attention' => null,
                'reason' => 'no_history',
            ];
        }

        if (in_array($relevantAttention->state, [
            RestoreResultAttention::STATE_FAILED,
            RestoreResultAttention::STATE_PARTIAL,
            RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
        ], true)) {
            return [
                'backup_posture' => $backupHealth->posture,
                'overview_state' => 'weakened',
                'headline' => 'Recent restore history weakens confidence',
                'summary' => $relevantAttention->summary,
                'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
                'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
                'latest_relevant_attention_state' => $relevantAttention->state,
                'latest_relevant_restore_run' => $relevantRun,
                'latest_relevant_attention' => $relevantAttention,
                'reason' => $relevantAttention->state,
            ];
        }

        return [
            'backup_posture' => $backupHealth->posture,
            'overview_state' => 'no_recent_issues_visible',
            'headline' => 'No recent restore issues visible',
            'summary' => 'Recent executed restore history exists without a current follow-up signal.',
            'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
            'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
            'latest_relevant_attention_state' => $relevantAttention->state,
            'latest_relevant_restore_run' => $relevantRun,
            'latest_relevant_attention' => $relevantAttention,
            'reason' => 'no_recent_issues_visible',
        ];
    }

    private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
    {
        $operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
        $reasonCode = strtolower((string) ($operationContext['reason_code'] ?? ''));

        if ($reasonCode !== '' && (str_contains($reasonCode, 'capability') || str_contains($reasonCode, 'rbac') || str_contains($reasonCode, 'write'))) {
            return 'write_gate_or_rbac';
        }

        $results = is_array($restoreRun->results) ? $restoreRun->results : [];
        $items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];

        foreach ($items as $item) {
            if (! is_array($item)) {
                continue;
            }

            $reason = strtolower((string) ($item['reason'] ?? ''));
            $graphMessage = strtolower((string) ($item['graph_error_message'] ?? ''));

            if (str_contains($reason, 'mapping') || str_contains($reason, 'group') || str_contains($graphMessage, 'mapping')) {
                return 'missing_dependency_or_mapping';
            }

            if (str_contains($reason, 'metadata only') || str_contains($reason, 'manual')) {
                return 'payload_quality';
            }

            if ($graphMessage !== '' || filled($item['graph_error_code'] ?? null)) {
                return 'item_level_failure';
            }
        }

        return 'none';
    }

    /**
     * @return list<string>
     */
    private function normalizeReasons(mixed $reasons): array
    {
        if (! is_array($reasons)) {
            return [];
        }

        $normalized = array_values(array_filter(array_map(static function (mixed $reason): ?string {
            if (! is_string($reason)) {
                return null;
            }

            $reason = trim($reason);

            return $reason === '' ? null : $reason;
        }, $reasons)));

        return array_values(array_unique($normalized));
    }

    /**
     * @return list<int>
     */
    private function normalizeIds(array $ids): array
    {
        $normalized = [];

        foreach ($ids as $id) {
            if (is_int($id) && $id > 0) {
                $normalized[] = $id;

                continue;
            }

            if (is_string($id) && ctype_digit($id) && (int) $id > 0) {
                $normalized[] = (int) $id;
            }
        }

        $normalized = array_values(array_unique($normalized));
        sort($normalized);

        return $normalized;
    }

    /**
     * @param  iterable<int, ManagedEnvironment|int>  $tenants
     * @return array<int, int>
     */
    private function normalizeTenantIds(iterable $tenants): array
    {
        $tenantIds = [];

        foreach ($tenants as $tenant) {
            $tenantId = $tenant instanceof ManagedEnvironment
                ? (int) $tenant->getKey()
                : (int) $tenant;

            if ($tenantId > 0) {
                $tenantIds[] = $tenantId;
            }
        }

        return array_values(array_unique($tenantIds));
    }
}
