<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VectorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

final class ArtworkEvolutionService
{
    /**
     * @return list<string>
     */
    public static function relationTypes(): array
    {
        return [
            ArtworkRelation::TYPE_REMAKE_OF,
            ArtworkRelation::TYPE_REMASTER_OF,
            ArtworkRelation::TYPE_REVISION_OF,
            ArtworkRelation::TYPE_INSPIRED_BY,
            ArtworkRelation::TYPE_VARIATION_OF,
        ];
    }

    public function __construct(
        private readonly ArtworkMaturityService $maturity,
        private readonly GroupService $groups,
        private readonly VectorService $vectors,
    ) {
    }

    /**
     * @return array<int, array<string, string>>
     */
    public function relationTypeOptions(): array
    {
        return array_map(fn (string $type): array => [
            'value' => $type,
            'label' => $this->relationTypeLabel($type),
            'short_label' => $this->relationTypeShortLabel($type),
        ], self::relationTypes());
    }

    /**
     * @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
     */
    public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
    {
        $this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');

        $targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
        $relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
        $note = $this->normalizeNote($payload['note'] ?? null);

        if ($targetArtworkId <= 0) {
            ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();

            return null;
        }

        if ($targetArtworkId === (int) $sourceArtwork->id) {
            throw ValidationException::withMessages([
                'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
            ]);
        }

        $targetArtwork = Artwork::query()
            ->with(['group.members'])
            ->find($targetArtworkId);

        if (! $targetArtwork) {
            throw ValidationException::withMessages([
                'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
            ]);
        }

        $this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');

        if (! $this->isPubliclyVisible($targetArtwork)) {
            throw ValidationException::withMessages([
                'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
            ]);
        }

        if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
            throw ValidationException::withMessages([
                'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
            ]);
        }

        return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
            ArtworkRelation::query()
                ->where('source_artwork_id', (int) $sourceArtwork->id)
                ->delete();

            return ArtworkRelation::query()->create([
                'source_artwork_id' => (int) $sourceArtwork->id,
                'target_artwork_id' => (int) $targetArtwork->id,
                'relation_type' => $relationType,
                'note' => $note,
                'sort_order' => 0,
                'created_by_user_id' => (int) $actor->id,
            ])->load([
                'targetArtwork.user.profile',
                'targetArtwork.group',
                'targetArtwork.categories.contentType',
            ]);
        });
    }

    /**
     * @return array<string, mixed>|null
     */
    public function editorRelation(Artwork $artwork, User $actor): ?array
    {
        $relation = ArtworkRelation::query()
            ->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
            ->where('source_artwork_id', (int) $artwork->id)
            ->orderBy('sort_order')
            ->orderBy('id')
            ->first();

        if (! $relation || ! $relation->targetArtwork) {
            return null;
        }

        return [
            'id' => (int) $relation->id,
            'relation_type' => (string) $relation->relation_type,
            'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
            'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
            'note' => $relation->note,
            'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
        ];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
    {
        $this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');

        $manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
            ->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
            ->pluck('id')
            ->map(static fn ($id): int => (int) $id)
            ->filter()
            ->values();

        $term = trim($search);
        $safeLimit = max(1, min($limit, 36));
        $rankedOptions = [];
        $rankedIds = [];

        if ($this->vectors->isConfigured()) {
            $rankedOptions = $this->similarityRankedOptions($sourceArtwork, $actor, $manageableGroupIds->all(), $term, $safeLimit);
            $rankedIds = array_map(static fn (array $option): int => (int) ($option['id'] ?? 0), $rankedOptions);
            $rankedIds = array_values(array_filter($rankedIds));
        }

        if (count($rankedOptions) >= $safeLimit) {
            return array_slice($rankedOptions, 0, $safeLimit);
        }

        $fallbackOptions = $this->fallbackSearchOptions(
            $sourceArtwork,
            $actor,
            $manageableGroupIds->all(),
            $term,
            $safeLimit,
            $rankedIds,
        );

        return collect(array_merge($rankedOptions, $fallbackOptions))
            ->unique('id')
            ->take($safeLimit)
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>|null
     */
    public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
    {
        $primaryRelation = ArtworkRelation::query()
            ->with([
                'sourceArtwork.user.profile',
                'sourceArtwork.group',
                'sourceArtwork.categories.contentType',
                'targetArtwork.user.profile',
                'targetArtwork.group',
                'targetArtwork.categories.contentType',
            ])
            ->where('source_artwork_id', (int) $artwork->id)
            ->orderBy('sort_order')
            ->orderBy('id')
            ->first();

        $incomingRelations = ArtworkRelation::query()
            ->with([
                'sourceArtwork.user.profile',
                'sourceArtwork.group',
                'sourceArtwork.categories.contentType',
                'targetArtwork.user.profile',
                'targetArtwork.group',
                'targetArtwork.categories.contentType',
            ])
            ->where('target_artwork_id', (int) $artwork->id)
            ->orderByDesc('updated_at')
            ->orderByDesc('id')
            ->limit(4)
            ->get();

        $primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
        $updates = $incomingRelations
            ->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
            ->filter()
            ->values()
            ->all();

        if ($primary === null && $updates === []) {
            return null;
        }

        return [
            'eyebrow' => 'Artwork Evolution',
            'primary' => $primary,
            'updates' => $updates,
        ];
    }

    private function ensureManageable(User $actor, Artwork $artwork, string $message): void
    {
        if (! Gate::forUser($actor)->allows('update', $artwork)) {
            throw ValidationException::withMessages([
                'evolution_target_artwork_id' => $message,
            ]);
        }
    }

    private function isPubliclyVisible(Artwork $artwork): bool
    {
        return ! $artwork->trashed()
            && (bool) $artwork->is_public
            && (bool) $artwork->is_approved
            && $artwork->published_at !== null
            && $artwork->published_at->lte(now());
    }

    private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
    {
        $sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
        $targetTimestamp = $this->comparisonTimestamp($targetArtwork);

        if ($sourceTimestamp === null || $targetTimestamp === null) {
            return true;
        }

        return $targetTimestamp->lt($sourceTimestamp);
    }

    private function comparisonTimestamp(Artwork $artwork): ?Carbon
    {
        $value = $artwork->published_at ?: $artwork->created_at;

        return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
    }

    private function normalizeRelationType(string $type): string
    {
        $normalized = Str::lower(trim($type));

        return in_array($normalized, self::relationTypes(), true)
            ? $normalized
            : ArtworkRelation::TYPE_REMAKE_OF;
    }

    private function normalizeNote(mixed $note): ?string
    {
        $resolved = trim((string) $note);

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

    /**
     * @return array<string, mixed>
     */
    private function mapStudioOption(Artwork $artwork, User $actor, array $context = []): array
    {
        $category = $artwork->categories->sortBy('sort_order')->first();
        $publishedAt = $artwork->published_at;
        $year = $publishedAt?->year ?: $artwork->created_at?->year;
        $similarityScore = array_key_exists('similarity_score', $context) && is_numeric($context['similarity_score'])
            ? round((float) $context['similarity_score'], 5)
            : null;

        return [
            'id' => (int) $artwork->id,
            'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
            'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
            'year' => $year,
            'published_at' => optional($publishedAt)->toIsoString(),
            'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
            'url' => route('art.show', [
                'id' => (int) $artwork->id,
                'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
            ]),
            'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
            'content_type' => $category?->contentType?->name,
            'category' => $category?->name,
            'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
            'similarity_score' => $similarityScore,
            'sort_source' => (string) ($context['sort_source'] ?? 'fallback'),
        ];
    }

    /**
     * @param list<int> $manageableGroupIds
     * @return list<array<string, mixed>>
     */
    private function similarityRankedOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit): array
    {
        try {
            $matches = $this->vectors->similarToArtwork($sourceArtwork, min(120, max($limit * 4, 48)));
        } catch (\Throwable) {
            return [];
        }

        $orderedIds = [];
        $scores = [];

        foreach ($matches as $match) {
            $candidateId = (int) ($match['id'] ?? 0);
            if ($candidateId <= 0 || isset($scores[$candidateId])) {
                continue;
            }

            $orderedIds[] = $candidateId;
            $scores[$candidateId] = (float) ($match['score'] ?? 0.0);
        }

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

        $candidates = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term)
            ->whereIn('id', $orderedIds)
            ->get()
            ->keyBy('id');

        $options = [];
        foreach ($orderedIds as $candidateId) {
            /** @var Artwork|null $candidate */
            $candidate = $candidates->get($candidateId);
            if (! $candidate) {
                continue;
            }

            $options[] = $this->mapStudioOption($candidate, $actor, [
                'similarity_score' => $scores[$candidateId] ?? null,
                'sort_source' => 'vector_similarity',
            ]);

            if (count($options) >= $limit) {
                break;
            }
        }

        return $options;
    }

    /**
     * @param list<int> $manageableGroupIds
     * @param list<int> $excludeIds
     * @return list<array<string, mixed>>
     */
    private function fallbackSearchOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit, array $excludeIds = []): array
    {
        $query = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term);

        if ($excludeIds !== []) {
            $query->whereNotIn('id', $excludeIds);
        }

        return $query
            ->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
            ->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
            ->orderByDesc('published_at')
            ->limit(max($limit * 2, 36))
            ->get()
            ->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
            ->values()
            ->all();
    }

    /**
     * @param list<int> $manageableGroupIds
     */
    private function manageableCandidatesQuery(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term): Builder
    {
        $query = Artwork::query()
            ->with(['user.profile', 'group', 'categories.contentType'])
            ->whereKeyNot((int) $sourceArtwork->id)
            ->whereNull('deleted_at')
            ->where('is_public', true)
            ->where('is_approved', true)
            ->whereNotNull('published_at')
            ->where('published_at', '<=', now())
            ->where(function ($builder) use ($actor, $manageableGroupIds): void {
                $builder->where('user_id', (int) $actor->id);

                if ($manageableGroupIds !== []) {
                    $builder->orWhereIn('group_id', $manageableGroupIds);
                }
            });

        $referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
        if ($referenceTimestamp !== null) {
            $query->where('published_at', '<=', $referenceTimestamp);
        }

        if ($term !== '') {
            $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';

            $query->where(function ($builder) use ($like): void {
                $builder->where('title', 'like', $like)
                    ->orWhere('slug', 'like', $like)
                    ->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
                    ->orWhereHas('user', fn ($userQuery) => $userQuery
                        ->where('name', 'like', $like)
                        ->orWhere('username', 'like', $like));
            });
        }

        return $query;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
    {
        $beforeArtwork = $relation->targetArtwork;
        $afterArtwork = $relation->sourceArtwork;

        if (! $beforeArtwork || ! $afterArtwork) {
            return null;
        }

        if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
            return null;
        }

        $before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
        $after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));

        if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
            return null;
        }

        $beforeYear = $before['year'] ?? null;
        $afterYear = $after['year'] ?? null;
        $yearsApart = $this->yearsApart($beforeYear, $afterYear);

        return [
            'id' => (int) $relation->id,
            'relation_type' => (string) $relation->relation_type,
            'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
            'heading' => 'Then & Now',
            'summary' => $this->primarySummary($beforeYear, $yearsApart),
            'years_apart' => $yearsApart,
            'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
            'note' => $relation->note,
            'before' => $before,
            'after' => $after,
            'compare' => [
                'available' => $this->compareAvailable($before, $after),
                'title' => 'Then & Now comparison',
            ],
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
    {
        $beforeArtwork = $relation->targetArtwork;
        $afterArtwork = $relation->sourceArtwork;

        if (! $beforeArtwork || ! $afterArtwork) {
            return null;
        }

        if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
            return null;
        }

        $before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
        $after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));

        if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
            return null;
        }

        $yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);

        return [
            'id' => (int) $relation->id,
            'relation_type' => (string) $relation->relation_type,
            'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
            'heading' => 'Updated Version',
            'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
            'years_apart' => $yearsApart,
            'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
            'note' => $relation->note,
            'before' => $before,
            'after' => $after,
            'compare' => [
                'available' => $this->compareAvailable($before, $after),
                'title' => 'Compare versions',
            ],
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
    {
        $category = $artwork->categories->sortBy('sort_order')->first();
        $md = ThumbnailPresenter::present($artwork, 'md');
        $lg = ThumbnailPresenter::present($artwork, 'lg');
        $xl = ThumbnailPresenter::present($artwork, 'xl');
        $publishedAt = $artwork->published_at;

        return $this->maturity->decoratePayload([
            'id' => (int) $artwork->id,
            'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
            'url' => route('art.show', [
                'id' => (int) $artwork->id,
                'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
            ]),
            'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
            'published_at' => optional($publishedAt)->toIsoString(),
            'year' => $publishedAt?->year ?: $artwork->created_at?->year,
            'role_label' => $roleLabel,
            'thumbnail' => $md['url'] ?? null,
            'image_md' => $md['url'] ?? null,
            'image_lg' => $lg['url'] ?? null,
            'image_xl' => $xl['url'] ?? null,
            'width' => (int) ($artwork->width ?? 0),
            'height' => (int) ($artwork->height ?? 0),
            'content_type' => $category?->contentType?->name,
            'category' => $category?->name,
        ], $artwork, $viewer);
    }

    /**
     * @param array<string, mixed> $card
     */
    private function shouldOmitForViewer(array $card): bool
    {
        return (bool) data_get($card, 'maturity.should_hide', false);
    }

    /**
     * @param array<string, mixed> $before
     * @param array<string, mixed> $after
     */
    private function compareAvailable(array $before, array $after): bool
    {
        return ! empty($before['image_lg']) && ! empty($after['image_lg']);
    }

    private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
    {
        if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
            return null;
        }

        return max(0, (int) $afterYear - (int) $beforeYear);
    }

    private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
    {
        if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
            return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
        }

        if (is_numeric($beforeYear)) {
            return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
        }

        return 'This artwork revisits an earlier version from the creator archive.';
    }

    private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
    {
        if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
            return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
        }

        if (is_numeric($afterYear)) {
            return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
        }

        return 'This artwork later received an updated version from the same creator.';
    }

    private function relationTypeLabel(string $type): string
    {
        return match ($type) {
            ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
            ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
            ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
            ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
            default => 'Remake of',
        };
    }

    private function relationTypeShortLabel(string $type): string
    {
        return match ($type) {
            ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
            ArtworkRelation::TYPE_REVISION_OF => 'Update',
            ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
            ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
            default => 'Remake',
        };
    }
}