<?php

namespace App\Services;

use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\SummaryCountsNormalizer;
use Illuminate\Database\QueryException;
use InvalidArgumentException;
use Throwable;

class OperationRunService
{
    public function ensureRun(
        Tenant $tenant,
        string $type,
        array $inputs,
        ?User $initiator = null
    ): OperationRun {
        $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('tenant_id', $tenant->id)
            ->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 {
            return OperationRun::create([
                'tenant_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,
            ]);
        } 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('tenant_id', $tenant->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);
        }

        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
            && $run->user instanceof User
        ) {
            $run->user->notify(new OperationRunCompletedNotification($run));
        }

        return $run;
    }

    /**
     * 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): void
    {
        try {
            $dispatcher();
        } catch (Throwable $e) {
            $this->updateRun(
                $run,
                status: OperationRunStatus::Completed->value,
                outcome: OperationRunOutcome::Failed->value,
                failures: [
                    [
                        'code' => 'dispatch.failed',
                        'message' => $e->getMessage(),
                    ],
                ],
            );

            throw $e;
        }
    }

    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(),
                ],
            ]
        );
    }

    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);
    }

    /**
     * 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, message: string}>
     */
    protected function sanitizeFailures(array $failures): array
    {
        $sanitized = [];

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

            $sanitized[] = [
                'code' => $this->sanitizeFailureCode($code),
                'message' => $this->sanitizeMessage($message),
            ];
        }

        return $sanitized;
    }

    protected function sanitizeFailureCode(string $code): string
    {
        $code = strtolower(trim($code));

        if ($code === '') {
            return 'unknown';
        }

        return substr($code, 0, 80);
    }

    protected function sanitizeMessage(string $message): string
    {
        $message = trim(str_replace(["\r", "\n"], ' ', $message));

        // Redact obvious bearer tokens / secrets.
        $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
        $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;

        // Redact long opaque blobs that look token-like.
        $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;

        return substr($message, 0, 120);
    }

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