<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Artwork;
use App\Services\TagNormalizer;
use App\Services\TagService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;

final class AutoTagArtworkJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    /**
     * Keep retries low; tagging must never block publish.
     */
    public int $tries = 3;

    /**
     * Hard timeout safety for queue workers.
     */
    public int $timeout = 20;

    /**
     * @param int $artworkId
     * @param string $hash File hash used to build public derivative URLs.
     */
    public function __construct(
        private readonly int $artworkId,
        private readonly string $hash,
    ) {
        $queue = (string) config('vision.queue', 'default');
        if ($queue !== '') {
            $this->onQueue($queue);
        }
    }

    public function backoff(): array
    {
        return [2, 10, 30];
    }

    public function handle(TagService $tagService, TagNormalizer $normalizer): void
    {
        if (! (bool) config('vision.enabled', true)) {
            return;
        }

        $artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
        if (! $artwork) {
            return;
        }

        $imageUrl = $this->buildImageUrl($this->hash);
        if ($imageUrl === null) {
            return;
        }

        $processingKey = $this->processingKey($this->artworkId, $this->hash);
        if (! $this->acquireProcessingLock($processingKey)) {
            return;
        }

        $ref = (string) Str::uuid();

        try {
            $clipTags = $this->callClip($imageUrl, $ref);

            $yoloTags = [];
            if ($this->shouldRunYolo($artwork)) {
                $yoloTags = $this->callYolo($imageUrl, $ref);
            }

            $merged = $this->mergeTags($clipTags, $yoloTags);
            if ($merged === []) {
                $this->markProcessed($this->processedKey($this->artworkId, $this->hash));
                return;
            }

            // Normalize explicitly (requirement), then attach via TagService (source=ai + confidence).
            $payload = [];
            foreach ($merged as $row) {
                $tag = $normalizer->normalize((string) ($row['tag'] ?? ''));
                if ($tag === '') {
                    continue;
                }
                $payload[] = [
                    'tag' => $tag,
                    'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null,
                ];
            }

            $tagService->attachAiTags($artwork, $payload);

            $this->markProcessed($this->processedKey($this->artworkId, $this->hash));
        } catch (\Throwable $e) {
            Log::error('AutoTagArtworkJob failed', [
                'ref' => $ref,
                'artwork_id' => $this->artworkId,
                'hash' => $this->hash,
                'attempt' => $this->attempts(),
                'error' => $e->getMessage(),
            ]);

            // Retry-safe: allow queue retry on transient failures.
            throw $e;
        } finally {
            $this->releaseProcessingLock($processingKey);
        }
    }

    private function buildImageUrl(string $hash): ?string
    {
        $base = (string) config('cdn.files_url');
        $base = rtrim($base, '/');
        if ($base === '') {
            return null;
        }

        $variant = (string) config('vision.image_variant', 'md');
        $variant = $variant !== '' ? $variant : 'md';

        // Matches the upload public path layout used for derivatives (img/aa/bb/cc/variant.webp).
        $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
        $clean = str_pad($clean, 6, '0');
        $segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
        $path = 'img/' . implode('/', $segments) . '/' . $variant . '.webp';

        return $base . '/' . $path;
    }

    /**
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function callClip(string $imageUrl, string $ref): array
    {
        $base = trim((string) config('vision.clip.base_url', ''));
        if ($base === '') {
            return [];
        }

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

        try {
            $response = Http::acceptJson()
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->retry(max(0, $retries), max(0, $delay), throw: false)
                ->post($url, [
                    'url' => $imageUrl,
                    'image_url' => $imageUrl,
                    'limit' => 8,
                    'artwork_id' => $this->artworkId,
                    'hash' => $this->hash,
                ]);
        } catch (\Throwable $e) {
            Log::warning('CLIP analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
            throw $e;
        }

        if ($response->serverError()) {
            Log::warning('CLIP analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
            throw new \RuntimeException('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())]);

            // Fallback: try uploading the local derivative file to the gateway's file upload
            // endpoint (`/analyze/all/file`) if the gateway cannot fetch the public URL.
            try {
                $variant = (string) config('vision.image_variant', 'md');
                $row = DB::table('artwork_files')
                    ->where('artwork_id', $this->artworkId)
                    ->where('variant', $variant)
                    ->first();

                if ($row && ! empty($row->path)) {
                    $storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR);
                    $absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path);
                    if (is_file($absolute) && is_readable($absolute)) {
                        $uploadUrl = rtrim($base, '/') . '/analyze/all/file';
                        try {
                            $attach = file_get_contents($absolute);
                            if ($attach !== false) {
                                $uploadResp = Http::attach('file', $attach, basename($absolute))
                                    ->post($uploadUrl, ['limit' => 5]);

                                if ($uploadResp->ok()) {
                                    return $this->extractTagList($uploadResp->json());
                                }
                                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 [];
        }

        return $this->extractTagList($response->json());
    }

    /**
     * @return array<int, array{tag: string, confidence?: float|int|null}>
     */
    private function callYolo(string $imageUrl, string $ref): array
    {
        if (! (bool) config('vision.yolo.enabled', true)) {
            return [];
        }

        $base = trim((string) config('vision.yolo.base_url', ''));
        if ($base === '') {
            return [];
        }

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

        try {
            $response = Http::acceptJson()
                ->connectTimeout(max(1, $connectTimeout))
                ->timeout(max(1, $timeout))
                ->retry(max(0, $retries), max(0, $delay), throw: false)
                ->post($url, [
                    'url' => $imageUrl,
                    'image_url' => $imageUrl,
                    'conf' => 0.25,
                    'artwork_id' => $this->artworkId,
                    'hash' => $this->hash,
                ]);
        } catch (\Throwable $e) {
            Log::warning('YOLO analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
            throw $e;
        }

        if ($response->serverError()) {
            Log::warning('YOLO analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
            throw new \RuntimeException('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 [];
        }

        return $this->extractTagList($response->json());
    }

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

    /**
     * @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'];
        }

        // Common YOLO-style response: objects: [{label, confidence}]
        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;
    }

    /**
     * @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}>
     */
    private 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;

            // Keep highest confidence for duplicates.
            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);
    }

    private function processingKey(int $artworkId, string $hash): string
    {
        return 'autotag:processing:' . $artworkId . ':' . $hash;
    }

    private function processedKey(int $artworkId, string $hash): string
    {
        return 'autotag:processed:' . $artworkId . ':' . $hash;
    }

    private function acquireProcessingLock(string $key): bool
    {
        try {
            $didSet = Redis::setnx($key, 1);
            if ($didSet) {
                Redis::expire($key, 1800);
            }
            return (bool) $didSet;
        } catch (\Throwable $e) {
            // If Redis is unavailable, proceed without dedupe.
            return true;
        }
    }

    private function releaseProcessingLock(string $key): void
    {
        try {
            Redis::del($key);
        } catch (\Throwable $e) {
            // ignore
        }
    }

    private function markProcessed(string $key): void
    {
        try {
            Redis::setex($key, 604800, 1); // 7 days
        } catch (\Throwable $e) {
            // ignore
        }
    }

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

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