<?php

declare(strict_types=1);

namespace App\Services;

use App\Jobs\IndexArtworkJob;
use App\Jobs\RecComputeSimilarByTagsJob;
use App\Jobs\RecComputeSimilarHybridJob;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagNormalizer;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class TagService
{
    public function __construct(
        private readonly TagNormalizer $normalizer,
    ) {
    }

    public function createOrFindTag(string $rawTag): Tag
    {
        $normalized  = $this->normalizer->normalize($rawTag);
        $this->validateNormalizedTag($normalized);

        // Derive display name from the clean slug, not the raw input.
        // This ensures consistent casing regardless of how the tag was submitted.
        // "digital-art" → "Digital Art", "sci-fi-landscape" → "Sci Fi Landscape"
        $displayName = $this->normalizer->toDisplayName($normalized);

        return Tag::query()->firstOrCreate(
            ['slug' => $normalized],
            ['name' => $displayName, 'usage_count' => 0, 'is_active' => true]
        );
    }

    /**
     * @param array<int, string> $tags
     */
    public function attachUserTags(Artwork $artwork, array $tags): void
    {
        $normalized = $this->normalizeUserTags($tags);
        if ($normalized === []) {
            return;
        }

        DB::transaction(function () use ($artwork, $normalized): void {
            $tagIdsBySlug = [];
            foreach ($normalized as $tag) {
                $model = $this->createOrFindTag($tag);
                $tagIdsBySlug[$model->slug] = $model->id;
            }

            $tagIds = array_values($tagIdsBySlug);

            $existing = DB::table('artwork_tag')
                ->where('artwork_id', $artwork->id)
                ->whereIn('tag_id', $tagIds)
                ->pluck('source', 'tag_id')
                ->all();

            $toAttach = [];
            $toUpdate = [];
            $newlyAttachedTagIds = [];

            foreach ($tagIds as $tagId) {
                $source = $existing[$tagId] ?? null;

                if ($source === null) {
                    $toAttach[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
                    $newlyAttachedTagIds[] = $tagId;
                    continue;
                }

                if ($source !== 'user') {
                    // User tags take precedence over AI/system.
                    $toUpdate[$tagId] = ['source' => 'user', 'confidence' => null];
                }
            }

            if ($toAttach !== []) {
                $artwork->tags()->syncWithoutDetaching($toAttach);
                $this->incrementUsageCounts($newlyAttachedTagIds);
            }

            foreach ($toUpdate as $tagId => $payload) {
                $artwork->tags()->updateExistingPivot($tagId, $payload);
            }
        });

        $this->queueReindex($artwork);
    }

    /**
     * @param array<int, array{tag: string, confidence?: float|int|null}> $aiTags
     */
    public function attachAiTags(Artwork $artwork, array $aiTags): void
    {
        if ($aiTags === []) {
            return;
        }

        DB::transaction(function () use ($artwork, $aiTags): void {
            $payloads = [];
            $newlyAttachedTagIds = [];

            foreach ($aiTags as $row) {
                $raw = (string) ($row['tag'] ?? '');
                $confidence = $row['confidence'] ?? null;

                $normalized = $this->normalizer->normalize($raw);
                if ($normalized === '') {
                    continue;
                }

                // AI tagging must be optional: invalid/banned tags are skipped (not fatal).
                try {
                    $this->validateNormalizedTag($normalized);
                } catch (ValidationException $e) {
                    continue;
                }

                $tag = $this->createOrFindTag($normalized);

                $existingSource = DB::table('artwork_tag')
                    ->where('artwork_id', $artwork->id)
                    ->where('tag_id', $tag->id)
                    ->value('source');

                if ($existingSource === 'user') {
                    continue;
                }

                if ($existingSource === null) {
                    $payloads[$tag->id] = [
                        'source' => 'ai',
                        'confidence' => is_numeric($confidence) ? (float) $confidence : null,
                        'created_at' => now(),
                    ];
                    $newlyAttachedTagIds[] = $tag->id;
                    continue;
                }

                if ($existingSource === 'ai') {
                    $artwork->tags()->updateExistingPivot($tag->id, [
                        'confidence' => is_numeric($confidence) ? (float) $confidence : null,
                    ]);
                }
            }

            if ($payloads !== []) {
                $artwork->tags()->syncWithoutDetaching($payloads);
                $this->incrementUsageCounts($newlyAttachedTagIds);
            }
        });

        $this->queueReindex($artwork);
    }

    public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
    {
        if ($tagSlugsOrIds === []) {
            return;
        }

        $tagIds = Tag::query()
            ->whereIn('id', array_filter($tagSlugsOrIds, 'is_numeric'))
            ->orWhereIn('slug', array_filter($tagSlugsOrIds, fn ($v) => is_string($v) && $v !== ''))
            ->pluck('id')
            ->all();

        if ($tagIds === []) {
            return;
        }

        DB::transaction(function () use ($artwork, $tagIds): void {
            $existing = DB::table('artwork_tag')
                ->where('artwork_id', $artwork->id)
                ->whereIn('tag_id', $tagIds)
                ->pluck('tag_id')
                ->all();

            if ($existing === []) {
                return;
            }

            $artwork->tags()->detach($existing);
            $this->decrementUsageCounts($existing);
        });

        $this->queueReindex($artwork);
    }

    /**
     * Sync user tags (PUT semantics): replaces the set of user-origin tags.
     *
     * @param array<int, string> $tags
     */
    public function syncTags(Artwork $artwork, array $tags): void
    {
        $normalized = $this->normalizeUserTags($tags);

        DB::transaction(function () use ($artwork, $normalized): void {
            $desiredTagIds = [];
            foreach ($normalized as $tag) {
                $model = $this->createOrFindTag($tag);
                $desiredTagIds[] = $model->id;
            }

            $desiredTagIds = array_values(array_unique($desiredTagIds));

            $currentUserTagIds = DB::table('artwork_tag')
                ->where('artwork_id', $artwork->id)
                ->where('source', 'user')
                ->pluck('tag_id')
                ->all();

            $toDetach = array_values(array_diff($currentUserTagIds, $desiredTagIds));
            $toAttach = array_values(array_diff($desiredTagIds, $currentUserTagIds));

            if ($toDetach !== []) {
                $artwork->tags()->detach($toDetach);
                $this->decrementUsageCounts($toDetach);
            }

            if ($toAttach !== []) {
                $payload = [];
                foreach ($toAttach as $tagId) {
                    $payload[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
                }
                $artwork->tags()->syncWithoutDetaching($payload);
                $this->incrementUsageCounts($toAttach);
            }

            // Ensure desired tags are marked as user (user precedence).
            if ($desiredTagIds !== []) {
                $existingNonUser = DB::table('artwork_tag')
                    ->where('artwork_id', $artwork->id)
                    ->whereIn('tag_id', $desiredTagIds)
                    ->where('source', '!=', 'user')
                    ->pluck('tag_id')
                    ->all();

                foreach ($existingNonUser as $tagId) {
                    $artwork->tags()->updateExistingPivot($tagId, ['source' => 'user', 'confidence' => null]);
                }
            }
        });

        $this->queueReindex($artwork);
    }

    /**
     * Sync the exact visible tag set from Studio editing.
     *
     * For manual/mixed saves, the submitted list becomes authoritative:
     * removed tags are detached regardless of prior source, and remaining
     * tags are upgraded to `user` source to reflect manual curation.
     *
     * For ai_applied saves, the submitted list is stored as AI tags.
     *
     * @param array<int, string> $tags
     */
    public function syncStudioTags(Artwork $artwork, array $tags, string $source = 'manual'): void
    {
        if ($source === 'ai_applied') {
            $currentTags = $artwork->tags()->pluck('tags.slug')->all();
            if ($currentTags !== []) {
                $this->detachTags($artwork, $currentTags);
            }

            $normalizedAi = $this->normalizeUserTags($tags);
            $this->attachAiTags(
                $artwork,
                array_map(static fn (string $tag): array => ['tag' => $tag], $normalizedAi)
            );

            return;
        }

        $normalized = $this->normalizeUserTags($tags);
        $currentAll = $artwork->tags()->pluck('tags.slug')->all();
        $toDetach = array_values(array_diff($currentAll, $normalized));

        if ($toDetach !== []) {
            $this->detachTags($artwork, $toDetach);
        }

        $this->syncTags($artwork, $normalized);
    }

    public function updateUsageCount(Tag $tag): void
    {
        $this->syncUsageCount($tag);
    }

    /**
     * Recalculate and persist the real usage_count from artwork_tag.
     *
     * @return array{before:int, after:int, changed:bool}
     */
    public function syncUsageCount(Tag $tag): array
    {
        $before = (int) $tag->usage_count;
        $count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
        $tag->forceFill(['usage_count' => $count, 'artworks_count' => $count])->save();

        return [
            'before' => $before,
            'after' => $count,
            'changed' => $before !== $count,
        ];
    }

    /**
     * @param array<int, string> $tags
     * @return array<int, string>
     */
    private function normalizeUserTags(array $tags): array
    {
        $max = (int) config('tags.max_user_tags', 30);
        if (count($tags) > $max) {
            throw ValidationException::withMessages([
                'tags' => ["Too many tags (max {$max})."],
            ]);
        }

        $normalized = [];
        foreach ($tags as $tag) {
            $value = $this->normalizer->normalize((string) $tag);
            if ($value === '') {
                continue;
            }
            $this->validateNormalizedTag($value);
            $normalized[] = $value;
        }

        $normalized = array_values(array_unique($normalized));
        return $normalized;
    }

    private function validateNormalizedTag(string $normalized): void
    {
        if ($normalized === '') {
            throw ValidationException::withMessages([
                'tags' => ['Invalid tag.'],
            ]);
        }

        $banned = array_map('strval', (array) config('tags.banned', []));
        if ($banned !== [] && in_array($normalized, $banned, true)) {
            throw ValidationException::withMessages([
                'tags' => ['Tag is not allowed.'],
            ]);
        }

        $patterns = (array) config('tags.banned_regex', []);
        foreach ($patterns as $pattern) {
            $pattern = (string) $pattern;
            if ($pattern === '') {
                continue;
            }
            if (@preg_match($pattern, $normalized) === 1) {
                throw ValidationException::withMessages([
                    'tags' => ['Tag is not allowed.'],
                ]);
            }
        }
    }

    /**
     * @param array<int, int> $tagIds
     */
    private function incrementUsageCounts(array $tagIds): void
    {
        if ($tagIds === []) {
            return;
        }

        DB::table('tags')->whereIn('id', $tagIds)->update([
            'usage_count'    => DB::raw('usage_count + 1'),
            'artworks_count' => DB::raw('artworks_count + 1'),
        ]);
    }

    /**
     * @param array<int, int> $tagIds
     */
    private function decrementUsageCounts(array $tagIds): void
    {
        if ($tagIds === []) {
            return;
        }

        // Never allow negative counts.
        DB::table('tags')
            ->whereIn('id', $tagIds)
            ->update([
                'usage_count'    => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END'),
                'artworks_count' => DB::raw('CASE WHEN artworks_count > 0 THEN artworks_count - 1 ELSE 0 END'),
            ]);
    }

    /**
     * Dispatch a non-blocking reindex job for the given artwork.
     * Called after every tag mutation so the search index stays consistent.
     */
    private function queueReindex(Artwork $artwork): void
    {
        IndexArtworkJob::dispatch($artwork->id);

        // §7.5 On-demand: recompute tag/hybrid similarity when tags change.
        // Pivot syncs don't trigger the Artwork "updated" event, so we dispatch here.
        if ($artwork->is_public && $artwork->published_at) {
            RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
            RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinute());
        }
    }
}
