<?php

declare(strict_types=1);

namespace App\Services\Vision;

use App\Models\Artwork;
use App\Services\ThumbnailService;
use App\Services\TagNormalizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

final class VisionService
{
    public function isEnabled(): bool
    {
        return (bool) config('vision.enabled', true);
    }

    public function buildImageUrl(string $hash, ?string $variant = null): ?string
    {
        $variant = $variant ?? (string) config('vision.image_variant', 'md');
        $variant = $variant !== '' ? $variant : 'md';

        $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));

        return ThumbnailService::fromHash($clean, 'webp', $variant);
    }

    /**
     * @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
     */
    public function analyzeArtwork(Artwork $artwork, string $hash): array
    {
        return $this->analyzeArtworkDetailed($artwork, $hash)['analysis'];
    }

    /**
     * @return array{analysis: array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}, debug: array<string, mixed>}
     */
    public function analyzeArtworkDetailed(Artwork $artwork, string $hash): array
    {
        $imageUrl = $this->buildImageUrl($hash);
        if ($imageUrl === null) {
            return [
                'analysis' => [],
                'debug' => [
                    'image_url' => null,
                    'hash' => $hash,
                    'reason' => 'image_url_unavailable',
                    'calls' => [],
                ],
            ];
        }

        $ref = (string) Str::uuid();
        $debug = [
            'ref' => $ref,
            'artwork_id' => (int) $artwork->id,
            'hash' => $hash,
            'image_url' => $imageUrl,
            'calls' => [],
        ];

        $gatewayCall = $this->callGatewayAllDetailed($imageUrl, (int) $artwork->id, $hash, 8, $ref);
        $debug['calls'][] = $gatewayCall['debug'];
        $gatewayAnalysis = $gatewayCall['analysis'];

        $clipTags = $gatewayAnalysis['clip_tags'] ?? [];
        if ($clipTags === []) {
            $clipCall = $this->callClipDetailed($imageUrl, (int) $artwork->id, $hash, $ref);
            $debug['calls'][] = $clipCall['debug'];
            $clipTags = $clipCall['tags'];
        }

        $yoloTags = $gatewayAnalysis['yolo_objects'] ?? [];
        if ($yoloTags === [] && $this->shouldRunYolo($artwork)) {
            $yoloCall = $this->callYoloDetailed($imageUrl, (int) $artwork->id, $hash, $ref);
            $debug['calls'][] = $yoloCall['debug'];
            $yoloTags = $yoloCall['tags'];
        }

        return [
            'analysis' => [
                'clip_tags' => $clipTags,
                'yolo_objects' => $yoloTags,
                'blip_caption' => $gatewayAnalysis['blip_caption'] ?? null,
            ],
            'debug' => $debug,
        ];
    }

    /**
     * @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
     */
    public function analyzeArtworkMaturityDetailed(Artwork $artwork, string $hash, ?string $variant = null): array
    {
        $imageUrl = $this->buildImageUrl($hash, $variant);
        $ref = (string) Str::uuid();

        if ($imageUrl === null) {
            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => 'Artwork maturity analysis could not start because no image URL was available.',
                ],
                'debug' => [
                    'ref' => $ref,
                    'artwork_id' => (int) $artwork->id,
                    'hash' => $hash,
                    'image_url' => null,
                    'reason' => 'image_url_unavailable',
                    'calls' => [],
                ],
            ];
        }

        $call = $this->callMaturityDetailed($artwork, $imageUrl, $hash, $ref);

        return [
            'assessment' => $call['assessment'],
            'debug' => [
                'ref' => $ref,
                'artwork_id' => (int) $artwork->id,
                'hash' => $hash,
                'image_url' => $imageUrl,
                'calls' => [$call['debug']],
            ],
        ];
    }

    /**
     * @return array{tags: array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>, vision_enabled: bool, source?: string, reason?: string}
     */
    public function suggestTags(Artwork $artwork, TagNormalizer $normalizer, int $limit = 10): array
    {
        if (! $this->isEnabled()) {
            return ['tags' => [], 'vision_enabled' => false];
        }

        $imageUrl = $this->buildImageUrl((string) $artwork->hash);
        if ($imageUrl === null) {
            return [
                'tags' => [],
                'vision_enabled' => true,
                'reason' => 'image_url_unavailable',
            ];
        }

        $gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
        if ($gatewayBase === '') {
            return [
                'tags' => [],
                'vision_enabled' => true,
                'reason' => 'gateway_not_configured',
            ];
        }

        $safeLimit = min(20, max(5, $limit));
        $url = rtrim($gatewayBase, '/') . '/analyze/all';
        $timeout = (int) config('vision.gateway.timeout_seconds', 10);
        $connectTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
        $ref = (string) Str::uuid();

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('gateway', $ref)
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->withHeaders(['X-Request-ID' => $ref])
                ->post($url, [
                    'url' => $imageUrl,
                    'limit' => $safeLimit,
                ]);

            if (! $response->ok()) {
                Log::warning('vision-suggest: non-ok response', [
                    'ref' => $ref,
                    'artwork_id' => (int) $artwork->id,
                    'status' => $response->status(),
                    'body' => Str::limit($response->body(), 400),
                ]);

                return [
                    'tags' => [],
                    'vision_enabled' => true,
                    'reason' => 'gateway_error_' . $response->status(),
                ];
            }

            return [
                'tags' => $this->parseGatewaySuggestions($response->json(), $normalizer),
                'vision_enabled' => true,
                'source' => 'gateway_sync',
            ];
        } catch (\Throwable $e) {
            Log::warning('vision-suggest: request failed', [
                'ref' => $ref,
                'artwork_id' => (int) $artwork->id,
                'error' => $e->getMessage(),
            ]);

            return [
                'tags' => [],
                'vision_enabled' => true,
                'reason' => 'gateway_exception',
            ];
        }
    }

    /**
     * @param array<int, array{tag: string, confidence?: float|int|null}> $clipTags
     * @param array<int, array{tag: string, confidence?: float|int|null}> $yoloTags
     */
    public function persistVisionMetadata(Artwork $artwork, array $clipTags, ?string $blipCaption, array $yoloTags): void
    {
        $artwork->forceFill([
            'clip_tags_json' => $clipTags === [] ? null : array_values($clipTags),
            'blip_caption' => $blipCaption,
            'yolo_objects_json' => $yoloTags === [] ? null : array_values($yoloTags),
            'vision_metadata_updated_at' => now(),
        ])->saveQuietly();
    }

    /**
     * @param array<int, array{tag: string, confidence?: float|int|null}> $a
     * @param array<int, array{tag: string, confidence?: float|int|null}> $b
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    public function mergeTags(array $a, array $b): array
    {
        $byTag = [];
        foreach (array_merge($a, $b) as $row) {
            $tag = (string) ($row['tag'] ?? '');
            if ($tag === '') {
                continue;
            }

            $conf = $row['confidence'] ?? null;
            $conf = is_numeric($conf) ? (float) $conf : null;

            if (! isset($byTag[$tag])) {
                $byTag[$tag] = ['tag' => $tag, 'confidence' => $conf];
                continue;
            }

            $existing = $byTag[$tag]['confidence'];
            if ($existing === null || ($conf !== null && $conf > (float) $existing)) {
                $byTag[$tag]['confidence'] = $conf;
            }
        }

        return array_values($byTag);
    }

    /**
     * @param mixed $json
     * @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
     */
    private function parseGatewayAnalysis(mixed $json): array
    {
        if (! is_array($json)) {
            return [];
        }

        return [
            'clip_tags' => $this->extractTagRowsFromMixed($json['clip'] ?? []),
            'yolo_objects' => $this->extractTagRowsFromMixed($json['yolo'] ?? ($json['objects'] ?? [])),
            'blip_caption' => $this->extractCaption($json['blip'] ?? ($json['captions'] ?? ($json['caption'] ?? null))),
        ];
    }

    /**
     * @param mixed $value
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function extractTagRowsFromMixed(mixed $value): array
    {
        if (! is_array($value)) {
            return [];
        }

        $rows = [];
        foreach ($value as $item) {
            if (is_string($item)) {
                $rows[] = ['tag' => $item, 'confidence' => null];
                continue;
            }

            if (! is_array($item)) {
                continue;
            }

            $tag = (string) ($item['tag'] ?? $item['label'] ?? $item['name'] ?? '');
            if ($tag === '') {
                continue;
            }

            $rows[] = ['tag' => $tag, 'confidence' => $item['confidence'] ?? null];
        }

        return $rows;
    }

    /**
     * @param mixed $value
     */
    private function extractCaption(mixed $value): ?string
    {
        if (is_string($value)) {
            $caption = trim($value);

            return $caption !== '' ? $caption : null;
        }

        if (! is_array($value)) {
            return null;
        }

        foreach ($value as $item) {
            if (is_string($item) && trim($item) !== '') {
                return trim($item);
            }

            if (is_array($item)) {
                $caption = trim((string) ($item['caption'] ?? $item['text'] ?? ''));
                if ($caption !== '') {
                    return $caption;
                }
            }
        }

        return null;
    }

    /**
     * @param mixed $json
     * @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
     */
    private function parseGatewaySuggestions(mixed $json, TagNormalizer $normalizer): array
    {
        $raw = [];

        if (! is_array($json)) {
            return [];
        }

        if (isset($json['clip']) && is_array($json['clip'])) {
            foreach ($json['clip'] as $item) {
                $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
            }
        }

        if (isset($json['yolo']) && is_array($json['yolo'])) {
            foreach ($json['yolo'] as $item) {
                $raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
            }
        }

        if ($raw === []) {
            $list = $json['tags'] ?? $json['data'] ?? $json;
            if (is_array($list)) {
                foreach ($list as $item) {
                    if (is_array($item)) {
                        $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
                    } elseif (is_string($item)) {
                        $raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
                    }
                }
            }
        }

        $bySlug = [];
        foreach ($raw as $row) {
            $slug = $normalizer->normalize((string) ($row['tag'] ?? ''));
            if ($slug === '') {
                continue;
            }

            $conf = isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null;
            if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
                $bySlug[$slug] = [
                    'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
                    'slug' => $slug,
                    'confidence' => $conf,
                    'source' => $row['source'] ?? 'vision',
                    'is_ai' => true,
                ];
            }
        }

        $sorted = array_values($bySlug);
        usort($sorted, static fn (array $a, array $b): int => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));

        return $sorted;
    }

    /**
     * @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
     */
    private function callGatewayAll(string $imageUrl, int $artworkId, string $hash, int $limit, string $ref): array
    {
        return $this->callGatewayAllDetailed($imageUrl, $artworkId, $hash, $limit, $ref)['analysis'];
    }

    /**
     * @return array{analysis: array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}, debug: array<string, mixed>}
     */
    private function callGatewayAllDetailed(string $imageUrl, int $artworkId, string $hash, int $limit, string $ref): array
    {
        $base = trim((string) config('vision.gateway.base_url', ''));
        if ($base === '') {
            return [
                'analysis' => [],
                'debug' => [
                    'service' => 'gateway_all',
                    'enabled' => false,
                    'reason' => 'base_url_missing',
                ],
            ];
        }

        $url = rtrim($base, '/') . '/analyze/all';
        $timeout = (int) config('vision.gateway.timeout_seconds', 10);
        $connectTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
        $requestPayload = [
            'url' => $imageUrl,
            'image_url' => $imageUrl,
            'limit' => $limit,
            'artwork_id' => $artworkId,
            'hash' => $hash,
        ];
        $debug = [
            'service' => 'gateway_all',
            'endpoint' => $url,
            'request' => $requestPayload,
        ];

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('gateway', $ref)
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->post($url, $requestPayload);
            $debug['status'] = $response->status();
            $debug['auth_header_sent'] = $this->visionApiKey('gateway') !== '';
            $debug['response'] = $response->json() ?? $this->safeBody($response->body());
        } catch (\Throwable $e) {
            Log::warning('Vision gateway analyze/all request failed', [
                'ref' => $ref,
                'artwork_id' => $artworkId,
                'error' => $e->getMessage(),
            ]);

            $debug['error'] = $e->getMessage();
            return ['analysis' => [], 'debug' => $debug];
        }

        if (! $response->ok()) {
            Log::warning('Vision gateway analyze/all non-ok response', [
                'ref' => $ref,
                'status' => $response->status(),
                'body' => $this->safeBody($response->body()),
            ]);

            return ['analysis' => [], 'debug' => $debug];
        }

        return ['analysis' => $this->parseGatewayAnalysis($response->json()), 'debug' => $debug];
    }

    /**
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function callClip(string $imageUrl, int $artworkId, string $hash, string $ref): array
    {
        return $this->callClipDetailed($imageUrl, $artworkId, $hash, $ref)['tags'];
    }

    /**
     * @return array{tags: array<int, array{tag: string, confidence?: float|int|null}>, debug: array<string, mixed>}
     */
    private function callClipDetailed(string $imageUrl, int $artworkId, string $hash, string $ref): array
    {
        $base = trim((string) config('vision.clip.base_url', ''));
        if ($base === '') {
            return [
                'tags' => [],
                'debug' => [
                    'service' => 'clip',
                    'enabled' => false,
                    'reason' => 'base_url_missing',
                ],
            ];
        }

        $endpoint = (string) config('vision.clip.endpoint', '/analyze');
        $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');

        $timeout = (int) config('vision.clip.timeout_seconds', 8);
        $connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2);
        $retries = (int) config('vision.clip.retries', 1);
        $delay = (int) config('vision.clip.retry_delay_ms', 200);
        $requestPayload = [
            'url' => $imageUrl,
            'image_url' => $imageUrl,
            'limit' => 8,
            'artwork_id' => $artworkId,
            'hash' => $hash,
        ];
        $debug = [
            'service' => 'clip',
            'endpoint' => $url,
            'request' => $requestPayload,
        ];

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('clip', $ref)
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->retry(max(0, $retries), max(0, $delay), throw: false)
                ->post($url, $requestPayload);
            $debug['status'] = $response->status();
            $debug['auth_header_sent'] = $this->visionApiKey('clip') !== '';
            $debug['response'] = $response->json() ?? $this->safeBody($response->body());
        } catch (\Throwable $e) {
            Log::warning('CLIP analyze request failed', [
                'ref' => $ref,
                'artwork_id' => $artworkId,
                'error' => $e->getMessage(),
            ]);
            $debug['error'] = $e->getMessage();
            throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: $e->getMessage(), previous: $e);
        }

        if ($response->serverError()) {
            Log::warning('CLIP analyze server error', [
                'ref' => $ref,
                'status' => $response->status(),
                'body' => $this->safeBody($response->body()),
            ]);
            throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: ('CLIP server error: ' . $response->status()));
        }

        if (! $response->ok()) {
            Log::warning('CLIP analyze non-ok response', [
                'ref' => $ref,
                'status' => $response->status(),
                'body' => $this->safeBody($response->body()),
            ]);

            try {
                $variant = (string) config('vision.image_variant', 'md');
                $row = DB::table('artwork_files')
                    ->where('artwork_id', $artworkId)
                    ->where('variant', $variant)
                    ->first();

                if ($row && ! empty($row->path)) {
                    $attach = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->get((string) $row->path);
                    if (is_string($attach) && $attach !== '') {
                        $uploadUrl = rtrim($base, '/') . '/analyze/all/file';
                        try {
                            /** @var \Illuminate\Http\Client\Response $uploadResp */
                            $uploadResp = $this->requestWithVisionAuth('clip', $ref)
                                ->attach('file', $attach, basename((string) $row->path))
                                ->post($uploadUrl, ['limit' => 5]);

                            if ($uploadResp->ok()) {
                                $debug['fallback_upload'] = [
                                    'endpoint' => $uploadUrl,
                                    'status' => $uploadResp->status(),
                                    'response' => $uploadResp->json() ?? $this->safeBody($uploadResp->body()),
                                ];
                                return ['tags' => $this->extractTagList($uploadResp->json()), 'debug' => $debug];
                            }

                            Log::warning('CLIP upload fallback non-ok', [
                                'ref' => $ref,
                                'status' => $uploadResp->status(),
                                'body' => $this->safeBody($uploadResp->body()),
                            ]);
                        } catch (\Throwable $e) {
                            Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]);
                        }
                    }
                }
            } catch (\Throwable $e) {
                Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]);
            }

            return ['tags' => [], 'debug' => $debug];
        }

        return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
    }

    /**
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function callYolo(string $imageUrl, int $artworkId, string $hash, string $ref): array
    {
        return $this->callYoloDetailed($imageUrl, $artworkId, $hash, $ref)['tags'];
    }

    /**
     * @return array{tags: array<int, array{tag: string, confidence?: float|int|null}>, debug: array<string, mixed>}
     */
    private function callYoloDetailed(string $imageUrl, int $artworkId, string $hash, string $ref): array
    {
        if (! (bool) config('vision.yolo.enabled', true)) {
            return [
                'tags' => [],
                'debug' => [
                    'service' => 'yolo',
                    'enabled' => false,
                    'reason' => 'disabled',
                ],
            ];
        }

        $base = trim((string) config('vision.yolo.base_url', ''));
        if ($base === '') {
            return [
                'tags' => [],
                'debug' => [
                    'service' => 'yolo',
                    'enabled' => false,
                    'reason' => 'base_url_missing',
                ],
            ];
        }

        $endpoint = (string) config('vision.yolo.endpoint', '/analyze');
        $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');

        $timeout = (int) config('vision.yolo.timeout_seconds', 8);
        $connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2);
        $retries = (int) config('vision.yolo.retries', 1);
        $delay = (int) config('vision.yolo.retry_delay_ms', 200);
        $requestPayload = [
            'url' => $imageUrl,
            'image_url' => $imageUrl,
            'conf' => 0.25,
            'artwork_id' => $artworkId,
            'hash' => $hash,
        ];
        $debug = [
            'service' => 'yolo',
            'endpoint' => $url,
            'request' => $requestPayload,
        ];

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('yolo', $ref)
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->retry(max(0, $retries), max(0, $delay), throw: false)
                ->post($url, $requestPayload);
            $debug['status'] = $response->status();
            $debug['auth_header_sent'] = $this->visionApiKey('yolo') !== '';
            $debug['response'] = $response->json() ?? $this->safeBody($response->body());
        } catch (\Throwable $e) {
            Log::warning('YOLO analyze request failed', [
                'ref' => $ref,
                'artwork_id' => $artworkId,
                'error' => $e->getMessage(),
            ]);
            $debug['error'] = $e->getMessage();
            throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: $e->getMessage(), previous: $e);
        }

        if ($response->serverError()) {
            Log::warning('YOLO analyze server error', [
                'ref' => $ref,
                'status' => $response->status(),
                'body' => $this->safeBody($response->body()),
            ]);
            throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: ('YOLO server error: ' . $response->status()));
        }

        if (! $response->ok()) {
            Log::warning('YOLO analyze non-ok response', [
                'ref' => $ref,
                'status' => $response->status(),
                'body' => $this->safeBody($response->body()),
            ]);
            return ['tags' => [], 'debug' => $debug];
        }

        return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
    }

    /**
     * @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
     */
    private function callMaturityDetailed(Artwork $artwork, string $imageUrl, string $hash, string $ref): array
    {
        $base = trim((string) config('vision.maturity.base_url', ''));
        if ($base === '') {
            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => 'Vision maturity endpoint is not configured.',
                ],
                'debug' => [
                    'service' => 'maturity',
                    'enabled' => false,
                    'reason' => 'base_url_missing',
                ],
            ];
        }

        $endpoint = (string) config('vision.maturity.endpoint', '/analyze/maturity');
        $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
        $timeout = (int) config('vision.maturity.timeout_seconds', 20);
        $connectTimeout = (int) config('vision.maturity.connect_timeout_seconds', 3);
        $retries = (int) config('vision.maturity.retries', 1);
        $delay = (int) config('vision.maturity.retry_delay_ms', 200);
        $requestPayload = [
            'url' => $imageUrl,
            'image_url' => $imageUrl,
            'artwork_id' => (int) $artwork->id,
            'hash' => $hash,
        ];
        $debug = [
            'service' => 'maturity',
            'endpoint' => $url,
            'request' => $requestPayload,
        ];

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('maturity', $ref)
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->retry(max(0, $retries), max(0, $delay), throw: false)
                ->post($url, $requestPayload);
            $debug['status'] = $response->status();
            $debug['auth_header_sent'] = $this->visionApiKey('maturity') !== '';
            $debug['response'] = $response->json() ?? $this->safeBody($response->body());
        } catch (\Throwable $e) {
            Log::warning('Vision maturity request failed', [
                'ref' => $ref,
                'artwork_id' => (int) $artwork->id,
                'error' => $e->getMessage(),
            ]);

            $debug['error'] = $e->getMessage();

            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => $e->getMessage(),
                ],
                'debug' => $debug,
            ];
        }

        if ($response->ok()) {
            return [
                'assessment' => $this->parseMaturityAssessment($response->json()),
                'debug' => $debug,
            ];
        }

        Log::warning('Vision maturity non-ok response', [
            'ref' => $ref,
            'artwork_id' => (int) $artwork->id,
            'status' => $response->status(),
            'body' => $this->safeBody($response->body()),
        ]);

        $fallback = $this->callMaturityFileDetailed($artwork, $ref);
        $debug['fallback_upload'] = $fallback['debug'];

        if (($fallback['assessment']['status'] ?? null) === 'succeeded') {
            return [
                'assessment' => $fallback['assessment'],
                'debug' => $debug,
            ];
        }

        return [
            'assessment' => [
                'status' => 'failed',
                'advisory' => $this->buildFailureAdvisory($response->status(), $fallback['assessment']['advisory'] ?? null),
            ],
            'debug' => $debug,
        ];
    }

    /**
     * @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
     */
    private function callMaturityFileDetailed(Artwork $artwork, string $ref): array
    {
        $base = trim((string) config('vision.maturity.base_url', ''));
        $endpoint = (string) config('vision.maturity.file_endpoint', '/analyze/maturity/file');
        $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
        $debug = [
            'endpoint' => $url,
        ];

        if ($base === '') {
            $debug['reason'] = 'base_url_missing';

            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => 'Vision maturity upload endpoint is not configured.',
                ],
                'debug' => $debug,
            ];
        }

        $file = $this->fetchStoredArtworkBinary((int) $artwork->id);
        if ($file === null) {
            $debug['reason'] = 'file_unavailable';

            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => 'Artwork maturity analysis could not fall back to the upload endpoint because the stored file was unavailable.',
                ],
                'debug' => $debug,
            ];
        }

        try {
            /** @var \Illuminate\Http\Client\Response $response */
            $response = $this->requestWithVisionAuth('maturity', $ref)
                ->attach('file', $file['contents'], $file['filename'])
                ->post($url, ['artwork_id' => (int) $artwork->id]);
            $debug['status'] = $response->status();
            $debug['response'] = $response->json() ?? $this->safeBody($response->body());
        } catch (\Throwable $e) {
            $debug['error'] = $e->getMessage();

            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => $e->getMessage(),
                ],
                'debug' => $debug,
            ];
        }

        if (! $response->ok()) {
            return [
                'assessment' => [
                    'status' => 'failed',
                    'advisory' => 'Vision maturity upload endpoint returned HTTP ' . $response->status() . '.',
                ],
                'debug' => $debug,
            ];
        }

        return [
            'assessment' => $this->parseMaturityAssessment($response->json()),
            'debug' => $debug,
        ];
    }

    private function shouldRunYolo(Artwork $artwork): bool
    {
        if (! (bool) config('vision.yolo.enabled', true)) {
            return false;
        }

        if (! (bool) config('vision.yolo.photography_only', true)) {
            return true;
        }

        foreach ($artwork->categories as $category) {
            $slug = strtolower((string) ($category->contentType?->slug ?? ''));
            if ($slug === 'photography') {
                return true;
            }
        }

        return false;
    }

    private function requestWithVisionAuth(string $service, ?string $requestId = null): PendingRequest
    {
        $headers = [];
        $apiKey = $this->visionApiKey($service);
        if ($apiKey !== '') {
            $headers['X-API-Key'] = $apiKey;
        }
        if ($requestId) {
            $headers['X-Request-ID'] = $requestId;
        }

        return Http::acceptJson()->withHeaders($headers);
    }

    private function visionApiKey(string $service): string
    {
        return match ($service) {
            'gateway' => trim((string) config('vision.gateway.api_key', '')),
            'maturity' => trim((string) config('vision.maturity.api_key', '')),
            'clip' => trim((string) config('vision.clip.api_key', '')),
            'yolo' => trim((string) config('vision.yolo.api_key', '')),
            default => '',
        };
    }

    /**
     * @param mixed $json
     * @return array<string, mixed>
     */
    private function parseMaturityAssessment(mixed $json): array
    {
        if (! is_array($json)) {
            return [
                'status' => 'failed',
                'advisory' => 'Vision maturity endpoint returned an invalid response.',
            ];
        }

        $label = $this->normalizeMaturityLabel(
            $json['maturity_label']
                ?? $json['label']
                ?? data_get($json, 'data.maturity_label')
                ?? data_get($json, 'result.maturity_label')
        );
        $confidence = $this->normalizeFloat(
            $json['confidence']
                ?? $json['score']
                ?? data_get($json, 'data.confidence')
                ?? data_get($json, 'result.confidence')
        );
        $thresholdUsed = $this->normalizeFloat(
            $json['threshold_used']
                ?? $json['threshold']
                ?? data_get($json, 'data.threshold_used')
                ?? data_get($json, 'result.threshold_used')
        );
        $actionHint = $this->normalizeActionHint(
            $json['action_hint']
                ?? data_get($json, 'data.action_hint')
                ?? data_get($json, 'result.action_hint')
        );
        $advisory = $this->normalizeText(
            $json['advisory']
                ?? $json['message']
                ?? data_get($json, 'data.advisory')
                ?? data_get($json, 'result.advisory')
        );
        $status = $this->normalizeAssessmentStatus(
            $json['status']
                ?? data_get($json, 'data.status')
                ?? data_get($json, 'result.status')
                ?? ($label !== null || $actionHint !== null ? 'succeeded' : 'failed')
        );
        $model = $this->normalizeText(
            $json['model']
                ?? data_get($json, 'meta.model')
                ?? data_get($json, 'result.model')
        );
        $analysisTimeMs = $this->normalizeInt(
            $json['analysis_time_ms']
                ?? data_get($json, 'meta.analysis_time_ms')
                ?? data_get($json, 'result.analysis_time_ms')
        );

        if ($status === 'succeeded' && $label === null && $actionHint === null) {
            $status = 'failed';
            $advisory = $advisory ?: 'Vision maturity endpoint did not return a maturity label or action hint.';
        }

        $labels = $this->extractMaturityLabels($json, $label);

        return array_filter([
            'status' => $status,
            'maturity_label' => $label,
            'confidence' => $confidence,
            'model' => $model,
            'threshold_used' => $thresholdUsed,
            'analysis_time_ms' => $analysisTimeMs,
            'action_hint' => $actionHint,
            'advisory' => $advisory,
            'labels' => $labels,
        ], static fn (mixed $value): bool => $value !== null);
    }

    /**
     * @param mixed $json
     * @return array<int, string>
     */
    private function extractMaturityLabels(mixed $json, ?string $fallbackLabel): array
    {
        if (! is_array($json)) {
            return $fallbackLabel !== null ? [$fallbackLabel] : [];
        }

        $raw = $json['labels']
            ?? $json['matched_labels']
            ?? $json['matched_terms']
            ?? data_get($json, 'data.labels')
            ?? data_get($json, 'result.labels')
            ?? [];

        $labels = collect(is_array($raw) ? $raw : [$raw])
            ->map(function (mixed $item): ?string {
                if (is_string($item)) {
                    $label = trim($item);
                    return $label !== '' ? $label : null;
                }

                if (! is_array($item)) {
                    return null;
                }

                $label = trim((string) ($item['label'] ?? $item['tag'] ?? $item['name'] ?? ''));

                return $label !== '' ? $label : null;
            })
            ->filter()
            ->values()
            ->all();

        if ($labels === [] && $fallbackLabel !== null) {
            $labels[] = $fallbackLabel;
        }

        return array_values(array_unique($labels));
    }

    /**
     * @return array{filename: string, contents: string}|null
     */
    private function fetchStoredArtworkBinary(int $artworkId): ?array
    {
        try {
            $variant = (string) config('vision.image_variant', 'md');
            $row = DB::table('artwork_files')
                ->where('artwork_id', $artworkId)
                ->where('variant', $variant)
                ->first();

            if (! $row || empty($row->path)) {
                return null;
            }

            $path = (string) $row->path;
            $contents = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->get($path);
            if (! is_string($contents) || $contents === '') {
                return null;
            }

            return [
                'filename' => basename($path),
                'contents' => $contents,
            ];
        } catch (\Throwable) {
            return null;
        }
    }

    private function buildFailureAdvisory(int $status, ?string $fallback): string
    {
        if (is_string($fallback) && trim($fallback) !== '') {
            return trim($fallback);
        }

        return 'Vision maturity endpoint returned HTTP ' . $status . '.';
    }

    private function normalizeMaturityLabel(mixed $value): ?string
    {
        if (! is_scalar($value)) {
            return null;
        }

        return match (Str::lower(trim((string) $value))) {
            'safe', 'clear', 'sfw' => 'safe',
            'mature', 'adult', 'nsfw', 'explicit' => 'mature',
            default => null,
        };
    }

    private function normalizeActionHint(mixed $value): ?string
    {
        if (! is_scalar($value)) {
            return null;
        }

        return match (Str::lower(trim((string) $value))) {
            'allow', 'mark_safe', 'safe' => 'safe',
            'review', 'queue', 'suspect' => 'review',
            'flag_high', 'block', 'mature', 'mark_mature' => 'flag_high',
            default => null,
        };
    }

    private function normalizeAssessmentStatus(mixed $value): string
    {
        if (! is_scalar($value)) {
            return 'failed';
        }

        return match (Str::lower(trim((string) $value))) {
            'ok', 'success', 'succeeded', 'complete', 'completed' => 'succeeded',
            'pending', 'queued', 'processing' => 'pending',
            'skipped', 'not_requested' => 'skipped',
            default => 'failed',
        };
    }

    private function normalizeFloat(mixed $value): ?float
    {
        return is_numeric($value) ? round((float) $value, 4) : null;
    }

    private function normalizeInt(mixed $value): ?int
    {
        return is_numeric($value) ? (int) $value : null;
    }

    private function normalizeText(mixed $value): ?string
    {
        if (! is_scalar($value)) {
            return null;
        }

        $normalized = trim((string) $value);

        return $normalized !== '' ? $normalized : null;
    }

    /**
     * @param mixed $json
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function extractTagList(mixed $json): array
    {
        if (is_array($json) && $this->isListOfTags($json)) {
            return $json;
        }

        if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) {
            return $json['tags'];
        }

        if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) {
            return $json['data'];
        }

        if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) {
            $out = [];
            foreach ($json['objects'] as $obj) {
                if (! is_array($obj)) {
                    continue;
                }
                $label = (string) ($obj['label'] ?? $obj['tag'] ?? '');
                if ($label === '') {
                    continue;
                }
                $out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null];
            }
            return $out;
        }

        return [];
    }

    /**
     * @param array<mixed> $arr
     */
    private function isListOfTags(array $arr): bool
    {
        if ($arr === []) {
            return true;
        }

        foreach ($arr as $row) {
            if (! is_array($row)) {
                return false;
            }
            if (! array_key_exists('tag', $row)) {
                return false;
            }
        }

        return true;
    }

    private function safeBody(string $body): string
    {
        $body = trim($body);
        if ($body === '') {
            return '';
        }

        return Str::limit($body, 800);
    }
}