<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Artwork;
use App\Services\TagService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Generate AI tags for artworks using a local LM Studio vision model.
 *
 * Usage:
 *   php artisan artworks:ai-tag
 *   php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
 *   php artisan artworks:ai-tag --limit=100 --skip-tagged
 *   php artisan artworks:ai-tag --artwork-id=242     # process a single artwork by ID
 *   php artisan artworks:ai-tag --artwork-id=242 --dump-curl  # print equivalent curl command (no API call made)
 *   php artisan artworks:ai-tag --artwork-id=242 --debug      # print CDN URL, file size, magic bytes and data-URI prefix
 *   php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
 */
final class AiTagArtworksCommand extends Command
{
    protected $signature = 'artworks:ai-tag
        {--artwork-id=     : Process only this single artwork ID (bypasses public/approved scope)}
        {--after-id=0      : Skip artworks with ID ≤ this value (useful for resuming)}
        {--limit=          : Stop after processing this many artworks}
        {--chunk=50        : DB chunk size}
        {--dry-run         : Print tags but do not persist them}
        {--skip-tagged     : Skip artworks that already have at least one AI tag}
        {--url-only        : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
        {--dump-curl       : Print the equivalent curl command for the API call and skip the actual request}
        {--debug           : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
        {--url=            : LM Studio base URL (overrides config/env)}
        {--model=          : Model identifier (overrides config/env)}
        {--clear-ai-tags   : Delete existing AI tags for each artwork before re-tagging}
    ';

    protected $description = 'Generate tags for artworks via a local LM Studio vision model';

    // -------------------------------------------------------------------------
    // Prompt
    // -------------------------------------------------------------------------

private const SYSTEM_PROMPT = <<<'PROMPT'
You are a precise visual-art tagging engine for an artwork gallery.

Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation.

Prioritise tags that are:
- visually evident in the image
- concise and specific
- useful for gallery search

Prefer concrete visual concepts over vague opinions.
Do not invent details that are not clearly visible.
Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible.

Return tags that describe:
- subject or scene
- art style or genre
- mood or atmosphere
- colour palette
- technique or medium if visually apparent
- composition or notable visual elements if relevant

Avoid:
- generic filler tags like "beautiful", "nice", "art", "image"
- duplicate or near-duplicate tags
- full sentences
- overly broad tags when a more specific one is visible

Output must be deterministic, compact, and consistent.
PROMPT;

private const USER_PROMPT = <<<'PROMPT'
Analyse this artwork image and return a JSON array of relevant tags.

Requirements:
- Return ONLY a valid JSON array of lowercase strings.
- No markdown, no explanation, no extra text.
- Output between 8 and 14 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- Do not end tags with punctuation.
- Do not include duplicate or near-duplicate tags.
- Order tags from most important to least important.

Focus on tags from these groups when visible:
1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable visual elements or composition

Tagging guidelines:
- Prefer specific tags over generic ones.
- Use searchable gallery-style tags.
- Include only what is clearly visible or strongly implied by the image.
- If the artwork is abstract, prioritise style, colour, mood, and composition.
- If the artwork is representational, prioritise subject, setting, style, and mood.
- If a detail is uncertain, leave it out.

Good output example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"]

Bad output example:
["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"]

Now return only the JSON array.
PROMPT;

    // -------------------------------------------------------------------------

    public function __construct(private readonly TagService $tagService)
    {
        parent::__construct();
    }

    public function handle(): int
    {
        $artworkId  = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
        $afterId    = max(0, (int) $this->option('after-id'));
        $limit      = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
        $chunk      = max(1, min((int) $this->option('chunk'), 200));
        $dryRun      = (bool) $this->option('dry-run');
        $skipTagged  = (bool) $this->option('skip-tagged');
        $dumpCurl    = (bool) $this->option('dump-curl');
        $verbose     = (bool) $this->option('debug');
        $useBase64   = !(bool) $this->option('url-only');
        $clearAiTags = (bool) $this->option('clear-ai-tags');

        $baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
        $model   = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
        $maxTags = (int) config('vision.lm_studio.max_tags', 12);

        $this->info("LM Studio  : {$baseUrl}");
        $this->info("Model      : {$model}");
        $this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
        $this->info("Dry run    : " . ($dryRun ? 'YES' : 'no'));
        $this->info("Clear AI   : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
        if ($artworkId !== null) {
            $this->info("Artwork ID : {$artworkId} (single-artwork mode)");
        }
        $this->line('');

        // Single-artwork mode: bypass public/approved scope so any artwork can be tested.
        if ($artworkId !== null) {
            $artwork = Artwork::withTrashed()->find($artworkId);
            if ($artwork === null) {
                $this->error("Artwork #{$artworkId} not found.");
                return self::FAILURE;
            }
            $limit = 1;
            $query = Artwork::withTrashed()->where('id', $artworkId);
        } else {
            $query = Artwork::query()
                ->public()
                ->where('id', '>', $afterId)
                ->whereNotNull('hash')
                ->whereNotNull('thumb_ext')
                ->orderBy('id');

            if ($skipTagged) {
                // Exclude artworks that already have an AI-sourced tag in the pivot.
                $query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
            }
        }

        $processed = 0;
        $tagged    = 0;
        $skipped   = 0;
        $errors    = 0;

        $query->chunkById($chunk, function ($artworks) use (
            &$processed, &$tagged, &$skipped, &$errors,
            $limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
        ) {
            foreach ($artworks as $artwork) {
                if ($limit !== null && $processed >= $limit) {
                    return false; // stop iteration
                }

                $processed++;

                $imageUrl = $artwork->thumbUrl('md');
                if ($imageUrl === null) {
                    $this->warn("  [#{$artwork->id}] No thumb URL — skip");
                    $skipped++;
                    continue;
                }

                $this->line("  [#{$artwork->id}] {$artwork->title}");

                // Remove AI tags first if requested.
                if ($clearAiTags) {
                    $aiTagIds = DB::table('artwork_tag')
                        ->where('artwork_id', $artwork->id)
                        ->where('source', 'ai')
                        ->pluck('tag_id')
                        ->all();

                    if ($aiTagIds !== []) {
                        if (!$dryRun) {
                            $this->tagService->detachTags($artwork, $aiTagIds);
                        }
                        $this->line('    ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
                    }
                }

                if ($verbose) {
                    $this->line("    CDN URL : {$imageUrl}");
                }

                try {
                    $tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
                } catch (Throwable $e) {
                    $this->error("    ✗ API error: " . $e->getMessage());
                    // Show first 120 chars of the response body for easier debugging.
                    if (str_contains($e->getMessage(), 'status code')) {
                        $this->line("    (use --dry-run to test without saving)");
                    }
                    Log::error('artworks:ai-tag API error', [
                        'artwork_id' => $artwork->id,
                        'error'      => $e->getMessage(),
                    ]);
                    $errors++;
                    continue;
                }

                if ($tags === []) {
                    $this->warn("    ✗ No tags returned");
                    $skipped++;
                    continue;
                }

                $tagList = implode(', ', $tags);
                $this->line("    → {$tagList}");

                if (!$dryRun) {
                    $aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);

                    try {
                        $this->tagService->attachAiTags($artwork, $aiTagPayload);
                        $tagged++;
                    } catch (Throwable $e) {
                        $this->error("    ✗ Save error: " . $e->getMessage());
                        Log::error('artworks:ai-tag save error', [
                            'artwork_id' => $artwork->id,
                            'error'      => $e->getMessage(),
                        ]);
                        $errors++;
                    }
                } else {
                    $tagged++;
                }
            }
        });

        $this->line('');
        $this->info("Done. processed={$processed}  tagged={$tagged}  skipped={$skipped}  errors={$errors}");

        return $errors > 0 ? self::FAILURE : self::SUCCESS;
    }

    // -------------------------------------------------------------------------
    // LM Studio API call
    // -------------------------------------------------------------------------

    /**
     * @return list<string>
     */
    private function fetchTags(
        string $baseUrl,
        string $model,
        string $imageUrl,
        bool $useBase64,
        int $maxTags,
        bool $dumpCurl = false,
        bool $verbose = false,
    ): array {
        $imageContent = $useBase64
            ? $this->buildBase64ImageContent($imageUrl, $verbose)
            : ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];

        $payload = [
            'model'       => $model,
            'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
            'max_tokens'  => (int) config('vision.lm_studio.max_tokens', 300),
            'messages'    => [
                [
                    'role'    => 'system',
                    'content' => self::SYSTEM_PROMPT,
                ],
                [
                    'role'    => 'user',
                    'content' => [
                        $imageContent,
                        ['type' => 'text', 'text' => self::USER_PROMPT],
                    ],
                ],
            ],
        ];

        $timeout        = (int) config('vision.lm_studio.timeout', 60);
        $connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
        $endpoint       = "{$baseUrl}/v1/chat/completions";

        // --dump-curl: write payload to a temp file and print the equivalent curl command.
        if ($dumpCurl) {
            $jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

            // Truncate any base64 data URIs in the printed output so the terminal stays readable.
            $printable = preg_replace(
                '/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
                '$1$2...[base64 truncated]$3',
                $jsonPayload,
            ) ?? $jsonPayload;

            // Write the full (untruncated) payload to a temp file for use with curl --data.
            $tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
            file_put_contents($tmpJson, $jsonPayload);

            $this->line('');
            $this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
            $this->line($printable);
            $this->line('');
            $this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
            $this->line(
                'curl -s -X POST ' . escapeshellarg($endpoint)
                . ' -H ' . escapeshellarg('Content-Type: application/json')
                . ' --data @' . escapeshellarg($tmpJson)
                . ' | python -m json.tool'
            );
            $this->line('');
            $this->info("Full JSON payload written to: {$tmpJson}");

            // Return empty — no real API call is made.
            return [];
        }

        $response = Http::timeout($timeout)
            ->connectTimeout($connectTimeout)
            ->post($endpoint, $payload)
            ->throw();

        $body    = $response->json();
        $content = $body['choices'][0]['message']['content'] ?? '';

        return $this->parseTags((string) $content, $maxTags);
    }

    /**
     * Download the image using the system curl binary (raw bytes, no encoding surprises),
     * base64-encode from the local file, then delete it.
     *
     * Using curl directly is more reliable than the Laravel Http client here because it
     * avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
     * edge cases that could corrupt the image bytes before encoding.
     *
     * @return array<string, mixed>
     * @throws \RuntimeException if curl fails or the file is empty
     */
    private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
    {
        $ext  = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
        $mime = match ($ext) {
            'png'  => 'image/png',
            'gif'  => 'image/gif',
            'webp' => 'image/webp',
            default => 'image/jpeg',
        };

        $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');

        try {
            exec(
                'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
                $output,
                $exitCode,
            );

            if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
                throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
            }

            $rawBytes = file_get_contents($tmpPath);
            if ($rawBytes === false || $rawBytes === '') {
                throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
            }

            // LM Studio does not support WebP. Convert to JPEG via GD if needed.
            if ($mime === 'image/webp') {
                $convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
                try {
                    if (!function_exists('imagecreatefromwebp')) {
                        throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
                    }
                    $img = imagecreatefromwebp($tmpPath);
                    if ($img === false) {
                        throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
                    }
                    imagejpeg($img, $convertedPath, 92);
                    imagedestroy($img);
                    $rawBytes = file_get_contents($convertedPath);
                    $mime     = 'image/jpeg';
                    if ($verbose) {
                        $this->line('    Convert : WebP → JPEG (LM Studio does not accept WebP)');
                    }
                } finally {
                    @unlink($convertedPath);
                }
            }

            if ($verbose) {
                $fileSize  = filesize($tmpPath);
                // Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
                $magicHex  = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
                $this->line("    File    : {$tmpPath}");
                $this->line("    Size    : {$fileSize} bytes");
                $this->line("    Magic   : {$magicHex}  (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
            }

            $base64  = base64_encode($rawBytes);
            $dataUri = "data:{$mime};base64,{$base64}";

            if ($verbose) {
                $this->line("    MIME    : {$mime}");
                $this->line("    URI pfx : " . substr($dataUri, 0, 60) . '...');
            }
        } finally {
            @unlink($tmpPath);
        }

        return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
    }

    // -------------------------------------------------------------------------
    // Response parsing
    // -------------------------------------------------------------------------

    /**
     * Extract a JSON array from the model's response text.
     *
     * The model should return just the array, but may include surrounding text
     * or markdown code fences, so we search for the first `[…]` block.
     *
     * @return list<string>
     */
    private function parseTags(string $content, int $maxTags): array
    {
        $content = trim($content);

        // Strip markdown code fences if present (```json … ```)
        $content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
        $content = preg_replace('/\s*```$/', '', $content) ?? $content;

        // Extract the first JSON array from the text.
        if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
            return [];
        }

        $decoded = json_decode($matches[1], true);
        if (!is_array($decoded)) {
            return [];
        }

        $tags = [];
        foreach ($decoded as $item) {
            if (!is_string($item)) {
                continue;
            }
            $clean = trim(strtolower((string) $item));
            if ($clean !== '') {
                $tags[] = $clean;
            }
        }

        // Respect the configured max-tags ceiling.
        return array_slice(array_unique($tags), 0, $maxTags);
    }
}
