<?php

namespace App\Services;

use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
use App\Services\Audit\AuditRecorder;
use App\Services\Operations\BulkIdempotencyFingerprint;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\ReasonTranslation\ReasonTranslator;
use App\Support\Tenants\TenantOperabilityReasonCode;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;

class OperationRunService
{
    public function __construct(
        private readonly AuditRecorder $auditRecorder,
        private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
        private readonly ReasonTranslator $reasonTranslator,
        private readonly ProductTelemetryRecorder $productTelemetryRecorder,
    ) {}

    public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
    {
        if ($run->status !== OperationRunStatus::Queued->value) {
            return false;
        }

        if ($run->started_at !== null) {
            return false;
        }

        if ($run->created_at === null) {
            return false;
        }

        return $run->created_at->lte(now()->subMinutes($thresholdMinutes));
    }

    private function assertTenantAllowsNewOperation(ManagedEnvironment $tenant): void
    {
        $tenant->loadMissing('workspace');

        if ($tenant->isRemovedFromWorkspace()) {
            throw new InvalidArgumentException('ManagedEnvironment was removed from the workspace; new operation runs are blocked until it is restored.');
        }

        if ($tenant->workspace?->isClosed()) {
            throw new InvalidArgumentException('Workspace is closed; new operation runs are blocked until it is reopened.');
        }
    }

    public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
    {
        return $this->forceFailNonTerminalRun(
            $run,
            reasonCode: LifecycleReconciliationReason::StaleQueued->value,
            message: $message,
            source: 'scheduled_reconciler',
            evidence: [
                'status' => OperationRunStatus::Queued->value,
                'created_at' => $run->created_at?->toIso8601String(),
            ],
        );
    }

    public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
    {
        if ($run->status !== OperationRunStatus::Running->value) {
            return false;
        }

        $startedAt = $run->started_at ?? $run->created_at;

        if ($startedAt === null) {
            return false;
        }

        return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
    }

    public function failStaleRunningRun(
        OperationRun $run,
        string $message = 'Run stopped reporting progress and was marked failed.',
    ): OperationRun {
        return $this->forceFailNonTerminalRun(
            $run,
            reasonCode: LifecycleReconciliationReason::StaleRunning->value,
            message: $message,
            source: 'scheduled_reconciler',
            evidence: [
                'status' => OperationRunStatus::Running->value,
                'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
            ],
        );
    }

    public function ensureRun(
        ManagedEnvironment $tenant,
        string $type,
        array $inputs,
        ?User $initiator = null
    ): OperationRun {
        $inputs = $this->normalizeExecutionContext($type, $inputs, $initiator);
        $workspaceId = (int) ($tenant->workspace_id ?? 0);

        if ($workspaceId <= 0) {
            throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
        }

        $this->assertTenantAllowsNewOperation($tenant);

        $hash = $this->calculateHash($tenant->id, $type, $inputs);

        // Idempotency Check (Fast Path)
        // We check specific status to match the partial unique index
        $existing = OperationRun::query()
            ->where('managed_environment_id', $tenant->id)
            ->where('workspace_id', $workspaceId)
            ->where('run_identity_hash', $hash)
            ->whereIn('status', OperationRunStatus::values())
            ->where('status', '!=', OperationRunStatus::Completed->value)
            ->first();

        if ($existing) {
            return $existing;
        }

        // Create new run (race-safe via partial unique index)
        try {
            $run = OperationRun::create([
                'workspace_id' => $workspaceId,
                'managed_environment_id' => $tenant->id,
                'user_id' => $initiator?->id,
                'initiator_name' => $initiator?->name ?? 'System',
                'type' => $type,
                'status' => OperationRunStatus::Queued->value,
                'outcome' => OperationRunOutcome::Pending->value,
                'run_identity_hash' => $hash,
                'context' => $inputs,
            ]);

            $this->recordOperationStartedTelemetry($run, $initiator);

            return $run;
        } catch (QueryException $e) {
            // Unique violation (active-run dedupe):
            // - PostgreSQL: 23505
            // - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
            if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
                throw $e;
            }

            $existing = OperationRun::query()
                ->where('managed_environment_id', $tenant->id)
                ->where('workspace_id', $workspaceId)
                ->where('run_identity_hash', $hash)
                ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
                ->first();

            if ($existing) {
                return $existing;
            }

            throw $e;
        }
    }

    public function ensureRunWithIdentity(
        ManagedEnvironment $tenant,
        string $type,
        array $identityInputs,
        array $context,
        ?User $initiator = null
    ): OperationRun {
        $context = $this->normalizeExecutionContext($type, $context, $initiator);
        $workspaceId = (int) ($tenant->workspace_id ?? 0);

        if ($workspaceId <= 0) {
            throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
        }

        $this->assertTenantAllowsNewOperation($tenant);

        $hash = $this->calculateHash($tenant->id, $type, $identityInputs);

        // Idempotency Check (Fast Path)
        // We check specific status to match the partial unique index
        $existing = OperationRun::query()
            ->where('managed_environment_id', $tenant->id)
            ->where('workspace_id', $workspaceId)
            ->where('run_identity_hash', $hash)
            ->whereIn('status', OperationRunStatus::values())
            ->where('status', '!=', OperationRunStatus::Completed->value)
            ->first();

        if ($existing) {
            return $existing;
        }

        // Create new run (race-safe via partial unique index)
        try {
            $run = OperationRun::create([
                'workspace_id' => $workspaceId,
                'managed_environment_id' => $tenant->id,
                'user_id' => $initiator?->id,
                'initiator_name' => $initiator?->name ?? 'System',
                'type' => $type,
                'status' => OperationRunStatus::Queued->value,
                'outcome' => OperationRunOutcome::Pending->value,
                'run_identity_hash' => $hash,
                'context' => $context,
            ]);

            $this->recordOperationStartedTelemetry($run, $initiator);

            return $run;
        } catch (QueryException $e) {
            // Unique violation (active-run dedupe):
            // - PostgreSQL: 23505
            // - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
            if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
                throw $e;
            }

            $existing = OperationRun::query()
                ->where('managed_environment_id', $tenant->id)
                ->where('workspace_id', $workspaceId)
                ->where('run_identity_hash', $hash)
                ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
                ->first();

            if ($existing) {
                return $existing;
            }

            throw $e;
        }
    }

    public function ensureRunWithIdentityCooldown(
        ManagedEnvironment $tenant,
        string $type,
        array $identityInputs,
        array $context,
        int $cooldownMinutes = 15,
        ?User $initiator = null,
    ): OperationRun {
        $existing = $this->findCanonicalRunWithIdentity(
            tenant: $tenant,
            type: $type,
            identityInputs: $identityInputs,
            cooldownMinutes: $cooldownMinutes,
        );

        if ($existing instanceof OperationRun) {
            return $existing;
        }

        return $this->ensureRunWithIdentity(
            tenant: $tenant,
            type: $type,
            identityInputs: $identityInputs,
            context: $context,
            initiator: $initiator,
        );
    }

    public function findCanonicalRunWithIdentity(
        ManagedEnvironment $tenant,
        string $type,
        array $identityInputs,
        int $cooldownMinutes = 15,
    ): ?OperationRun {
        $workspaceId = (int) ($tenant->workspace_id ?? 0);

        if ($workspaceId <= 0) {
            throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to resolve operation run identity.');
        }

        $hash = $this->calculateHash((int) $tenant->getKey(), $type, $identityInputs);

        $activeRun = OperationRun::query()
            ->where('managed_environment_id', (int) $tenant->getKey())
            ->where('workspace_id', $workspaceId)
            ->where('run_identity_hash', $hash)
            ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
            ->orderByDesc('id')
            ->first();

        if ($activeRun instanceof OperationRun) {
            return $activeRun;
        }

        $cooldownMinutes = max(1, $cooldownMinutes);
        $cutoff = now()->subMinutes($cooldownMinutes);

        return OperationRun::query()
            ->where('managed_environment_id', (int) $tenant->getKey())
            ->where('workspace_id', $workspaceId)
            ->where('run_identity_hash', $hash)
            ->where('status', OperationRunStatus::Completed->value)
            ->whereNotNull('completed_at')
            ->where('completed_at', '>=', $cutoff)
            ->orderByDesc('completed_at')
            ->orderByDesc('id')
            ->first();
    }

    public function ensureRunWithIdentityStrict(
        ManagedEnvironment $tenant,
        string $type,
        array $identityInputs,
        array $context,
        ?User $initiator = null,
    ): OperationRun {
        $context = $this->normalizeExecutionContext($type, $context, $initiator);
        $workspaceId = (int) ($tenant->workspace_id ?? 0);

        if ($workspaceId <= 0) {
            throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
        }

        $this->assertTenantAllowsNewOperation($tenant);

        $hash = $this->calculateHash($tenant->id, $type, $identityInputs);

        $existing = OperationRun::query()
            ->where('managed_environment_id', $tenant->id)
            ->where('workspace_id', $workspaceId)
            ->where('type', $type)
            ->where('run_identity_hash', $hash)
            ->first();

        if ($existing instanceof OperationRun) {
            return $existing;
        }

        try {
            $run = OperationRun::create([
                'workspace_id' => $workspaceId,
                'managed_environment_id' => $tenant->id,
                'user_id' => $initiator?->id,
                'initiator_name' => $initiator?->name ?? 'System',
                'type' => $type,
                'status' => OperationRunStatus::Queued->value,
                'outcome' => OperationRunOutcome::Pending->value,
                'run_identity_hash' => $hash,
                'context' => $context,
            ]);

            $this->recordOperationStartedTelemetry($run, $initiator);

            return $run;
        } catch (QueryException $e) {
            if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
                throw $e;
            }

            $existing = OperationRun::query()
                ->where('managed_environment_id', $tenant->id)
                ->where('workspace_id', $workspaceId)
                ->where('type', $type)
                ->where('run_identity_hash', $hash)
                ->first();

            if ($existing instanceof OperationRun) {
                return $existing;
            }

            throw $e;
        }
    }

    /**
     * Standardized enqueue helper for bulk operations.
     *
     * Builds the canonical bulk context contract and ensures active-run dedupe
     * is based on target scope + selection identity.
     *
     * @param  array{entra_tenant_id?: mixed, directory_context_id?: mixed}  $targetScope
     * @param  array{kind: string, ids_hash?: string, query_hash?: string}  $selectionIdentity
     * @param  array<string, mixed>  $extraContext
     */
    public function enqueueBulkOperation(
        ManagedEnvironment $tenant,
        string $type,
        array $targetScope,
        array $selectionIdentity,
        callable $dispatcher,
        ?User $initiator = null,
        array $extraContext = [],
        bool $emitQueuedNotification = false
    ): OperationRun {
        $targetScope = BulkRunContext::normalizeTargetScope($targetScope);

        $entraTenantId = $targetScope['entra_tenant_id'] ?? null;

        if (is_string($entraTenantId) && $entraTenantId !== '' && $entraTenantId !== $tenant->providerTenantContext()) {
            throw new InvalidArgumentException('Bulk enqueue target_scope entra_tenant_id must match the current tenant.');
        }

        /** @var BulkIdempotencyFingerprint $fingerprints */
        $fingerprints = app(BulkIdempotencyFingerprint::class);

        $fingerprint = $fingerprints->build($type, $targetScope, $selectionIdentity);

        $context = array_merge($extraContext, [
            'operation' => [
                'type' => $type,
            ],
            'target_scope' => $targetScope,
            'selection' => $selectionIdentity,
            'idempotency' => [
                'fingerprint' => $fingerprint,
            ],
        ]);

        $run = $this->ensureRunWithIdentity(
            tenant: $tenant,
            type: $type,
            identityInputs: [
                'target_scope' => $targetScope,
                'selection' => $selectionIdentity,
            ],
            context: $context,
            initiator: $initiator,
        );

        if ($run->wasRecentlyCreated) {
            $this->dispatchOrFail($run, $dispatcher, emitQueuedNotification: $emitQueuedNotification);
        }

        return $run;
    }

    public function ensureWorkspaceRunWithIdentity(
        Workspace $workspace,
        string $type,
        array $identityInputs,
        array $context,
        ?User $initiator = null,
    ): OperationRun {
        $context = $this->normalizeExecutionContext($type, $context, $initiator);
        $hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs);

        $existing = OperationRun::query()
            ->where('workspace_id', (int) $workspace->getKey())
            ->whereNull('managed_environment_id')
            ->where('run_identity_hash', $hash)
            ->whereIn('status', OperationRunStatus::values())
            ->where('status', '!=', OperationRunStatus::Completed->value)
            ->first();

        if ($existing) {
            return $existing;
        }

        try {
            return OperationRun::create([
                'workspace_id' => (int) $workspace->getKey(),
                'managed_environment_id' => null,
                'user_id' => $initiator?->id,
                'initiator_name' => $initiator?->name ?? 'System',
                'type' => $type,
                'status' => OperationRunStatus::Queued->value,
                'outcome' => OperationRunOutcome::Pending->value,
                'run_identity_hash' => $hash,
                'context' => $context,
            ]);
        } catch (QueryException $e) {
            if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
                throw $e;
            }

            $existing = OperationRun::query()
                ->where('workspace_id', (int) $workspace->getKey())
                ->whereNull('managed_environment_id')
                ->where('run_identity_hash', $hash)
                ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
                ->first();

            if ($existing) {
                return $existing;
            }

            throw $e;
        }
    }

    public function updateRun(
        OperationRun $run,
        string $status,
        ?string $outcome = null,
        array $summaryCounts = [],
        array $failures = []
    ): OperationRun {
        $previousStatus = (string) $run->status;

        if (! in_array($status, OperationRunStatus::values(), true)) {
            throw new InvalidArgumentException('Invalid OperationRun status: '.$status);
        }

        if ($outcome !== null) {
            if (! in_array($outcome, OperationRunOutcome::values(), true)) {
                throw new InvalidArgumentException('Invalid OperationRun outcome: '.$outcome);
            }

            // Reserved/future: MUST NOT be produced by feature 054.
            if ($outcome === OperationRunOutcome::Cancelled->value) {
                $outcome = OperationRunOutcome::Failed->value;
                $failures[] = [
                    'code' => 'run.cancelled',
                    'message' => 'Run cancelled (reserved outcome mapped to failed).',
                ];
            }
        }

        $updateData = [
            'status' => $status,
        ];

        if ($outcome) {
            $updateData['outcome'] = $outcome;
        }

        if (! empty($summaryCounts)) {
            $updateData['summary_counts'] = $this->sanitizeSummaryCounts($summaryCounts);
        }

        if (! empty($failures)) {
            $updateData['failure_summary'] = $this->sanitizeFailures($failures);
        }

        $updatedContext = $this->withReasonTranslationContext(
            run: $run,
            context: is_array($run->context) ? $run->context : [],
            failures: is_array($updateData['failure_summary'] ?? null) ? $updateData['failure_summary'] : [],
        );

        if ($updatedContext !== null) {
            $updateData['context'] = $updatedContext;
        }

        if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
            $updateData['started_at'] = now();
        }

        if ($status === OperationRunStatus::Completed->value && is_null($run->completed_at)) {
            $updateData['completed_at'] = now();
        }

        $run->update($updateData);

        $run->refresh();

        if ($previousStatus !== OperationRunStatus::Completed->value
            && $run->status === OperationRunStatus::Completed->value
        ) {
            $this->writeTerminalAudit($run);

            if ($run->user instanceof User) {
                $run->user->notify(new OperationRunCompletedNotification($run));
            }
        }

        return $run;
    }

    /**
     * Increment whitelisted summary_counts keys for a run.
     *
     * Uses a transaction + row lock to prevent lost updates when multiple workers
     * update counts concurrently.
     *
     * @param  array<string, mixed>  $delta
     */
    public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun
    {
        $delta = $this->sanitizeSummaryCounts($delta);

        if ($delta === []) {
            return $run;
        }

        /** @var OperationRun $updated */
        $updated = DB::transaction(function () use ($run, $delta): OperationRun {
            $locked = OperationRun::query()
                ->whereKey($run->getKey())
                ->lockForUpdate()
                ->first();

            if (! $locked instanceof OperationRun) {
                return $run;
            }

            $current = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : [];
            $current = SummaryCountsNormalizer::normalize($current);

            foreach ($delta as $key => $value) {
                $current[$key] = ($current[$key] ?? 0) + $value;
            }

            $locked->summary_counts = $current;
            $locked->save();

            return $locked;
        });

        $updated->refresh();

        return $updated;
    }

    /**
     * Dispatch a queued operation safely.
     *
     * If dispatch fails synchronously (misconfiguration, serialization errors, etc.),
     * the OperationRun is marked terminal failed so we do not leave a misleading queued run behind.
     */
    public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = false): void
    {
        try {
            $this->invokeDispatcher($dispatcher, $run);

            if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
                $run->user->notify(new OperationRunQueuedNotification($run));
            }
        } catch (Throwable $e) {
            $this->updateRun(
                $run,
                status: OperationRunStatus::Completed->value,
                outcome: OperationRunOutcome::Failed->value,
                failures: [
                    [
                        'code' => 'dispatch.failed',
                        'message' => $e->getMessage(),
                    ],
                ],
            );

            throw $e;
        }
    }

    /**
     * Append failure entries to failure_summary (sanitized + bounded) without overwriting existing.
     *
     * @param  array<int, array{code?: mixed, message?: mixed}>  $failures
     */
    public function appendFailures(OperationRun $run, array $failures): OperationRun
    {
        $failures = $this->sanitizeFailures($failures);

        if ($failures === []) {
            return $run;
        }

        /** @var OperationRun $updated */
        $updated = DB::transaction(function () use ($run, $failures): OperationRun {
            $locked = OperationRun::query()
                ->whereKey($run->getKey())
                ->lockForUpdate()
                ->first();

            if (! $locked instanceof OperationRun) {
                return $run;
            }

            $current = is_array($locked->failure_summary ?? null) ? $locked->failure_summary : [];
            $current = $this->sanitizeFailures($current);

            $merged = array_merge($current, $failures);

            // Prevent runaway payloads.
            $merged = array_slice($merged, 0, 50);

            $locked->failure_summary = $merged;
            $locked->save();

            return $locked;
        });

        $updated->refresh();

        return $updated;
    }

    /**
     * Mark a bulk run as completed if summary_counts indicate all work is processed.
     */
    public function maybeCompleteBulkRun(OperationRun $run): OperationRun
    {
        $run->refresh();

        if ($run->status === OperationRunStatus::Completed->value) {
            return $run;
        }

        $updated = DB::transaction(function () use ($run): OperationRun {
            $locked = OperationRun::query()
                ->whereKey($run->getKey())
                ->lockForUpdate()
                ->first();

            if (! $locked instanceof OperationRun) {
                return $run;
            }

            if ($locked->status === OperationRunStatus::Completed->value) {
                return $locked;
            }

            $counts = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : [];
            $counts = SummaryCountsNormalizer::normalize($counts);

            $total = (int) ($counts['total'] ?? 0);
            $processed = (int) ($counts['processed'] ?? 0);
            $failed = (int) ($counts['failed'] ?? 0);

            if ($total <= 0 || $processed < $total) {
                return $locked;
            }

            $outcome = OperationRunOutcome::Succeeded->value;

            if ($failed > 0 && $failed < $total) {
                $outcome = OperationRunOutcome::PartiallySucceeded->value;
            }

            if ($failed >= $total) {
                $outcome = OperationRunOutcome::Failed->value;
            }

            return $this->updateRun(
                $locked,
                status: OperationRunStatus::Completed->value,
                outcome: $outcome,
            );
        });

        $updated->refresh();

        return $updated;
    }

    public function failRun(OperationRun $run, Throwable $e): OperationRun
    {
        return $this->updateRun(
            $run,
            status: OperationRunStatus::Completed->value,
            outcome: OperationRunOutcome::Failed->value,
            failures: [
                [
                    'code' => 'exception.unhandled',
                    'message' => $e->getMessage(),
                ],
            ]
        );
    }

    /**
     * @param  array<string, mixed>  $evidence
     * @param  array<string, mixed>  $summaryCounts
     */
    public function forceFailNonTerminalRun(
        OperationRun $run,
        string $reasonCode,
        string $message,
        string $source = 'scheduled_reconciler',
        array $evidence = [],
        array $summaryCounts = [],
    ): OperationRun {
        return $this->updateRunWithReconciliation(
            run: $run,
            status: OperationRunStatus::Completed->value,
            outcome: OperationRunOutcome::Failed->value,
            summaryCounts: $summaryCounts,
            failures: [[
                'code' => $reasonCode,
                'reason_code' => $reasonCode,
                'message' => $message,
            ]],
            reasonCode: $reasonCode,
            reasonMessage: $message,
            source: $source,
            evidence: $evidence,
        );
    }

    public function bridgeFailedJobFailure(
        OperationRun $run,
        Throwable $exception,
        string $source = 'failed_callback',
    ): OperationRun {
        $reason = $this->bridgeReasonForThrowable($exception);
        $message = $reason->defaultMessage();
        $exceptionMessage = $this->sanitizeMessage($exception->getMessage());

        if ($exceptionMessage !== '') {
            $message = $exceptionMessage;
        }

        return $this->forceFailNonTerminalRun(
            $run,
            reasonCode: $reason->value,
            message: $message,
            source: $source,
            evidence: [
                'exception_class' => $exception::class,
                'bridge_source' => $source,
            ],
        );
    }

    /**
     * @param  array<string, mixed>  $summaryCounts
     * @param  array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}>  $failures
     * @param  array<string, mixed>  $evidence
     */
    public function updateRunWithReconciliation(
        OperationRun $run,
        string $status,
        string $outcome,
        array $summaryCounts,
        array $failures,
        string $reasonCode,
        string $reasonMessage,
        string $source = 'scheduled_reconciler',
        array $evidence = [],
    ): OperationRun {
        /** @var OperationRun $updated */
        $updated = DB::transaction(function () use (
            $run,
            $status,
            $outcome,
            $summaryCounts,
            $failures,
            $reasonCode,
            $reasonMessage,
            $source,
            $evidence,
        ): OperationRun {
            $locked = OperationRun::query()
                ->whereKey($run->getKey())
                ->lockForUpdate()
                ->first();

            if (! $locked instanceof OperationRun) {
                return $run;
            }

            if ((string) $locked->status === OperationRunStatus::Completed->value) {
                return $locked;
            }

            $context = is_array($locked->context) ? $locked->context : [];
            $context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
            $context['reconciliation'] = $this->reconciliationMetadata(
                reasonCode: $reasonCode,
                reasonMessage: $reasonMessage,
                source: $source,
                evidence: $evidence,
            );

            $translatedContext = $this->withReasonTranslationContext(
                run: $locked,
                context: $context,
                failures: $failures,
            );

            $locked->update([
                'context' => $translatedContext ?? $context,
            ]);

            $locked->refresh();

            return $this->updateRun(
                $locked,
                status: $status,
                outcome: $outcome,
                summaryCounts: $summaryCounts,
                failures: $failures,
            );
        });

        $updated->refresh();

        return $updated;
    }

    /**
     * Finalize a run as blocked with deterministic reason_code + link-only next steps.
     *
     * @param  array<int, array{label?: mixed, url?: mixed}>  $nextSteps
     */
    public function finalizeBlockedRun(
        OperationRun $run,
        string $reasonCode,
        array $nextSteps = [],
        ?string $message = null,
    ): OperationRun {
        $reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
        $nextSteps = $this->sanitizeNextSteps($nextSteps);

        $context = is_array($run->context) ? $run->context : [];
        $context['reason_code'] = $reasonCode;
        $context['next_steps'] = $nextSteps;
        $context = $this->withReasonTranslationContext(
            run: $run,
            context: $context,
            failures: [[
                'reason_code' => $reasonCode,
            ]],
        ) ?? $context;
        $summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);

        $run->update([
            'context' => $context,
            'summary_counts' => $summaryCounts,
        ]);

        $run->refresh();

        return $this->updateRun(
            $run,
            status: OperationRunStatus::Completed->value,
            outcome: OperationRunOutcome::Blocked->value,
            failures: [
                [
                    'code' => 'operation.blocked',
                    'reason_code' => $reasonCode,
                    'message' => $message ?? 'Operation blocked due to provider configuration.',
                ],
            ],
        );
    }

    public function finalizeExecutionLegitimacyBlockedRun(
        OperationRun $run,
        QueuedExecutionLegitimacyDecision $decision,
    ): OperationRun {
        $context = is_array($run->context) ? $run->context : [];
        $context['execution_legitimacy'] = $decision->toArray();
        $context['blocked_by'] = 'queued_execution_legitimacy';
        $context['retryable'] = $decision->retryable;

        $run->update([
            'context' => $context,
        ]);

        $run = $this->finalizeBlockedRun(
            run: $run->fresh(),
            reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
            message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
        );

        if ($run->type === 'provider.connection.check') {
            VerificationReportWriter::write(
                run: $run,
                checks: BlockedVerificationReportFactory::checks($run),
                identity: BlockedVerificationReportFactory::identity($run),
            );

            $run->refresh();
        }

        return $run;
    }

    private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
    {
        $ref = null;

        if (is_array($dispatcher) && count($dispatcher) === 2) {
            $ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
        } elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
            [$class, $method] = explode('::', $dispatcher, 2);
            $ref = new ReflectionMethod($class, $method);
        } elseif ($dispatcher instanceof \Closure) {
            $ref = new ReflectionFunction($dispatcher);
        } elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
            $ref = new ReflectionMethod($dispatcher, '__invoke');
        }

        if ($ref && $ref->getNumberOfParameters() >= 1) {
            $dispatcher($run);

            return;
        }

        $dispatcher();
    }

    protected function calculateHash(int $tenantId, string $type, array $inputs): string
    {
        $normalizedInputs = $this->normalizeInputs($inputs);

        $json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        return hash('sha256', $tenantId.'|'.$type.'|'.$json);
    }

    protected function calculateWorkspaceHash(int $workspaceId, string $type, array $inputs): string
    {
        $normalizedInputs = $this->normalizeInputs($inputs);

        $json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json);
    }

    /**
     * @param  array<string, mixed>  $context
     * @return array<string, mixed>
     */
    private function normalizeExecutionContext(string $type, array $context, ?User $initiator): array
    {
        $context['execution_authority_mode'] = is_string($context['execution_authority_mode'] ?? null)
            ? trim((string) $context['execution_authority_mode'])
            : ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value);

        $requiredCapability = $context['required_capability'] ?? null;

        if (! is_string($requiredCapability) || trim($requiredCapability) === '') {
            $requiredCapability = $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($type);
        } else {
            $requiredCapability = trim($requiredCapability);
        }

        if (is_string($requiredCapability) && $requiredCapability !== '') {
            $context['required_capability'] = $requiredCapability;
        } else {
            unset($context['required_capability']);
        }

        return $context;
    }

    private function recordOperationStartedTelemetry(OperationRun $run, ?User $initiator): void
    {
        if (! $initiator instanceof User) {
            return;
        }

        if (! is_numeric($run->workspace_id) || ! is_numeric($run->managed_environment_id)) {
            return;
        }

        $this->productTelemetryRecorder->record(
            eventName: ProductUsageEventCatalog::OPERATIONS_STARTED,
            workspaceId: (int) $run->workspace_id,
            tenantId: (int) $run->managed_environment_id,
            userId: (int) $initiator->getKey(),
            subjectType: 'operation_run',
            subjectId: (int) $run->getKey(),
            metadata: [
                'operation_type' => (string) $run->type,
            ],
            occurredAt: $run->created_at ?? now(),
        );
    }

    /**
     * Normalize inputs for stable identity hashing.
     *
     * - Associative arrays: sorted by key.
     * - Lists: elements normalized and then sorted by a stable JSON representation.
     */
    protected function normalizeInputs(array $value): array
    {
        if ($this->isListArray($value)) {
            $items = array_map(function ($item) {
                return is_array($item) ? $this->normalizeInputs($item) : $item;
            }, $value);

            usort($items, function ($a, $b): int {
                $aJson = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                $bJson = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

                return strcmp((string) $aJson, (string) $bJson);
            });

            return array_values($items);
        }

        ksort($value);

        foreach ($value as $key => $item) {
            if (is_array($item)) {
                $value[$key] = $this->normalizeInputs($item);
            }
        }

        return $value;
    }

    protected function isListArray(array $array): bool
    {
        if ($array === []) {
            return true;
        }

        return array_keys($array) === range(0, count($array) - 1);
    }

    /**
     * @param  array<int, array{code?: mixed, message?: mixed}>  $failures
     * @return array<int, array{code: string, reason_code: string, message: string}>
     */
    protected function sanitizeFailures(array $failures): array
    {
        $sanitized = [];

        foreach ($failures as $failure) {
            $code = (string) ($failure['code'] ?? 'unknown');
            $reasonCode = (string) ($failure['reason_code'] ?? $code);
            $message = (string) ($failure['message'] ?? '');

            $sanitized[] = [
                'code' => $this->sanitizeFailureCode($code),
                'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
                'message' => $this->sanitizeMessage($message),
            ];
        }

        return $sanitized;
    }

    protected function sanitizeFailureCode(string $code): string
    {
        return RunFailureSanitizer::sanitizeCode($code);
    }

    protected function sanitizeMessage(string $message): string
    {
        return RunFailureSanitizer::sanitizeMessage($message);
    }

    /**
     * @param  array<string, mixed>  $summaryCounts
     * @return array<string, int>
     */
    protected function sanitizeSummaryCounts(array $summaryCounts): array
    {
        return SummaryCountsNormalizer::normalize($summaryCounts);
    }

    /**
     * @param  array<int, array{label?: mixed, url?: mixed}>  $nextSteps
     * @return array<int, array{label: string, url: string}>
     */
    protected function sanitizeNextSteps(array $nextSteps): array
    {
        $sanitized = [];

        foreach ($nextSteps as $nextStep) {
            $label = is_string($nextStep['label'] ?? null) ? trim((string) $nextStep['label']) : '';
            $url = is_string($nextStep['url'] ?? null) ? trim((string) $nextStep['url']) : '';

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

            $sanitized[] = [
                'label' => substr($label, 0, 120),
                'url' => substr($url, 0, 2048),
            ];
        }

        return $sanitized;
    }

    /**
     * @param  array<string, mixed>  $context
     * @param  array<int, array{code?: string, reason_code?: string, message?: string}>  $failures
     * @return array<string, mixed>|null
     */
    private function withReasonTranslationContext(OperationRun $run, array $context, array $failures): ?array
    {
        $reasonCode = $this->resolveReasonCode($context, $failures);

        if ($reasonCode === null) {
            return null;
        }

        $hasExplicitContextReason = is_string(data_get($context, 'execution_legitimacy.reason_code'))
            || is_string(data_get($context, 'reason_code'));

        if (! $hasExplicitContextReason && ! $this->isDirectlyTranslatableReason($reasonCode)) {
            return null;
        }

        $translation = $this->reasonTranslator->translate($reasonCode, surface: 'notification', context: $context);

        if (! $translation instanceof ReasonResolutionEnvelope) {
            return null;
        }

        $legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];

        if ($translation->nextSteps === [] && $legacyNextSteps !== []) {
            $translation = $translation->withNextSteps($legacyNextSteps);
        }

        $context['reason_translation'] = $translation->toArray();

        if ($translation->toLegacyNextSteps() !== [] && empty($context['next_steps'])) {
            $context['next_steps'] = $translation->toLegacyNextSteps();
        }

        return $context;
    }

    /**
     * @param  array<string, mixed>  $context
     * @param  array<int, array{code?: string, reason_code?: string, message?: string}>  $failures
     */
    private function resolveReasonCode(array $context, array $failures): ?string
    {
        $reasonCode = data_get($context, 'execution_legitimacy.reason_code')
            ?? data_get($context, 'reason_code')
            ?? data_get($failures, '0.reason_code');

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

        return trim($reasonCode);
    }

    private function isDirectlyTranslatableReason(string $reasonCode): bool
    {
        if ($reasonCode === ProviderReasonCodes::UnknownError) {
            return false;
        }

        return ProviderReasonCodes::isKnown($reasonCode)
            || ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
            || LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
            || TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
            || RbacReason::tryFrom($reasonCode) instanceof RbacReason;
    }

    /**
     * @param  array<string, mixed>  $evidence
     * @return array<string, mixed>
     */
    private function reconciliationMetadata(
        string $reasonCode,
        string $reasonMessage,
        string $source,
        array $evidence,
    ): array {
        return [
            'reconciled_at' => now()->toIso8601String(),
            'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
            'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
            'reason_message' => $this->sanitizeMessage($reasonMessage),
            'source' => $this->sanitizeFailureCode($source),
            'evidence' => $evidence,
        ];
    }

    private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
    {
        $className = strtolower(class_basename($exception));

        if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
            return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
        }

        return LifecycleReconciliationReason::QueueFailureBridge;
    }

    private function writeTerminalAudit(OperationRun $run): void
    {
        $tenant = $run->tenant;
        $workspace = $run->workspace;
        $context = is_array($run->context) ? $run->context : [];
        $executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
        $reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
        $operationLabel = OperationCatalog::label((string) $run->type);

        $action = match ($run->outcome) {
            OperationRunOutcome::Succeeded->value => 'operation.completed',
            OperationRunOutcome::PartiallySucceeded->value => 'operation.partial',
            OperationRunOutcome::Blocked->value => 'operation.blocked',
            default => 'operation.failed',
        };

        $summary = match ($run->outcome) {
            OperationRunOutcome::Succeeded->value => sprintf('%s completed', $operationLabel),
            OperationRunOutcome::PartiallySucceeded->value => sprintf('%s partially completed', $operationLabel),
            OperationRunOutcome::Blocked->value => sprintf('%s blocked', $operationLabel),
            default => sprintf('%s failed', $operationLabel),
        };

        $this->auditRecorder->record(
            action: $action,
            context: [
                'metadata' => [
                    'operation_type' => $run->canonicalOperationType(),
                    'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
                    'failure_summary' => $run->failure_summary,
                    'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),
                    'reason_code' => $executionLegitimacy['reason_code'] ?? ($context['reason_code'] ?? null),
                    'denial_class' => $executionLegitimacy['denial_class'] ?? null,
                    'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
                    'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
                    'blocked_by' => $context['blocked_by'] ?? null,
                    'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
                ],
            ],
            workspace: $workspace,
            tenant: $tenant,
            actor: $run->user instanceof User ? AuditActorSnapshot::human($run->user) : null,
            target: new AuditTargetSnapshot(
                type: 'operation_run',
                id: (int) $run->getKey(),
            ),
            outcome: $run->outcome,
            summary: $summary,
            operationRunId: (int) $run->getKey(),
        );
    }
}
