<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Meilisearch\Client as MeilisearchClient;

final class ReconcileArtworkSearchIndexCommand extends Command
{
    protected $signature = 'artworks:search-reconcile
                            {--id=* : Specific artwork IDs to inspect instead of scanning the full catalog}
                            {--after-id=0 : Resume scanning after this artwork id in the chosen sort direction}
                            {--chunk=200 : Number of artworks per chunk}
                            {--limit=0 : Stop after this many artworks (0 = no limit)}
                            {--recent-minutes=0 : Only inspect artworks touched recently by created_at, updated_at, or published_at}
                            {--reverse : Process highest artwork ids first}
                            {--repair : Apply fixes instead of reporting only}
                            {--queue : When repairing, dispatch queue jobs instead of writing directly to Meilisearch}
                            {--remove-unexpected : Remove live documents for artworks that should not be indexed}
                            {--no-cache-bump : Skip bumping explore cache version after repairs}}';

    protected $description = 'Audit the artwork Meilisearch index against the database and repair missing or stale documents.';

    public function handle(MeilisearchClient $client): int
    {
        $chunk = max(1, (int) $this->option('chunk'));
        $limit = max(0, (int) $this->option('limit'));
        $afterId = max(0, (int) $this->option('after-id'));
        $recentMinutes = max(0, (int) $this->option('recent-minutes'));
        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
        $reverse = (bool) $this->option('reverse');
        $repair = (bool) $this->option('repair');
        $queue = (bool) $this->option('queue');
        $removeUnexpected = (bool) $this->option('remove-unexpected');
        $bumpCache = ! (bool) $this->option('no-cache-bump');

        if ($queue && ! $repair) {
            $this->error('The --queue option requires --repair.');

            return self::FAILURE;
        }

        $query = Artwork::query()
            ->withoutGlobalScopes()
            ->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
            ->when($afterId > 0, function ($builder) use ($afterId, $reverse): void {
                $builder->where('id', $reverse ? '<' : '>', $afterId);
            })
            ->orderBy('id', $reverse ? 'desc' : 'asc');

        if ($ids === [] && $recentMinutes > 0) {
            $cutoff = Carbon::now()->subMinutes($recentMinutes);

            $query->where(function ($builder) use ($cutoff): void {
                $builder->where('created_at', '>=', $cutoff)
                    ->orWhere('updated_at', '>=', $cutoff)
                    ->orWhere('published_at', '>=', $cutoff);
            });
        }

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

        $uncappedTotal = (clone $query)->count();

        if ($limit > 0) {
            $query->limit($limit);
        }

        $total = $limit > 0 ? min($limit, $uncappedTotal) : $uncappedTotal;

        if ($total === 0) {
            $this->warn('No artworks matched the reconcile query.');

            return self::SUCCESS;
        }

        $this->info(sprintf(
            '%sReconciling %d artwork(s)%s%s%s%s.',
            $repair ? '[REPAIR] ' : '[REPORT] ',
            $total,
            $ids !== [] ? ' for selected ids' : '',
            $recentMinutes > 0 && $ids === [] ? sprintf(' touched in the last %d minute(s)', $recentMinutes) : '',
            $reverse ? ' newest first' : '',
            $queue ? ' using queued repair jobs' : '',
        ));

        $bar = $this->output->createProgressBar($total);
        $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
        $bar->start();

        $stats = [
            'processed' => 0,
            'ok' => 0,
            'missing' => 0,
            'stale' => 0,
            'unexpected' => 0,
            'repaired' => 0,
            'failed' => 0,
        ];

        $indexName = null;

        $chunkMethod = $reverse ? 'chunkByIdDesc' : 'chunkById';

        $query->{$chunkMethod}($chunk, function ($artworks) use ($client, $repair, $queue, $removeUnexpected, $bar, &$stats, &$indexName): void {
            foreach ($artworks as $artwork) {
                $stats['processed']++;

                $indexName ??= $artwork->searchableAs();
                $artworkId = (int) $artwork->id;
                $eligible = $this->shouldBeIndexed($artwork);
                $generatedDocument = $eligible ? $artwork->toSearchableArray() : null;
                $liveDocument = null;
                $liveMissing = false;

                try {
                    $liveDocument = $client->index($indexName)->getDocument($artworkId);
                } catch (\Throwable $exception) {
                    if ($this->isMissingDocumentError($exception)) {
                        $liveMissing = true;
                    } else {
                        $stats['failed']++;
                        $bar->clear();
                        $this->line(sprintf('  <error>error</error> #%d %s', $artworkId, $exception->getMessage()));
                        $bar->display();
                        $bar->advance();
                        continue;
                    }
                }

                $status = 'ok';

                if ($eligible) {
                    if ($liveMissing) {
                        $status = 'missing';
                        $stats['missing']++;
                    } elseif (! $this->documentsMatch($generatedDocument, $liveDocument)) {
                        $status = 'stale';
                        $stats['stale']++;
                    } else {
                        $stats['ok']++;
                    }
                } else {
                    if (! $liveMissing) {
                        $status = 'unexpected';
                        $stats['unexpected']++;
                    } else {
                        $stats['ok']++;
                    }
                }

                if ($status !== 'ok') {
                    $bar->clear();
                    $this->line(sprintf('  <comment>%s</comment> #%d %s', $status, $artworkId, (string) ($artwork->slug ?? '')));
                    $bar->display();
                }

                if ($repair) {
                    try {
                        if (in_array($status, ['missing', 'stale'], true)) {
                            $this->repairIndexDocument($client, $artwork, $generatedDocument ?? [], $queue);
                            $stats['repaired']++;
                        } elseif ($status === 'unexpected' && $removeUnexpected) {
                            $this->repairUnexpectedDocument($client, $artworkId, $indexName, $queue);
                            $stats['repaired']++;
                        }
                    } catch (\Throwable $exception) {
                        $stats['failed']++;
                        $bar->clear();
                        $this->line(sprintf('  <error>repair failed</error> #%d %s', $artworkId, $exception->getMessage()));
                        $bar->display();
                    }
                }

                $bar->advance();
            }
        }, 'id');

        $bar->finish();
        $this->newLine(2);

        if ($repair && $stats['repaired'] > 0 && $bumpCache) {
            $newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
            Cache::forever('explore.cache.version', $newVersion);
            $this->line("Explore cache version bumped to {$newVersion}.");
        }

        $this->table(
            ['processed', 'ok', 'missing', 'stale', 'unexpected', 'repaired', 'failed'],
            [[
                $stats['processed'],
                $stats['ok'],
                $stats['missing'],
                $stats['stale'],
                $stats['unexpected'],
                $stats['repaired'],
                $stats['failed'],
            ]]
        );

        if (! $repair) {
            $this->line('Run again with --repair to fix missing/stale documents directly.');
        } elseif (! $removeUnexpected && $stats['unexpected'] > 0) {
            $this->line('Unexpected live documents were only reported. Re-run with --remove-unexpected to delete them.');
        }

        return $stats['failed'] > 0 ? self::FAILURE : self::SUCCESS;
    }

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

    /**
     * @param  array<string, mixed>  $generatedDocument
     * @param  mixed  $liveDocument
     */
    private function documentsMatch(array $generatedDocument, mixed $liveDocument): bool
    {
        if (! is_array($liveDocument)) {
            return false;
        }

        return $this->normalizeForComparison($generatedDocument) === $this->normalizeForComparison($liveDocument);
    }

    /**
     * @param  array<string, mixed>  $document
     */
    private function repairIndexDocument(MeilisearchClient $client, Artwork $artwork, array $document, bool $queue): void
    {
        if ($queue) {
            IndexArtworkJob::dispatch((int) $artwork->id);

            return;
        }

        $client->index($artwork->searchableAs())->addDocuments([$document]);
    }

    private function repairUnexpectedDocument(MeilisearchClient $client, int $artworkId, string $indexName, bool $queue): void
    {
        if ($queue) {
            DeleteArtworkFromIndexJob::dispatch($artworkId);

            return;
        }

        $client->index($indexName)->deleteDocument($artworkId);
    }

    private function isMissingDocumentError(\Throwable $exception): bool
    {
        return str_contains(strtolower($exception->getMessage()), 'not found');
    }

    /**
     * @param  array<string, mixed>  $document
     * @return array<string, mixed>
     */
    private function normalizeForComparison(array $document): array
    {
        $normalized = Arr::sortRecursive($document);
        unset($normalized['_formatted']);

        return $normalized;
    }
}