<?php

declare(strict_types=1);

namespace App\Services\Search\Drivers;

use App\Enums\SecondarySearchIndex;
use App\Jobs\ReindexReleaseJob;
use App\Models\BookInfo;
use App\Models\ConsoleInfo;
use App\Models\GamesInfo;
use App\Models\MovieInfo;
use App\Models\MusicInfo;
use App\Models\Release;
use App\Models\SteamApp;
use App\Models\Video;
use App\Services\Search\Contracts\SearchDriverInterface;
use App\Services\Search\DTO\ReleaseSearchQuery;
use App\Services\Search\DTO\SearchPage;
use App\Services\Search\Support\ManticoreClientFactory;
use App\Services\Search\Support\ManticoreIndexRegistry;
use App\Services\Search\Support\ReleaseIndexProjection;
use App\Support\ReleaseSearchIndexDocument;
use App\Support\SecondaryIndexDocuments;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Manticoresearch\Client;
use Manticoresearch\Exceptions\ResponseException;
use Manticoresearch\Exceptions\RuntimeException;
use Manticoresearch\Search;

/**
 * ManticoreSearch driver for full-text search functionality.
 */
class ManticoreSearchDriver implements SearchDriverInterface
{
    private const AVAILABILITY_CHECK_CACHE_TTL = 30;

    /** @var list<string> */
    private const RELEASE_FIELD_SEARCH_FIELDS = [
        'searchname',
        'plainsearchname',
        'name',
        'fromname',
        'filename',
        'media_movie_name',
        'media_file_name',
        'media_container_format',
        'media_video_format',
        'media_video_codec',
        'media_audio_format',
        'media_audio_channels',
        'media_audio_language',
        'media_subtitle_language',
    ];

    /** @var list<string> */
    private const RELEASE_MEDIA_INFO_TEXT_FIELDS = [
        'media_movie_name',
        'media_file_name',
        'media_container_format',
        'media_video_format',
        'media_video_codec',
        'media_audio_format',
        'media_audio_channels',
        'media_audio_language',
        'media_subtitle_language',
    ];

    private static ?bool $availabilityCache = null;

    private static ?int $availabilityCacheTime = null;

    /**
     * @var array<string, mixed>
     */
    protected array $config;

    /**
     * @var array<string, mixed>
     */
    protected array $connection;

    public Client $manticoreSearch;

    public Search $search;

    /** @var array<string, true> Tables already confirmed to exist (avoids repeated CREATE TABLE attempts). */
    private array $verifiedTables = [];

    /**
     * Establishes a connection to ManticoreSearch HTTP port.
     *
     * @param  array<string, mixed>  $config
     */
    public function __construct(array $config = [])
    {
        $this->config = ! empty($config) ? $config : config('search.drivers.manticore');
        $this->connection = ManticoreClientFactory::clientConfig($this->config);
        $this->manticoreSearch = ManticoreClientFactory::make($this->config);
        $this->search = new Search($this->manticoreSearch);
    }

    /**
     * Get the driver name.
     */
    public function getDriverName(): string
    {
        return 'manticore';
    }

    /**
     * Check if ManticoreSearch is available.
     */
    public function isAvailable(): bool
    {
        $now = time();
        if (self::$availabilityCache !== null && self::$availabilityCacheTime !== null
            && ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) {
            return self::$availabilityCache;
        }

        try {
            $status = $this->manticoreSearch->nodes()->status();

            self::$availabilityCache = ! empty($status);
            self::$availabilityCacheTime = $now;

            return self::$availabilityCache;
        } catch (\Throwable $e) {
            self::$availabilityCache = false;
            self::$availabilityCacheTime = $now;
            if (config('app.debug')) {
                Log::debug('ManticoreSearch not available: '.$e->getMessage());
            }

            return false;
        }
    }

    public function searchReleasePage(ReleaseSearchQuery $query): SearchPage
    {
        $startedAt = hrtime(true);
        $result = $this->searchReleasesFiltered($query->criteria(), $query->limit, $query->offset);

        return new SearchPage(
            ids: array_map(static fn (int|string $id): int => (int) $id, $result['ids']),
            total: (int) $result['total'],
            fuzzy: (bool) $result['fuzzy'],
            driver: $this->getDriverName(),
            available: (bool) ($result['available'] ?? true),
            durationMs: (hrtime(true) - $startedAt) / 1_000_000,
            lastSortValues: $result['last_sort'] ?? [],
            hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])),
            documents: $result['documents'] ?? [],
        );
    }

    /**
     * Return the current document count for an RT table.
     */
    public function countIndexDocuments(string $index): int
    {
        if (preg_match('/^[a-zA-Z0-9_]+$/', $index) !== 1) {
            throw new \InvalidArgumentException('Unsafe Manticore index name.');
        }

        $response = $this->manticoreSearch->sql("SELECT COUNT(*) AS total FROM `{$index}`", true);
        if (! is_array($response)) {
            return 0;
        }

        foreach ($response as $key => $value) {
            if (is_array($value) && isset($value['total'])) {
                return (int) $value['total'];
            }
            if ($key === 'total' && is_numeric($value)) {
                return (int) $value;
            }
            if (is_numeric($value)) {
                return (int) $value;
            }
        }

        return 0;
    }

    /**
     * Return a stable page of release document ids for drift/orphan checks.
     *
     * @return list<int>
     */
    public function indexedReleaseIds(int $limit = 1000, int $offset = 0): array
    {
        $limit = max(1, min(10000, $limit));
        $offset = max(0, $offset);
        $index = $this->getReleasesIndex();

        if (preg_match('/^[a-zA-Z0-9_]+$/', $index) !== 1) {
            throw new \InvalidArgumentException('Unsafe Manticore index name.');
        }

        $sql = "SELECT id FROM {$index} ORDER BY id ASC LIMIT {$offset},{$limit} OPTION max_matches=".($offset + $limit);
        $response = $this->manticoreSearch->sql($sql, true);
        if (! is_array($response)) {
            return [];
        }

        $ids = [];
        foreach ($response as $key => $value) {
            if (ctype_digit((string) $key)) {
                $ids[(int) $key] = true;
            }
            if (is_array($value) && isset($value['id'])) {
                $ids[(int) $value['id']] = true;
            }
            if (
                ($key === 'id' || is_int($key) || ctype_digit((string) $key))
                && (is_int($value) || (is_string($value) && ctype_digit($value)))
            ) {
                $ids[(int) $value] = true;
            }
        }

        if (isset($response['data']) && is_array($response['data'])) {
            foreach ($response['data'] as $row) {
                if (is_array($row) && isset($row['id'])) {
                    $ids[(int) $row['id']] = true;
                }
            }
        }

        return array_values(array_filter(array_map('intval', array_keys($ids)), static fn (int $id): bool => $id > 0));
    }

    /**
     * Check if autocomplete is enabled.
     */
    public function isAutocompleteEnabled(): bool
    {
        return ($this->config['autocomplete']['enabled'] ?? true) === true;
    }

    /**
     * Check if suggest is enabled.
     */
    public function isSuggestEnabled(): bool
    {
        return ($this->config['suggest']['enabled'] ?? true) === true;
    }

    /**
     * Check if fuzzy search is enabled.
     */
    public function isFuzzyEnabled(): bool
    {
        return ($this->config['fuzzy']['enabled'] ?? true) === true;
    }

    /**
     * Get fuzzy search configuration.
     *
     * @return array<string, mixed>
     */
    public function getFuzzyConfig(): array
    {
        return $this->config['fuzzy'] ?? [
            'enabled' => true,
            'max_distance' => 2,
            'layouts' => 'us',
        ];
    }

    /**
     * Get the releases index name.
     */
    public function getReleasesIndex(): string
    {
        return $this->config['indexes']['releases'] ?? 'releases_rt';
    }

    /**
     * Get the predb index name.
     */
    public function getPredbIndex(): string
    {
        return $this->config['indexes']['predb'] ?? 'predb_rt';
    }

    /**
     * Get the movies index name.
     */
    public function getMoviesIndex(): string
    {
        return $this->config['indexes']['movies'] ?? 'movies_rt';
    }

    /**
     * Get the TV shows index name.
     */
    public function getTvShowsIndex(): string
    {
        return $this->config['indexes']['tvshows'] ?? 'tvshows_rt';
    }

    /**
     * Insert release into ManticoreSearch releases_rt realtime index
     *
     * @param  array<string, mixed>  $parameters
     */
    public function insertRelease(array $parameters): void
    {
        if (empty($parameters['id'])) {
            Log::warning('ManticoreSearch: Cannot insert release without ID');

            return;
        }

        $releaseId = (int) $parameters['id'];
        if (! $this->replaceReleaseDocumentWithRetry($parameters)) {
            $this->recordReleaseIndexFailure($releaseId, 'insertRelease', 'upsert');
            try {
                ReindexReleaseJob::dispatch($releaseId)->delay(now()->addSeconds(2));
            } catch (\Throwable $e) {
                Log::error('ManticoreSearch: failed to queue ReindexReleaseJob', [
                    'release_id' => $releaseId,
                    'error' => $e->getMessage(),
                ]);
            }
        }
    }

    /**
     * @param  array<string, mixed>  $parameters
     */
    private function replaceReleaseDocumentWithRetry(array $parameters): bool
    {
        // updateRelease() supplies the already-normalized canonical projection.
        // Preserve its *_ts fields instead of normalizing a second time and
        // replacing post/add timestamps with zero.
        $document = ReleaseSearchIndexDocument::normalizeForBulk($parameters);
        unset($document['id']);

        $indexName = $this->config['indexes']['releases'];
        $releaseId = (int) $parameters['id'];

        $attempts = max(1, (int) ($this->config['retry_attempts'] ?? config('search.drivers.manticore.retry_attempts', 3)));
        $delayMs = max(0, (int) ($this->config['retry_delay_ms'] ?? config('search.drivers.manticore.retry_delay_ms', 100)));

        for ($attempt = 0; $attempt < $attempts; $attempt++) {
            try {
                $this->manticoreSearch->table($indexName)
                    ->replaceDocument($document, $releaseId);

                $this->resolveReleaseIndexFailure($releaseId);

                return true;
            } catch (\Throwable $e) {
                $isResponse = $e instanceof ResponseException;
                $isManticoreRuntime = $e instanceof RuntimeException;

                if (($isResponse || $isManticoreRuntime) && $attempt < $attempts - 1) {
                    if ($delayMs > 0) {
                        usleep($delayMs * 1000 * ($attempt + 1));
                    }

                    continue;
                }

                if ($isResponse) {
                    Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [
                        'release_id' => $releaseId,
                        'index' => $indexName,
                    ]);

                    return false;
                }

                if ($isManticoreRuntime) {
                    Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [
                        'release_id' => $releaseId,
                    ]);

                    return false;
                }

                Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [
                    'release_id' => $releaseId,
                    'trace' => $e->getTraceAsString(),
                ]);

                return false;
            }
        }

        return false;
    }

    private function recordReleaseIndexFailure(int $releaseId, string $phase, string $operation = 'upsert'): void
    {
        Cache::increment('search:index:failures:releases');
        try {
            $existing = DB::table('search_index_failures')
                ->where('release_id', $releaseId)
                ->first(['attempts']);
            $attempts = ((int) ($existing->attempts ?? 0)) + 1;
            DB::table('search_index_failures')->updateOrInsert(
                ['release_id' => $releaseId],
                [
                    'operation' => $operation,
                    'attempts' => $attempts,
                    'last_error' => $phase,
                    'next_attempt_at' => now()->addSeconds(min(3600, 2 ** min($attempts, 10))),
                    'resolved_at' => null,
                    'updated_at' => now(),
                    'created_at' => now(),
                ]
            );
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch: unable to persist release index failure', [
                'release_id' => $releaseId,
                'phase' => $phase,
                'error' => $e->getMessage(),
            ]);
        }
        if (Cache::add('search:index:release_index_warn_lock', true, 60)) {
            Log::warning('ManticoreSearch: release indexing failure', [
                'release_id_sample' => $releaseId,
                'phase' => $phase,
                'failures_total' => (int) Cache::get('search:index:failures:releases', 0),
            ]);
        }
    }

    private function resolveReleaseIndexFailure(int $releaseId): void
    {
        if ($releaseId <= 0) {
            return;
        }

        try {
            DB::table('search_index_failures')
                ->where('release_id', $releaseId)
                ->update([
                    'resolved_at' => now(),
                    'next_attempt_at' => null,
                    'updated_at' => now(),
                ]);
        } catch (\Throwable $e) {
            Log::debug('ManticoreSearch: unable to mark release index failure resolved', [
                'release_id' => $releaseId,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function recordReleaseNotFoundForIndex(int|string $releaseID): void
    {
        Cache::increment('search:index:failures:release_not_found');
        if (Cache::add('search:index:release_not_found_warn_lock', true, 60)) {
            Log::warning('ManticoreSearch: release not found when updating index', [
                'release_id' => $releaseID,
                'not_found_total' => (int) Cache::get('search:index:failures:release_not_found', 0),
            ]);
        }
    }

    /**
     * Insert release into Manticore RT table.
     *
     * @param  array<string, mixed>  $parameters
     */
    public function insertPredb(array $parameters): void
    {
        if (empty($parameters['id'])) {
            Log::warning('ManticoreSearch: Cannot insert predb without ID');

            return;
        }

        try {
            $document = [
                'title' => $parameters['title'] ?? '',
                'filename' => $parameters['filename'] ?? '',
                'source' => $parameters['source'] ?? '',
            ];

            $this->manticoreSearch->table($this->config['indexes']['predb'])
                ->replaceDocument($document, $parameters['id']);

        } catch (ResponseException $e) {
            Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [
                'predb_id' => $parameters['id'],
            ]);
        } catch (RuntimeException $e) {
            Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [
                'predb_id' => $parameters['id'],
            ]);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [
                'predb_id' => $parameters['id'],
            ]);
        }
    }

    /**
     * Delete release from Manticore RT tables.
     *
     * @param  int  $id  Release ID
     */
    public function deleteRelease(int $id): void
    {
        $this->deleteReleases([$id]);
    }

    public function deleteReleases(iterable $ids): void
    {
        $ids = array_values(array_unique(array_filter(
            array_map('intval', is_array($ids) ? $ids : iterator_to_array($ids)),
            static fn (int $id): bool => $id > 0
        )));

        if ($ids === []) {
            Log::warning('ManticoreSearch: Cannot delete release without ID');

            return;
        }

        $attempts = max(1, (int) ($this->config['retry_attempts'] ?? config('search.drivers.manticore.retry_attempts', 2)));
        $delayMs = max(0, (int) ($this->config['retry_delay_ms'] ?? config('search.drivers.manticore.retry_delay_ms', 100)));

        for ($attempt = 0; $attempt < $attempts; $attempt++) {
            try {
                $this->manticoreSearch->table($this->getReleasesIndex())
                    ->deleteDocumentsByIds($ids);
                foreach ($ids as $id) {
                    $this->resolveReleaseIndexFailure((int) $id);
                }

                return;
            } catch (\Throwable $e) {
                if ($attempt < $attempts - 1) {
                    if ($delayMs > 0) {
                        usleep($delayMs * 1000 * ($attempt + 1));
                    }

                    continue;
                }

                Log::error('ManticoreSearch deleteReleases error: '.$e->getMessage(), [
                    'ids' => $ids,
                ]);
                foreach ($ids as $id) {
                    $this->recordReleaseIndexFailure((int) $id, 'deleteReleases: '.$e->getMessage(), 'delete');
                }
            }
        }
    }

    /**
     * Delete a predb record from the index.
     *
     * @param  int  $id  Predb ID
     */
    public function deletePreDb(int $id): void
    {
        if (empty($id)) {
            Log::warning('ManticoreSearch: Cannot delete predb without ID');

            return;
        }

        try {
            $this->manticoreSearch->table($this->config['indexes']['predb'])
                ->deleteDocument($id);
        } catch (ResponseException $e) {
            Log::error('ManticoreSearch deletePreDb error: '.$e->getMessage(), [
                'id' => $id,
            ]);
        }
    }

    /**
     * Bulk insert multiple releases into the index.
     *
     * @param  array<string, mixed>  $releases  Array of release data arrays
     * @return array<string, mixed> Results with 'success' and 'errors' counts
     */
    public function bulkInsertReleases(array $releases): array
    {
        if (empty($releases)) {
            return ['success' => 0, 'errors' => 0];
        }

        $documents = [];
        foreach ($releases as $release) {
            if (empty($release['id'])) {
                continue;
            }

            $documents[] = array_merge(
                ['id' => $release['id']],
                ReleaseSearchIndexDocument::normalizeForBulk($release)
            );
        }

        if (empty($documents)) {
            return ['success' => 0, 'errors' => 0];
        }

        try {
            $this->manticoreSearch->table($this->config['indexes']['releases'])
                ->replaceDocuments($documents);

            return ['success' => count($documents), 'errors' => 0];
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch bulkInsertReleases error: '.$e->getMessage());

            return ['success' => 0, 'errors' => count($documents)];
        }
    }

    /**
     * Bulk insert multiple predb records into the index.
     *
     * @param  array<string, mixed>  $predbRecords  Array of predb data arrays
     * @return array<string, mixed> Results with 'success' and 'errors' counts
     */
    public function bulkInsertPredb(array $predbRecords): array
    {
        if (empty($predbRecords)) {
            return ['success' => 0, 'errors' => 0];
        }

        $documents = [];
        foreach ($predbRecords as $predb) {
            if (empty($predb['id'])) {
                continue;
            }

            $documents[] = [
                'id' => $predb['id'],
                'title' => (string) ($predb['title'] ?? ''),
                'filename' => (string) ($predb['filename'] ?? ''),
                'source' => (string) ($predb['source'] ?? ''),
            ];
        }

        if (empty($documents)) {
            return ['success' => 0, 'errors' => 0];
        }

        try {
            $this->manticoreSearch->table($this->config['indexes']['predb'])
                ->replaceDocuments($documents);

            return ['success' => count($documents), 'errors' => 0];
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch bulkInsertPredb error: '.$e->getMessage());

            return ['success' => 0, 'errors' => count($documents)];
        }
    }

    /**
     * Delete release from Manticore RT tables by GUID.
     *
     * @param  array<string, mixed>  $identifiers  ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)]
     */
    public function deleteReleaseByGuid(array $identifiers): void
    {
        if (empty($identifiers['g'])) {
            Log::warning('ManticoreSearch: Cannot delete release without GUID');

            return;
        }

        try {
            if ($identifiers['i'] === false || empty($identifiers['i'])) {
                $release = Release::query()->where('guid', $identifiers['g'])->first(['id']);
                $identifiers['i'] = $release?->id;
            }

            if (! empty($identifiers['i'])) {
                $this->manticoreSearch->table($this->config['indexes']['releases'])
                    ->deleteDocument($identifiers['i']);
            } else {
                Log::warning('ManticoreSearch: Could not find release ID for deletion', [
                    'guid' => $identifiers['g'],
                ]);
            }
        } catch (ResponseException $e) {
            Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
                'guid' => $identifiers['g'],
                'id' => $identifiers['i'] ?? null,
            ]);
        }
    }

    /**
     * Escapes characters that are treated as special operators by the query language parser.
     *
     * This method escapes ALL special characters, including search operators.
     * Use prepareUserSearchQuery() instead for user-facing search queries
     * where operators like negation (!) should be preserved.
     */
    public static function escapeString(string $string): string
    {
        if ($string === '*' || empty($string)) {
            return '';
        }

        $from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-'];
        $to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-'];

        $string = str_replace($from, $to, $string);

        // Clean up trailing special characters
        $string = rtrim($string, '-!');

        return trim($string);
    }

    /**
     * Prepares a user search query for ManticoreSearch, preserving search operators.
     *
     * Unlike escapeString() which escapes ALL special characters, this method
     * recognizes and preserves user-facing search operators:
     *
     * - Negation: !word or -word (excludes results containing "word")
     * - Phrase search: "exact phrase"
     * - Negated phrase: !"exact phrase" or -"exact phrase"
     * - OR operator: word1 | word2
     * - Wildcards: word*, *word, *word*
     * - Grouping: (word1 | word2) -word3
     *
     * Characters that are not useful as user operators are still escaped:
     * \, @, ~, &, /, $, =, ', [, ]
     */
    public static function prepareUserSearchQuery(string $query): string
    {
        $query = trim($query);
        if ($query === '' || $query === '*') {
            return '';
        }

        // Tokenize while preserving quoted phrases intact
        // Matches: optional negation prefix (! or -) + "quoted strings", OR non-whitespace sequences
        preg_match_all('/[-!]?"[^"]*"|\S+/', $query, $matches);
        $tokens = $matches[0];

        if (empty($tokens)) {
            return '';
        }

        // Characters that should always be escaped (not meaningful as user-facing search operators)
        // Includes " for unmatched quotes that appear in non-quoted tokens
        $escapeFrom = ['\\', '@', '~', '&', '/', '$', '=', "'", '[', ']', '"'];
        $escapeTo = ['\\\\', '\@', '\~', '\&', '\/', '\$', '\=', "\'", '\[', '\]', '\"'];

        $processed = [];
        foreach ($tokens as $token) {
            // Preserve OR operator
            if ($token === '|') {
                $processed[] = '|';

                continue;
            }

            // Extract leading/trailing parentheses for grouping: (word) or ((word))
            $leadingParens = '';
            $trailingParens = '';
            while (str_starts_with($token, '(')) {
                $leadingParens .= '(';
                $token = substr($token, 1);
            }
            while (str_ends_with($token, ')') && ! str_starts_with($token, '"')) {
                $trailingParens = ')'.$trailingParens;
                $token = substr($token, 0, -1);
            }

            if ($token === '') {
                // Only parens, no word content
                if ($leadingParens !== '' || $trailingParens !== '') {
                    $processed[] = $leadingParens.$trailingParens;
                }

                continue;
            }

            // Detect negation prefix (! or -) at the start of a word
            $negation = '';
            if (strlen($token) > 1 && ($token[0] === '!' || $token[0] === '-')) {
                $negation = $token[0];
                $token = substr($token, 1);
            }

            // Handle quoted phrases: "exact phrase" (possibly with negation prefix)
            if (str_starts_with($token, '"') && str_ends_with($token, '"') && strlen($token) > 1) {
                $inner = substr($token, 1, -1);
                $inner = str_replace($escapeFrom, $escapeTo, $inner);
                // Escape ! and - inside phrases (they're literal text, not operators)
                $inner = str_replace(['!', '-'], ['\!', '\-'], $inner);
                $processed[] = $leadingParens.$negation.'"'.$inner.'"'.$trailingParens;

                continue;
            }

            // Detect wildcard prefix/suffix on non-quoted tokens
            $wildcardPrefix = '';
            $wildcardSuffix = '';
            if (str_starts_with($token, '*')) {
                $wildcardPrefix = '*';
                $token = ltrim($token, '*');
            }
            if (str_ends_with($token, '*')) {
                $wildcardSuffix = '*';
                $token = rtrim($token, '*');
            }

            // Escape non-operator special characters within the word
            $token = str_replace($escapeFrom, $escapeTo, $token);

            // Escape ! and - that appear INSIDE a word (not at the start as operators)
            // e.g., "spider-man" → "spider\-man", but "-circus" keeps the leading -
            $token = str_replace(['!', '-'], ['\!', '\-'], $token);

            if ($token !== '' || $wildcardPrefix !== '' || $wildcardSuffix !== '') {
                $processed[] = $leadingParens.$negation.$wildcardPrefix.$token.$wildcardSuffix.$trailingParens;
            }
        }

        $result = implode(' ', $processed);

        return trim($result);
    }

    private static function scopePreparedQueryToField(string $preparedQuery, string $fieldSelector = ''): string
    {
        $preparedQuery = trim($preparedQuery);
        if ($preparedQuery === '') {
            return '';
        }

        $scopedQuery = '('.$preparedQuery.')';

        if ($fieldSelector === '') {
            return $scopedQuery;
        }

        return $fieldSelector.' '.$scopedQuery;
    }

    /**
     * Build a release-name query that also handles punctuation used as token
     * separators in Usenet subjects (for example, WEB-DL.x265).
     *
     * The prepared query deliberately escapes mid-word hyphens so user search
     * operators remain safe. Manticore tokenizes those separators instead of
     * indexing them as part of a word, though, so an ordinary release-name
     * query needs a normalized-token alternative as well.
     */
    private static function scopeReleaseSearchQuery(string $rawQuery, string $preparedQuery, string $fieldSelector = ''): string
    {
        $primary = self::scopePreparedQueryToField($preparedQuery, $fieldSelector);

        if ($fieldSelector !== '@searchname' || self::queryHasNegation($rawQuery)) {
            return $primary;
        }

        // Preserve advanced query syntax and only add the fallback for plain
        // release-name input whose punctuation is a token separator.
        if (preg_match('/[^\p{L}\p{N}\s._-]/u', $rawQuery) === 1 || ! preg_match('/[._-]/', $rawQuery)) {
            return $primary;
        }

        $normalized = preg_replace('/[._-]+/u', ' ', trim($rawQuery));
        $normalized = is_string($normalized) ? trim(preg_replace('/\s+/', ' ', $normalized) ?? '') : '';
        $normalizedPrepared = self::prepareUserSearchQuery($normalized);

        if ($normalizedPrepared === '' || $normalizedPrepared === $preparedQuery) {
            return $primary;
        }

        $normalizedQuery = self::scopePreparedQueryToField($normalizedPrepared, $fieldSelector);

        return '('.$primary.' | '.$normalizedQuery.')';
    }

    /**
     * Check if a search query contains negation operators (! or - prefix on words).
     *
     * Used to prevent fuzzy fallback from reversing the user's negation intent.
     * For example, if the user searches "!harry", fuzzy should not strip the !
     * and return "harry" results.
     *
     * @param  array<string, mixed>  $phrases
     */
    public static function queryHasNegation(array|string $phrases): bool
    {
        $values = [];
        if (is_string($phrases)) {
            $values[] = $phrases;
        } elseif (is_array($phrases)) {
            foreach ($phrases as $value) {
                if (is_string($value) && $value !== '' && $value !== '-1') {
                    $values[] = $value;
                }
            }
        }

        foreach ($values as $value) {
            // Check if any token starts with ! or - (negation operator)
            if (preg_match('/(?:^|\s)[!-]\S/', $value)) {
                return true;
            }
        }

        return false;
    }

    public function updateRelease(int|string $releaseID): void
    {
        if (empty($releaseID)) {
            Log::warning('ManticoreSearch: Cannot update release without ID');

            return;
        }

        try {
            $release = ReleaseIndexProjection::forId((int) $releaseID);

            if ($release !== null) {
                $this->insertRelease($release);
            } else {
                Log::warning('ManticoreSearch: Release not found for update, removing from index', ['id' => $releaseID]);
                $this->recordReleaseNotFoundForIndex($releaseID);
                $this->deleteRelease((int) $releaseID);
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [
                'release_id' => $releaseID,
            ]);
            $this->recordReleaseIndexFailure((int) $releaseID, 'updateRelease_query');
        }
    }

    /**
     * Update Manticore Predb index for given predb_id.
     *
     * @param  array<string, mixed>  $parameters
     */
    public function updatePreDb(array $parameters): void
    {
        if (empty($parameters)) {
            Log::warning('ManticoreSearch: Cannot update predb with empty parameters');

            return;
        }

        $this->insertPredb($parameters);
    }

    /**
     * @param  array<string, mixed>  $indexes
     */
    public function truncateRTIndex(array $indexes = []): bool
    {
        if (empty($indexes)) {
            cli()->error('You need to provide index name to truncate');

            return false;
        }

        $success = true;
        foreach ($indexes as $index) {
            if (! \in_array($index, $this->config['indexes'], true)) {
                cli()->error('Unsupported index: '.$index);
                $success = false;

                continue;
            }

            try {
                $this->manticoreSearch->table($index)->truncate();
                cli()->info('Truncating index '.$index.' finished.');
            } catch (ResponseException $e) {
                // Handle case where index doesn't exist - create it
                $message = $e->getMessage();
                if (str_contains($message, 'does not exist') || $message === 'Invalid index') {
                    cli()->info('Index '.$index.' does not exist, creating it...');
                    $this->createIndexIfNotExists($index);
                } else {
                    cli()->error('Error truncating index '.$index.': '.$message);
                    $success = false;
                }
            } catch (\Throwable $e) {
                // Also handle generic exceptions for non-existent tables
                $message = $e->getMessage();
                if (str_contains($message, 'does not exist')) {
                    cli()->info('Index '.$index.' does not exist, creating it...');
                    $this->createIndexIfNotExists($index);
                } else {
                    cli()->error('Unexpected error truncating index '.$index.': '.$message);
                    $success = false;
                }
            }
        }

        return $success;
    }

    /**
     * Truncate/clear an index (remove all documents).
     * Implements SearchServiceInterface::truncateIndex
     *
     * @param  array<string, mixed>|string  $indexes  Index name(s) to truncate
     */
    public function truncateIndex(array|string $indexes): void
    {
        $indexArray = is_array($indexes) ? $indexes : [$indexes];
        $this->truncateRTIndex($indexArray);
    }

    /**
     * Create index if it doesn't exist.
     *
     * Uses an in-memory set so the expensive CREATE TABLE round-trip is attempted
     * at most once per table per process lifetime.
     */
    private function createIndexIfNotExists(string $index): void
    {
        if (isset($this->verifiedTables[$index])) {
            return;
        }

        try {
            $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
            $definition = $logical === null ? null : (ManticoreIndexRegistry::definitions()[$logical] ?? null);

            if ($definition === null) {
                cli()->error("No Manticore schema is registered for {$index}");

                return;
            }

            $this->manticoreSearch->tables()->create(['index' => $index, 'body' => $definition]);
            cli()->info("Created {$index} with the configured Manticore schema");
        } catch (\Throwable $e) {
            if (! str_contains($e->getMessage(), 'already exists')) {
                cli()->error('Error creating index '.$index.': '.$e->getMessage());
            }
        }

        $this->verifiedTables[$index] = true;
    }

    /**
     * Optimize the RT indices.
     */
    public function optimizeRTIndex(): bool
    {
        $success = true;

        foreach ($this->config['indexes'] as $index) {
            try {
                $this->manticoreSearch->table($index)->flush();
                $this->manticoreSearch->table($index)->optimize();
                Log::info("Successfully optimized index: {$index}");
            } catch (ResponseException $e) {
                Log::error('Failed to optimize index '.$index.': '.$e->getMessage());
                $success = false;
            } catch (\Throwable $e) {
                Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage());
                $success = false;
            }
        }

        return $success;
    }

    /**
     * Optimize index for better search performance.
     * Implements SearchServiceInterface::optimizeIndex
     */
    public function optimizeIndex(): void
    {
        $this->optimizeRTIndex();
    }

    /**
     * Search releases index.
     *
     * @param  array<string, mixed>|string  $phrases  Search phrases - can be a string, indexed array of terms, or associative array with field names
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array of release IDs
     */
    public function searchReleases(array|string $phrases, int $limit = 1000): array
    {
        if (is_string($phrases)) {
            // Simple string search - search in searchname field
            $searchArray = ['searchname' => $phrases];
        } else {
            // Check if it's an associative array (has string keys like 'searchname')
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;

            if ($isAssociative) {
                // Already has field names as keys
                $searchArray = $phrases;
            } else {
                // Indexed array - combine values and search in searchname
                $searchArray = ['searchname' => implode(' ', $phrases)];
            }
        }

        $result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray, $limit);

        return ! empty($result) ? ($result['id'] ?? []) : [];
    }

    /**
     * Search releases with fuzzy fallback.
     *
     * If exact search returns no results and fuzzy is enabled, this method
     * will automatically try a fuzzy search as a fallback.
     *
     * @param  array<string, mixed>|string  $phrases  Search phrases
     * @param  int  $limit  Maximum number of results
     * @param  bool  $forceFuzzy  Force fuzzy search regardless of exact results
     * @return array<string, mixed> Array with 'ids' (release IDs) and 'fuzzy' (bool indicating if fuzzy was used)
     */
    public function searchReleasesWithFuzzy(array|string $phrases, int $limit = 1000, bool $forceFuzzy = false): array
    {
        // First try exact search unless forcing fuzzy
        if (! $forceFuzzy) {
            $exactResults = $this->searchReleases($phrases, $limit);
            if (! empty($exactResults)) {
                return [
                    'ids' => $exactResults,
                    'fuzzy' => false,
                ];
            }
        }

        // Skip fuzzy fallback when the query contains negation operators (! or -).
        // Fuzzy search strips all special characters, which would reverse the user's
        // intent: "!harry" would become "harry" and return exactly what they wanted to exclude.
        if (self::queryHasNegation($phrases)) {
            if (config('app.debug')) {
                Log::debug('ManticoreSearch::searchReleasesWithFuzzy skipping fuzzy - query contains negation operators', [
                    'phrases' => $phrases,
                ]);
            }

            return [
                'ids' => [],
                'fuzzy' => false,
            ];
        }

        // If exact search returned nothing (or forcing fuzzy) and fuzzy is enabled, try fuzzy search
        if ($this->isFuzzyEnabled()) {
            $fuzzyResults = $this->fuzzySearchReleases($phrases, $limit);
            if (! empty($fuzzyResults)) {
                return [
                    'ids' => $fuzzyResults,
                    'fuzzy' => true,
                ];
            }
        }

        return [
            'ids' => [],
            'fuzzy' => false,
        ];
    }

    /**
     * Perform fuzzy search on releases index.
     *
     * Uses Manticore's native fuzzy search with Levenshtein distance algorithm.
     *
     * @param  array<string, mixed>|string  $phrases  Search phrases
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array of release IDs
     */
    public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array
    {
        if (! $this->isFuzzyEnabled()) {
            return [];
        }

        if (is_string($phrases)) {
            $searchArray = ['searchname' => $phrases];
        } else {
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
            if ($isAssociative) {
                $searchArray = $phrases;
            } else {
                $searchArray = ['searchname' => implode(' ', $phrases)];
            }
        }

        $result = $this->fuzzySearchIndexes($this->getReleasesIndex(), $searchArray, $limit);

        return ! empty($result) ? ($result['id'] ?? []) : [];
    }

    /**
     * Perform fuzzy search on an index using Manticore's native fuzzy search.
     *
     * Uses Levenshtein distance algorithm to find matches with typo tolerance.
     * Supports:
     * - Missing characters: "laptp" → "laptop"
     * - Extra characters: "laptopp" → "laptop"
     * - Transposed characters: "lpatop" → "laptop"
     * - Wrong characters: "laptip" → "laptop"
     *
     * @param  string  $index  Index to search
     * @param  array<string, mixed>  $searchArray  Associative array of field => value to search
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array with 'id' and 'data' keys
     */
    public function fuzzySearchIndexes(string $index, array $searchArray, int $limit = 1000): array
    {
        if (empty($index) || empty($searchArray)) {
            return [];
        }

        $normalizedLimit = $this->normalizeSearchLimit($limit);
        $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
        $profile = ManticoreIndexRegistry::profile($logical ?? '');
        $fuzzyConfig = $this->getFuzzyConfig();
        $distance = min((int) ($fuzzyConfig['max_distance'] ?? 2), $profile['fuzzy_distance']);

        // Create cache key for fuzzy search results
        $cacheKey = 'manticore:fuzzy:'.md5(serialize([
            'generation' => config('search.index_generation', '1'),
            'index' => $index,
            'array' => $searchArray,
            'limit' => $normalizedLimit,
            'distance' => $distance,
        ]));

        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            if (config('app.debug')) {
                Log::debug('ManticoreSearch::fuzzySearchIndexes returning cached result', [
                    'cacheKey' => $cacheKey,
                ]);
            }

            return $cached;
        }

        // For fuzzy search, we need to use a simple query string without the @@relaxed @field syntax
        // The fuzzy option only works with plain query_string queries
        $searchTerms = [];
        foreach ($searchArray as $field => $value) {
            if (! empty($value)) {
                // Clean value for fuzzy search - remove special characters but keep words
                $cleanValue = preg_replace('/[^\w\s]/', ' ', $value);
                $cleanValue = preg_replace('/\s+/', ' ', trim($cleanValue));
                if (! empty($cleanValue)) {
                    $searchTerms[] = $cleanValue;
                }
            }
        }

        if (empty($searchTerms)) {
            return [];
        }

        $searchExpr = implode(' ', $searchTerms);

        if (config('app.debug')) {
            Log::debug('ManticoreSearch::fuzzySearchIndexes query', [
                'index' => $index,
                'searchExpr' => $searchExpr,
                'fuzzy' => true,
                'distance' => $distance,
            ]);
        }

        try {
            // Use Manticore's native fuzzy search with Levenshtein distance
            // Important: use search() with plain query string for fuzzy to work
            // Note: Keep options minimal - layouts, stripBadUtf8, and sort can interfere with fuzzy
            $query = (new Search($this->manticoreSearch))
                ->setTable($index)
                ->search($searchExpr)
                ->option('fuzzy', true)
                ->option('distance', $distance)
                ->option('field_weights', $profile['fields'])
                ->limit($normalizedLimit);

            $results = $query->get();
        } catch (ResponseException $e) {
            $message = $e->getMessage();

            // Check if fuzzy search failed due to missing min_infix_len
            // This happens when index was created without proper settings
            if (str_contains($message, 'min_infix_len')) {
                Log::warning('ManticoreSearch fuzzySearchIndexes: Fuzzy search unavailable - index missing min_infix_len setting. Please recreate the index with: php artisan manticore:create-indexes --drop', [
                    'index' => $index,
                ]);

                // Fall back to regular search without fuzzy
                return $this->searchIndexes($index, '', [], $searchArray, $normalizedLimit);
            }

            Log::error('ManticoreSearch fuzzySearchIndexes ResponseException: '.$message, [
                'index' => $index,
                'searchArray' => $searchArray,
            ]);

            return [];
        } catch (RuntimeException $e) {
            Log::error('ManticoreSearch fuzzySearchIndexes RuntimeException: '.$e->getMessage(), [
                'index' => $index,
            ]);

            return [];
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch fuzzySearchIndexes unexpected error: '.$e->getMessage(), [
                'index' => $index,
            ]);

            return [];
        }

        $resultIds = [];
        $resultData = [];
        foreach ($results as $doc) {
            $resultIds[] = $doc->getId();
            $resultData[] = $doc->getData();
        }

        $result = [
            'id' => $resultIds,
            'data' => $resultData,
        ];

        if (config('app.debug')) {
            Log::debug('ManticoreSearch::fuzzySearchIndexes results', [
                'index' => $index,
                'total_results' => count($resultIds),
            ]);
        }

        // Cache fuzzy results for 5 minutes
        if (! empty($resultIds)) {
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5));
        }

        return $result;
    }

    /**
     * Search predb index.
     *
     * @param  array<string, mixed>|string  $searchTerm  Search term(s)
     * @return array<string, mixed> Array of predb records
     */
    public function searchPredb(array|string $searchTerm): array
    {
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;

        $result = $this->searchIndexes($this->getPredbIndex(), $searchString, ['title', 'filename'], []); // @phpstan-ignore argument.type

        return $result['data'] ?? [];
    }

    /**
     * @param  array<string, mixed>  $column
     * @param  array<string, mixed>  $searchArray
     * @return array<string, mixed>
     */
    public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = [], int $limit = 1000): array
    {
        if (empty($rt_index)) {
            Log::warning('ManticoreSearch: Index name is required for search');

            return [];
        }

        $normalizedLimit = $this->normalizeSearchLimit($limit);
        $logical = ManticoreIndexRegistry::logicalName($rt_index, $this->config['indexes'] ?? []);
        $profile = ManticoreIndexRegistry::profile($logical ?? '');

        if (config('app.debug')) {
            Log::debug('ManticoreSearch::searchIndexes called', [
                'rt_index' => $rt_index,
                'searchString' => $searchString,
                'column' => $column,
                'searchArray' => $searchArray,
                'limit' => $normalizedLimit,
            ]);
        }

        // Create cache key for search results
        $cacheKey = md5(serialize([
            'generation' => config('search.index_generation', '1'),
            'index' => $rt_index,
            'search' => $searchString,
            'columns' => $column,
            'array' => $searchArray,
            'limit' => $normalizedLimit,
            'profile' => $profile,
        ]));

        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            if (config('app.debug')) {
                Log::debug('ManticoreSearch::searchIndexes returning cached result', [
                    'cacheKey' => $cacheKey,
                    'cached_ids_count' => count($cached['id'] ?? []),
                ]);
            }

            return $cached;
        }

        // Build query string once so we can retry if needed
        // Use prepareUserSearchQuery() to preserve search operators (!, -, "", |, *)
        $searchExpr = null;
        if (! empty($searchArray)) {
            $terms = [];
            foreach ($searchArray as $key => $value) {
                if (! empty($value)) {
                    $rawValue = (string) $value;
                    $preparedValue = self::prepareUserSearchQuery($rawValue);
                    if (! empty($preparedValue)) {
                        $terms[] = '@@relaxed '.self::scopeReleaseSearchQuery($rawValue, $preparedValue, '@'.$key);
                    }
                }
            }
            if (! empty($terms)) {
                $searchExpr = implode(' ', $terms);
            } else {
                if (config('app.debug')) {
                    Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray');
                }

                return [];
            }
        } elseif (! empty($searchString)) {
            $preparedSearch = self::prepareUserSearchQuery($searchString);
            if (empty($preparedSearch)) {
                if (config('app.debug')) {
                    Log::debug('ManticoreSearch::searchIndexes preparedSearch is empty');
                }

                return [];
            }

            $searchColumns = '';
            if (! empty($column)) {
                if (count($column) > 1) {
                    $searchColumns = '@('.implode(',', $column).')';
                } else {
                    $searchColumns = '@'.$column[0]; // @phpstan-ignore offsetAccess.notFound
                }
            }

            $searchExpr = '@@relaxed '.self::scopeReleaseSearchQuery($searchString, $preparedSearch, $searchColumns);
        } else {
            return [];
        }

        // Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error
        $avoidSortForIndex = ($rt_index === 'predb_rt');

        if (config('app.debug')) {
            Log::debug('ManticoreSearch::searchIndexes executing query', [
                'rt_index' => $rt_index,
                'searchExpr' => $searchExpr,
            ]);
        }

        try {
            // Use a fresh Search instance for every query to avoid parameter accumulation across calls
            $query = (new Search($this->manticoreSearch))
                ->setTable($rt_index)
                ->option('ranker', $profile['ranker'])
                ->option('field_weights', $profile['fields'])
                ->maxMatches($normalizedLimit)
                ->limit($normalizedLimit)
                ->stripBadUtf8(true)
                ->search($searchExpr);

            if (! $avoidSortForIndex) {
                $query->sort('id', 'desc');
            }

            $results = $query->get();
        } catch (ResponseException $e) {
            // If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting
            if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) {
                try {
                    $query = (new Search($this->manticoreSearch))
                        ->setTable($rt_index)
                        ->option('ranker', $profile['ranker'])
                        ->option('field_weights', $profile['fields'])
                        ->maxMatches($normalizedLimit)
                        ->limit($normalizedLimit)
                        ->stripBadUtf8(true)
                        ->search($searchExpr);

                    $results = $query->get();

                    Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [
                        'index' => $rt_index,
                    ]);
                } catch (ResponseException $e2) {
                    Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [
                        'index' => $rt_index,
                        'search' => $searchString,
                    ]);

                    return [];
                }
            } else {
                Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [
                    'index' => $rt_index,
                    'search' => $searchString,
                ]);

                return [];
            }
        } catch (RuntimeException $e) {
            Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [
                'index' => $rt_index,
                'search' => $searchString,
            ]);

            return [];
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [
                'index' => $rt_index,
                'search' => $searchString,
            ]);

            return [];
        }

        // Parse results and cache
        $resultIds = [];
        $resultData = [];
        foreach ($results as $doc) {
            $resultIds[] = $doc->getId();
            $resultData[] = $doc->getData();
        }

        $result = [
            'id' => $resultIds,
            'data' => $resultData,
        ];

        // Only cache non-empty results to avoid caching temporary failures or empty index states
        if (! empty($resultIds)) {
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5));
        }

        return $result;
    }

    private function normalizeSearchLimit(int $limit): int
    {
        $maxMatches = max(1, (int) ($this->config['max_matches'] ?? 10000));

        return max(1, min($limit, $maxMatches));
    }

    /**
     * Get autocomplete suggestions for a search query.
     * Searches the releases index and returns matching searchnames.
     *
     * @param  string  $query  The partial search query
     * @param  string|null  $index  Index to search (defaults to releases index)
     * @return array<array{suggest: string, distance: int, docs: int}>
     */
    public function autocomplete(string $query, ?string $index = null): array
    {
        $autocompleteConfig = $this->config['autocomplete'] ?? [
            'enabled' => true,
            'min_length' => 2,
            'max_results' => 10,
            'cache_minutes' => 10,
        ];

        if (! ($autocompleteConfig['enabled'] ?? true)) {
            return [];
        }

        $query = trim($query);
        $minLength = $autocompleteConfig['min_length'] ?? 2;
        if (strlen($query) < $minLength) {
            return [];
        }

        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
        $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
        $fields = ManticoreIndexRegistry::autocompleteFields($logical ?? '');
        if ($fields === []) {
            return [];
        }
        $cacheKey = 'manticore:autocomplete:'.config('search.index_generation', '1').':'.md5($index.$query);

        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $suggestions = [];
        $maxResults = $autocompleteConfig['max_results'] ?? 10;

        try {
            // Search releases index for matching searchnames
            $escapedQuery = self::escapeString($query);
            if (empty($escapedQuery)) {
                return [];
            }

            $searchExpr = '@@relaxed @('.implode(',', $fields).') '.$escapedQuery.'*';

            $search = (new Search($this->manticoreSearch))
                ->setTable($index)
                ->search($searchExpr)
                ->sort('id', 'desc')
                ->limit($maxResults * 3)
                ->stripBadUtf8(true);

            $results = $search->get();

            $ranked = [];
            foreach ($results as $doc) {
                $data = $doc->getData();
                $searchname = '';
                foreach ($fields as $field) {
                    if (! empty($data[$field])) {
                        $searchname = (string) $data[$field];
                        break;
                    }
                }

                if (empty($searchname)) {
                    continue;
                }

                // Create a clean suggestion from the searchname
                $suggestion = $this->extractSuggestion($searchname, $query);
                if ($suggestion === null || $suggestion === '') {
                    continue;
                }

                $normalized = mb_strtolower(trim((string) preg_replace('/\s+/', ' ', $suggestion)));
                if ($normalized !== '') {
                    if (! isset($ranked[$normalized])) {
                        $ranked[$normalized] = [
                            'suggest' => $suggestion,
                            'distance' => 0,
                            'docs' => 0,
                            'prefix' => str_starts_with($normalized, mb_strtolower($query)) ? 1 : 0,
                        ];
                    }
                    $ranked[$normalized]['docs']++;
                }
            }

            uasort($ranked, static fn (array $a, array $b): int => [$b['prefix'], $b['docs']] <=> [$a['prefix'], $a['docs']]);
            foreach (array_slice($ranked, 0, $maxResults) as $item) {
                unset($item['prefix']);
                $suggestions[] = $item;
            }
        } catch (\Throwable $e) {
            if (config('app.debug')) {
                Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
            }
        }

        if (! empty($suggestions)) {
            $cacheMinutes = (int) ($autocompleteConfig['cache_minutes'] ?? 10);
            Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
        }

        return $suggestions;
    }

    /**
     * Extract a clean suggestion from a searchname.
     *
     * @param  string  $searchname  The full searchname
     * @param  string  $query  The user's query
     * @return string|null The extracted suggestion
     */
    private function extractSuggestion(string $searchname, string $query): ?string
    {
        // Clean up the searchname - remove file extensions, quality tags at the end
        $clean = (string) preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);

        // Replace dots and underscores with spaces for readability
        $clean = str_replace(['.', '_'], ' ', $clean);

        // Remove multiple spaces
        $clean = (string) preg_replace('/\s+/', ' ', $clean);
        $clean = trim($clean);

        if (empty($clean)) {
            return null;
        }

        // If the clean name is reasonable length, use it
        if (strlen($clean) <= 80) {
            return $clean;
        }

        // For very long names, try to extract the relevant part
        // Find where the query matches and extract context around it
        $pos = stripos($clean, $query);
        if ($pos !== false) {
            // Get up to 80 chars starting from the match position, or from beginning if match is early
            $start = max(0, $pos - 10);
            $extracted = substr($clean, $start, 80);

            // Clean up - don't cut mid-word
            if ($start > 0) {
                $extracted = (string) preg_replace('/^\S*\s/', '', $extracted);
            }
            $extracted = (string) preg_replace('/\s\S*$/', '', $extracted);

            return trim($extracted);
        }

        // Fallback: just truncate
        return substr($clean, 0, 80);
    }

    /**
     * Get spell correction suggestions ("Did you mean?").
     *
     * @param  string  $query  The search query to check
     * @param  string|null  $index  Index to use for suggestions
     * @return array<array{suggest: string, distance: int, docs: int}>
     */
    public function suggest(string $query, ?string $index = null): array
    {
        $suggestConfig = $this->config['suggest'] ?? [
            'enabled' => true,
            'max_edits' => 4,
        ];

        if (! ($suggestConfig['enabled'] ?? true)) {
            return [];
        }

        $query = trim($query);
        if (empty($query)) {
            return [];
        }

        // Don't suggest spelling corrections for queries with negation operators.
        // Suggesting "harry" for "!harry" would be misleading since the user
        // intentionally wants to exclude that term.
        if (self::queryHasNegation($query)) {
            return [];
        }

        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
        $cacheKey = 'manticore:suggest:'.config('search.index_generation', '1').':'.md5($index.$query);

        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $suggestions = [];

        try {
            // Try native CALL SUGGEST first
            $result = $this->manticoreSearch->suggest([
                'table' => $index,
                'body' => [
                    'query' => $query,
                    'options' => [
                        'limit' => 5,
                        'max_edits' => $suggestConfig['max_edits'] ?? 4,
                    ],
                ],
            ]);

            if (! empty($result) && is_array($result)) {
                foreach ($result as $item) {
                    if (isset($item['suggest']) && $item['suggest'] !== $query) {
                        $suggestions[] = [
                            'suggest' => $item['suggest'],
                            'distance' => $item['distance'] ?? 0,
                            'docs' => $item['docs'] ?? 0,
                        ];
                    }
                }
            }
        } catch (\Throwable $e) {
            if (config('app.debug')) {
                Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage());
            }
        }

        // If native suggest didn't return results, try a fuzzy search fallback
        if (empty($suggestions)) {
            $suggestions = $this->suggestFallback($query, $index);
        }

        if (! empty($suggestions)) {
            Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? 5));
        }

        return $suggestions;
    }

    /**
     * Fallback suggest using similar searchname matches.
     *
     * @param  string  $query  The search query
     * @param  string  $index  Index to search
     * @return array<array{suggest: string, distance: int, docs: int}>
     */
    private function suggestFallback(string $query, string $index): array
    {
        try {
            $escapedQuery = self::escapeString($query);
            if (empty($escapedQuery)) {
                return [];
            }

            $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
            $fields = ManticoreIndexRegistry::autocompleteFields($logical ?? '');
            if ($fields === []) {
                return [];
            }
            $searchExpr = '@@relaxed @('.implode(',', $fields).') '.$escapedQuery;

            $search = (new Search($this->manticoreSearch))
                ->setTable($index)
                ->search($searchExpr)
                ->limit(20)
                ->stripBadUtf8(true);

            $results = $search->get();

            // Extract common terms from the results that differ from the query
            $termCounts = [];
            foreach ($results as $doc) {
                $data = $doc->getData();
                $searchname = '';
                foreach ($fields as $field) {
                    if (! empty($data[$field])) {
                        $searchname = (string) $data[$field];
                        break;
                    }
                }

                // Extract words from searchname
                $words = preg_split('/[\s.\-_]+/', strtolower($searchname)) ?: [];
                foreach ($words as $word) {
                    if (strlen($word) >= 3 && $word !== strtolower($query)) {
                        // Check if word is similar to query (within edit distance)
                        $distance = levenshtein(strtolower($query), $word);
                        if ($distance > 0 && $distance <= 3) {
                            if (! isset($termCounts[$word])) {
                                $termCounts[$word] = ['count' => 0, 'distance' => $distance];
                            }
                            $termCounts[$word]['count']++;
                        }
                    }
                }
            }

            // Sort by count (most common first)
            uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);

            $suggestions = [];
            foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
                $suggestions[] = [
                    'suggest' => $term,
                    'distance' => $data['distance'],
                    'docs' => $data['count'],
                ];
            }

            return $suggestions;
        } catch (\Throwable $e) {
            if (config('app.debug')) {
                Log::debug('ManticoreSearch suggest fallback error: '.$e->getMessage());
            }

            return [];
        }
    }

    /**
     * Insert a movie into the movies search index.
     *
     * @param  array<string, mixed>  $parameters  Movie data
     */
    public function insertMovie(array $parameters): void
    {
        if (empty($parameters['id'])) {
            Log::warning('ManticoreSearch: Cannot insert movie without ID');

            return;
        }

        try {
            $document = [
                'imdbid' => (string) ($parameters['imdbid'] ?? ''),
                'tmdbid' => (int) ($parameters['tmdbid'] ?? 0),
                'traktid' => (int) ($parameters['traktid'] ?? 0),
                'title' => (string) ($parameters['title'] ?? ''),
                'year' => (string) ($parameters['year'] ?? ''),
                'genre' => (string) ($parameters['genre'] ?? ''),
                'actors' => (string) ($parameters['actors'] ?? ''),
                'director' => (string) ($parameters['director'] ?? ''),
                'rating' => (string) ($parameters['rating'] ?? ''),
                'plot' => (string) ($parameters['plot'] ?? ''),
            ];

            $this->manticoreSearch->table($this->getMoviesIndex())
                ->replaceDocument($document, $parameters['id']);

        } catch (ResponseException $e) {
            Log::error('ManticoreSearch insertMovie ResponseException: '.$e->getMessage(), [
                'movie_id' => $parameters['id'],
            ]);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch insertMovie unexpected error: '.$e->getMessage(), [
                'movie_id' => $parameters['id'],
            ]);
        }
    }

    /**
     * Update a movie in the search index.
     *
     * @param  int  $movieId  Movie ID
     */
    public function updateMovie(int $movieId): void
    {
        if (empty($movieId)) {
            Log::warning('ManticoreSearch: Cannot update movie without ID');

            return;
        }

        try {
            $movie = MovieInfo::find($movieId);

            if ($movie !== null) {
                $this->insertMovie($movie->toArray());
            } else {
                Log::warning('ManticoreSearch: Movie not found for update', ['id' => $movieId]);
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch updateMovie error: '.$e->getMessage(), [
                'movie_id' => $movieId,
            ]);
        }
    }

    /**
     * Delete a movie from the search index.
     *
     * @param  int  $id  Movie ID
     */
    public function deleteMovie(int $id): void
    {
        if (empty($id)) {
            Log::warning('ManticoreSearch: Cannot delete movie without ID');

            return;
        }

        try {
            $this->manticoreSearch->table($this->getMoviesIndex())
                ->deleteDocument($id);
        } catch (ResponseException $e) {
            Log::error('ManticoreSearch deleteMovie error: '.$e->getMessage(), [
                'id' => $id,
            ]);
        }
    }

    /**
     * Bulk insert multiple movies into the index.
     *
     * @param  array<string, mixed>  $movies  Array of movie data arrays
     * @return array<string, mixed> Results with 'success' and 'errors' counts
     */
    public function bulkInsertMovies(array $movies): array
    {
        if (empty($movies)) {
            return ['success' => 0, 'errors' => 0];
        }

        $success = 0;
        $errors = 0;

        $documents = [];
        foreach ($movies as $movie) {
            if (empty($movie['id'])) {
                $errors++;

                continue;
            }

            $documents[] = [
                'id' => $movie['id'],
                'imdbid' => (string) ($movie['imdbid'] ?? ''),
                'tmdbid' => (int) ($movie['tmdbid'] ?? 0),
                'traktid' => (int) ($movie['traktid'] ?? 0),
                'title' => (string) ($movie['title'] ?? ''),
                'year' => (string) ($movie['year'] ?? ''),
                'genre' => (string) ($movie['genre'] ?? ''),
                'actors' => (string) ($movie['actors'] ?? ''),
                'director' => (string) ($movie['director'] ?? ''),
                'rating' => (string) ($movie['rating'] ?? ''),
                'plot' => (string) ($movie['plot'] ?? ''),
            ];
        }

        if (! empty($documents)) {
            try {
                $this->manticoreSearch->table($this->getMoviesIndex())
                    ->replaceDocuments($documents);
                $success = count($documents);
            } catch (\Throwable $e) {
                Log::error('ManticoreSearch bulkInsertMovies error: '.$e->getMessage());
                $errors += count($documents);
            }
        }

        return ['success' => $success, 'errors' => $errors];
    }

    /**
     * Search the movies index.
     *
     * @param  array<string, mixed>|string  $searchTerm  Search term(s)
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array with 'id' (movie IDs) and 'data' (movie data)
     */
    public function searchMovies(array|string $searchTerm, int $limit = 1000): array
    {
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;

        return $this->searchIndexes($this->getMoviesIndex(), $searchString, ['title', 'actors', 'director'], []); // @phpstan-ignore argument.type
    }

    /**
     * {@inheritdoc}
     */
    public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array
    {
        $allowed = ['title' => true, 'director' => true, 'actors' => true, 'genre' => true];
        $filtered = [];
        foreach ($fieldTerms as $key => $value) {
            $k = (string) $key;
            if (isset($allowed[$k]) && is_string($value) && trim($value) !== '') {
                $filtered[$k] = trim($value);
            }
        }

        if ($filtered === [] || ! $this->isAvailable()) {
            return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []];
        }

        $raw = $this->searchIndexes($this->getMoviesIndex(), null, [], $filtered, $limit);
        $imdbids = [];
        $movieinfoIds = [];
        $data = $raw['data'] ?? [];

        foreach (($raw['id'] ?? []) as $i => $mid) {
            $movieinfoIds[] = (int) $mid;
            $row = $data[$i] ?? [];
            $imdb = (string) ($row['imdbid'] ?? '');
            if ($imdb !== '') {
                $imdbids[] = $imdb;
            }
        }

        return [
            'imdbids' => array_values(array_unique($imdbids)),
            'movieinfo_ids' => $movieinfoIds,
            'data' => $data,
        ];
    }

    /**
     * Search movies by external ID (IMDB, TMDB, Trakt).
     *
     * @param  string  $field  Field name (imdbid, tmdbid, traktid)
     * @param  int|string  $value  The external ID value
     * @return array<string, mixed>|null Movie data or null if not found
     */
    public function searchMovieByExternalId(string $field, int|string $value): ?array
    {
        if (empty($value)) {
            return null;
        }

        return $this->searchMovieByExternalIds([$field => $value]);
    }

    /**
     * @param  array<string, mixed>  $externalIds
     * @return array<string, mixed>|null
     */
    public function searchMovieByExternalIds(array $externalIds): ?array
    {
        $allowedFields = ['imdbid', 'tmdbid', 'traktid'];
        $normalized = [];

        foreach ($allowedFields as $field) {
            $value = $externalIds[$field] ?? null;
            if (empty($value)) {
                continue;
            }

            $normalized[$field] = $field === 'imdbid' ? (string) $value : (int) $value;
        }

        if ($normalized === []) {
            return null;
        }

        $cacheKey = 'manticore:movie:any:'.md5(serialize($normalized));
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        try {
            foreach ($normalized as $field => $value) {
                $query = (new Search($this->manticoreSearch))
                    ->setTable($this->getMoviesIndex())
                    ->filter($field, '=', $value)
                    ->limit(1);

                $results = $query->get();

                foreach ($results as $doc) {
                    $data = $doc->getData();
                    $data['id'] = $doc->getId();
                    Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5));

                    return $data;
                }
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchMovieByExternalIds error: '.$e->getMessage(), [
                'externalIds' => $externalIds,
            ]);
        }

        return null;
    }

    /**
     * Insert a TV show into the tvshows search index.
     *
     * @param  array<string, mixed>  $parameters  TV show data
     */
    public function insertTvShow(array $parameters): void
    {
        if (empty($parameters['id'])) {
            Log::warning('ManticoreSearch: Cannot insert TV show without ID');

            return;
        }

        try {
            $document = [
                'title' => (string) ($parameters['title'] ?? ''),
                'tvdb' => (int) ($parameters['tvdb'] ?? 0),
                'trakt' => (int) ($parameters['trakt'] ?? 0),
                'tvmaze' => (int) ($parameters['tvmaze'] ?? 0),
                'tvrage' => (int) ($parameters['tvrage'] ?? 0),
                'imdb' => (string) ($parameters['imdb'] ?? ''),
                'tmdb' => (int) ($parameters['tmdb'] ?? 0),
                'started' => (string) ($parameters['started'] ?? ''),
                'type' => (int) ($parameters['type'] ?? 0),
            ];

            $this->manticoreSearch->table($this->getTvShowsIndex())
                ->replaceDocument($document, $parameters['id']);

        } catch (ResponseException $e) {
            Log::error('ManticoreSearch insertTvShow ResponseException: '.$e->getMessage(), [
                'tvshow_id' => $parameters['id'],
            ]);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch insertTvShow unexpected error: '.$e->getMessage(), [
                'tvshow_id' => $parameters['id'],
            ]);
        }
    }

    /**
     * Update a TV show in the search index.
     *
     * @param  int  $videoId  Video/TV show ID
     */
    public function updateTvShow(int $videoId): void
    {
        if (empty($videoId)) {
            Log::warning('ManticoreSearch: Cannot update TV show without ID');

            return;
        }

        try {
            $video = Video::find($videoId);

            if ($video !== null) {
                $this->insertTvShow($video->toArray());
            } else {
                Log::warning('ManticoreSearch: TV show not found for update', ['id' => $videoId]);
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch updateTvShow error: '.$e->getMessage(), [
                'tvshow_id' => $videoId,
            ]);
        }
    }

    /**
     * Delete a TV show from the search index.
     *
     * @param  int  $id  TV show ID
     */
    public function deleteTvShow(int $id): void
    {
        if (empty($id)) {
            Log::warning('ManticoreSearch: Cannot delete TV show without ID');

            return;
        }

        try {
            $this->manticoreSearch->table($this->getTvShowsIndex())
                ->deleteDocument($id);
        } catch (ResponseException $e) {
            Log::error('ManticoreSearch deleteTvShow error: '.$e->getMessage(), [
                'id' => $id,
            ]);
        }
    }

    /**
     * Bulk insert multiple TV shows into the index.
     *
     * @param  array<string, mixed>  $tvShows  Array of TV show data arrays
     * @return array<string, mixed> Results with 'success' and 'errors' counts
     */
    public function bulkInsertTvShows(array $tvShows): array
    {
        if (empty($tvShows)) {
            return ['success' => 0, 'errors' => 0];
        }

        $success = 0;
        $errors = 0;

        $documents = [];
        foreach ($tvShows as $tvShow) {
            if (empty($tvShow['id'])) {
                $errors++;

                continue;
            }

            $documents[] = [
                'id' => $tvShow['id'],
                'title' => (string) ($tvShow['title'] ?? ''),
                'tvdb' => (int) ($tvShow['tvdb'] ?? 0),
                'trakt' => (int) ($tvShow['trakt'] ?? 0),
                'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0),
                'tvrage' => (int) ($tvShow['tvrage'] ?? 0),
                'imdb' => (string) ($tvShow['imdb'] ?? ''),
                'tmdb' => (int) ($tvShow['tmdb'] ?? 0),
                'started' => (string) ($tvShow['started'] ?? ''),
                'type' => (int) ($tvShow['type'] ?? 0),
            ];
        }

        if (! empty($documents)) {
            try {
                $this->manticoreSearch->table($this->getTvShowsIndex())
                    ->replaceDocuments($documents);
                $success = count($documents);
            } catch (\Throwable $e) {
                Log::error('ManticoreSearch bulkInsertTvShows error: '.$e->getMessage());
                $errors += count($documents);
            }
        }

        return ['success' => $success, 'errors' => $errors];
    }

    /**
     * Search the TV shows index.
     *
     * @param  array<string, mixed>|string  $searchTerm  Search term(s)
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array with 'id' (TV show IDs) and 'data' (TV show data)
     */
    public function searchTvShows(array|string $searchTerm, int $limit = 1000): array
    {
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;

        return $this->searchIndexes($this->getTvShowsIndex(), $searchString, ['title'], []); // @phpstan-ignore argument.type
    }

    /**
     * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB).
     *
     * @param  string  $field  Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb)
     * @param  int|string  $value  The external ID value
     * @return array<string, mixed>|null TV show data or null if not found
     */
    public function searchTvShowByExternalId(string $field, int|string $value): ?array
    {
        if (empty($value)) {
            return null;
        }

        return $this->searchTvShowByExternalIds([$field => $value]);
    }

    /**
     * @param  array<string, mixed>  $externalIds
     * @return array<string, mixed>|null
     */
    public function searchTvShowByExternalIds(array $externalIds): ?array
    {
        $allowedFields = ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'];
        $normalized = [];

        foreach ($allowedFields as $field) {
            $value = $externalIds[$field] ?? null;
            if (empty($value)) {
                continue;
            }

            $normalized[$field] = $field === 'imdb' ? (string) $value : (int) $value;
        }

        if ($normalized === []) {
            return null;
        }

        $cacheKey = 'manticore:tvshow:any:'.md5(serialize($normalized));
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        try {
            foreach ($normalized as $field => $value) {
                $query = (new Search($this->manticoreSearch))
                    ->setTable($this->getTvShowsIndex())
                    ->filter($field, '=', $value)
                    ->limit(1);

                $results = $query->get();

                foreach ($results as $doc) {
                    $data = $doc->getData();
                    $data['id'] = $doc->getId();
                    Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5));

                    return $data;
                }
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchTvShowByExternalIds error: '.$e->getMessage(), [
                'externalIds' => $externalIds,
            ]);
        }

        return null;
    }

    /**
     * Search releases by external media IDs.
     * Used to find releases associated with a specific movie or TV show.
     *
     * @param  array<string, mixed>  $externalIds  Associative array of external IDs
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed> Array of release IDs
     */
    public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array
    {
        return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit);
    }

    /**
     * @param  array<int, array<string, mixed>>  $externalIdSets
     * @return array<string, mixed>
     */
    public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array
    {
        if ($externalIdSets === []) {
            return [];
        }

        $normalizedLimit = min($limit, 10000);
        $cacheKey = 'manticore:releases:extid_sets:'.md5(serialize([$externalIdSets, $normalizedLimit]));
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $queries = [];
        foreach ($externalIdSets as $externalIds) {
            foreach ($externalIds as $field => $value) {
                if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'], true)) {
                    continue;
                }

                $typedValue = $field === 'imdbid' ? (string) $value : (int) $value;
                $key = $field.':'.$typedValue;
                $queries[$key] = [
                    'field' => $field,
                    'value' => $typedValue,
                ];
            }
        }

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

        $resultIds = [];
        $seen = [];

        try {
            foreach ($queries as $queryData) {
                $query = (new Search($this->manticoreSearch))
                    ->setTable($this->getReleasesIndex())
                    ->filter($queryData['field'], '=', $queryData['value'])
                    ->limit($normalizedLimit);

                $results = $query->get();
                foreach ($results as $doc) {
                    $docId = (int) $doc->getId();
                    if (isset($seen[$docId])) {
                        continue;
                    }

                    $seen[$docId] = true;
                    $resultIds[] = $docId;
                    if (count($resultIds) >= $normalizedLimit) {
                        break 2;
                    }
                }
            }
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchReleasesByMultipleExternalIds error: '.$e->getMessage(), [
                'externalIdSets' => $externalIdSets,
            ]);

            return [];
        }

        if ($resultIds !== []) {
            Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
        }

        return $resultIds;
    }

    /**
     * Search releases by category ID using the search index.
     * This provides a fast way to get release IDs for a specific category without hitting the database.
     *
     * @param  array<string, mixed>  $categoryIds  Array of category IDs to filter by
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed>
     */
    public function searchReleasesByCategory(array $categoryIds, int $limit = 1000): array
    {
        if (empty($categoryIds)) {
            return [];
        }

        // Filter out invalid category IDs (-1 means "all categories")
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);
        if (empty($validCategoryIds)) {
            return [];
        }

        $cacheKey = 'manticore:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit);
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        try {
            $query = (new Search($this->manticoreSearch))
                ->setTable($this->getReleasesIndex())
                ->limit(min($limit, 10000));

            // Use IN filter for multiple category IDs
            if (count($validCategoryIds) === 1) {
                $query->filter('categories_id', '=', (int) $validCategoryIds[0]); // @phpstan-ignore offsetAccess.notFound
            } else {
                $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds));
            }

            $results = $query->get();

            $resultIds = [];
            foreach ($results as $doc) {
                $resultIds[] = $doc->getId();
            }

            if (! empty($resultIds)) {
                Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
            }

            return $resultIds; // @phpstan-ignore return.type
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchReleasesByCategory error: '.$e->getMessage(), [
                'categoryIds' => $categoryIds,
            ]);
        }

        return [];
    }

    /**
     * Combined search: text search with category filtering.
     * First searches by text, then filters by category IDs using the search index.
     *
     * @param  string  $searchTerm  Search text
     * @param  array<string, mixed>  $categoryIds  Array of category IDs to filter by (empty for all categories)
     * @param  int  $limit  Maximum number of results
     * @return array<string, mixed>
     */
    public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array
    {
        if (empty($searchTerm)) {
            // If no search term, just filter by category
            return $this->searchReleasesByCategory($categoryIds, $limit);
        }

        // Filter out invalid category IDs
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);

        $cacheKey = 'manticore:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit);
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        try {
            $preparedSearch = self::prepareUserSearchQuery($searchTerm);
            if (empty($preparedSearch)) {
                return $this->searchReleasesByCategory($categoryIds, $limit);
            }

            $searchExpr = '@@relaxed '.self::scopeReleaseSearchQuery($searchTerm, $preparedSearch, '@searchname');

            $query = (new Search($this->manticoreSearch))
                ->setTable($this->getReleasesIndex())
                ->search($searchExpr)
                ->option('ranker', 'sph04')
                ->stripBadUtf8(true)
                ->limit(min($limit, 10000));

            // Add category filter if provided
            if (! empty($validCategoryIds)) {
                if (count($validCategoryIds) === 1) {
                    $query->filter('categories_id', '=', (int) $validCategoryIds[0]); // @phpstan-ignore offsetAccess.notFound
                } else {
                    $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds));
                }
            }

            $results = $query->get();

            $resultIds = [];
            foreach ($results as $doc) {
                $resultIds[] = $doc->getId();
            }

            if (! empty($resultIds)) {
                Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
            }

            return $resultIds; // @phpstan-ignore return.type
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [
                'searchTerm' => $searchTerm,
                'categoryIds' => $categoryIds,
            ]);
        }

        return [];
    }

    /**
     * {@inheritdoc}
     */
    public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array
    {
        if (! $this->isAvailable()) {
            return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false];
        }

        $phrases = $criteria['phrases'] ?? null;
        $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1;
        $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true) && ! self::hasMediaInfoFieldQuery($phrases);

        $execute = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array {
            $query = (new Search($this->manticoreSearch))
                ->setTable($this->getReleasesIndex())
                ->stripBadUtf8(true);

            if ($hasText) {
                if ($useFuzzy && $this->isFuzzyEnabled()) {
                    if (self::queryHasNegation($phrases)) {
                        return ['ids' => [], 'total' => 0, 'fuzzy' => false];
                    }
                    $searchString = $this->phrasesToFuzzyString($phrases);
                    if ($searchString === '') {
                        return ['ids' => [], 'total' => 0, 'fuzzy' => false];
                    }
                    $profile = ManticoreIndexRegistry::profile('releases');
                    $distance = min(
                        (int) ($this->getFuzzyConfig()['max_distance'] ?? 2),
                        $profile['fuzzy_distance']
                    );
                    $query->search($searchString)
                        ->option('fuzzy', true)
                        ->option('distance', $distance)
                        ->option('field_weights', $profile['fields']);
                } else {
                    $searchArray = $this->phrasesToSearchArray($phrases);
                    $terms = [];
                    foreach ($searchArray as $key => $value) {
                        if (! is_string($key) || ! in_array($key, self::RELEASE_FIELD_SEARCH_FIELDS, true) || $value === '' || $value === null) {
                            continue;
                        }
                        $prepared = self::prepareUserSearchQuery((string) $value);
                        if ($prepared !== '') {
                            $terms[] = '@@relaxed '.self::scopeReleaseSearchQuery((string) $value, $prepared, '@'.$key);
                        }
                    }
                    if ($terms === []) {
                        return ['ids' => [], 'total' => 0, 'fuzzy' => false];
                    }
                    $profile = ManticoreIndexRegistry::profile('releases');
                    $query->search(implode(' ', $terms))
                        ->option('ranker', $profile['ranker'])
                        ->option('field_weights', $profile['fields']);
                }
            }

            $this->applyManticoreReleaseIndexFilters($query, $criteria);

            $sortField = (string) ($criteria['sort_field'] ?? 'postdate_ts');
            $sortDir = strtolower((string) ($criteria['sort_dir'] ?? 'desc'));
            $allowedSort = ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id'];
            if (! in_array($sortField, $allowedSort, true)) {
                $sortField = 'postdate_ts';
            }
            $order = $sortDir === 'asc' ? 'asc' : 'desc';
            $query->sort($sortField, $order);
            if ($sortField !== 'id') {
                // Stabilize ordering across pages when primary sort values are equal.
                $query->sort('id', $order);
            }

            $maxNeed = max(1000, $offset + $limit + 100);
            $query->maxMatches(min($maxNeed, (int) ($this->config['max_matches'] ?? 10000)));
            $query->limit(max(1, $limit));
            if ($offset > 0) {
                $query->offset($offset);
            }

            try {
                $results = $query->get();
            } catch (\Throwable $e) {
                Log::error('ManticoreSearch searchReleasesFiltered error: '.$e->getMessage(), [
                    'criteria' => $criteria,
                ]);

                return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false];
            }

            $ids = [];
            $documents = [];
            foreach ($results as $doc) {
                $id = (int) $doc->getId();
                $ids[] = $id;
                if ((bool) ($criteria['include_documents'] ?? false)) {
                    $document = $doc->getData();
                    $document['id'] = $id;
                    $documents[] = $document;
                }
            }

            return [
                'ids' => $ids,
                'total' => (int) $results->getTotal(),
                'fuzzy' => $useFuzzy,
                'available' => true,
                // Manticore uses the compatible offset cursor until the PHP client
                // supports a compound search-after predicate without raw SQL.
                'last_sort' => [],
                'has_more' => $offset + count($ids) < (int) $results->getTotal(),
                'documents' => $documents,
            ];
        };

        if ($hasText) {
            $first = $execute(false);
            if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled() || self::queryHasNegation($phrases)) {
                return $first;
            }

            return $execute(true);
        }

        return $execute(false);
    }

    /**
     * @param  array<string, mixed>|string  $phrases
     * @return array<string, mixed>
     */
    private function phrasesToSearchArray(array|string $phrases): array
    {
        if (is_string($phrases)) {
            return ['searchname' => $phrases];
        }
        $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
        if ($isAssociative) {
            return $phrases;
        }

        return ['searchname' => implode(' ', $phrases)];
    }

    /**
     * @param  array<string, mixed>|string  $phrases
     */
    private function phrasesToFuzzyString(array|string $phrases): string
    {
        $searchArray = $this->phrasesToSearchArray($phrases);
        $searchTerms = [];
        foreach ($searchArray as $value) {
            if (! empty($value)) {
                $cleanValue = preg_replace('/[^\w\s]/', ' ', (string) $value);
                $cleanValue = preg_replace('/\s+/', ' ', trim((string) $cleanValue));
                if ($cleanValue !== '') {
                    $searchTerms[] = $cleanValue;
                }
            }
        }

        return implode(' ', $searchTerms);
    }

    private static function hasMediaInfoFieldQuery(mixed $phrases): bool
    {
        if (! is_array($phrases)) {
            return false;
        }

        foreach (self::RELEASE_MEDIA_INFO_TEXT_FIELDS as $field) {
            if (isset($phrases[$field]) && $phrases[$field] !== '' && $phrases[$field] !== -1) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param  array<string, mixed>  $criteria
     */
    private function applyManticoreReleaseIndexFilters(Search $query, array $criteria): void
    {
        $releaseIds = $criteria['release_ids'] ?? null;
        if (is_array($releaseIds) && $releaseIds !== []) {
            $valid = array_values(array_filter(
                array_map(static fn ($id): int => (int) $id, $releaseIds),
                static fn (int $id): bool => $id > 0
            ));
            if (count($valid) === 1) {
                $query->filter('id', '=', $valid[0]);
            } elseif (count($valid) > 1) {
                $query->filter('id', 'in', $valid);
            }
        }

        $categoryIds = $criteria['category_ids'] ?? null;
        if (is_array($categoryIds) && $categoryIds !== []) {
            $valid = array_values(array_filter($categoryIds, static fn ($id): bool => (int) $id > 0));
            if (count($valid) === 1) {
                $query->filter('categories_id', '=', (int) $valid[0]);
            } elseif (count($valid) > 1) {
                $query->filter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $valid));
            }
        }

        $excluded = $criteria['excluded_category_ids'] ?? [];
        if ($excluded !== []) {
            $query->notFilter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $excluded));
        }

        $minSize = (int) ($criteria['min_size'] ?? 0);
        if ($minSize > 0) {
            $query->filter('size', 'gte', $minSize);
        }

        $maxSize = (int) ($criteria['max_size'] ?? 0);
        if ($maxSize > 0) {
            $query->filter('size', 'lte', $maxSize);
        }

        $maxAge = (int) ($criteria['max_age_days'] ?? 0);
        if ($maxAge > 0) {
            $cutoff = time() - ($maxAge * 86400);
            $query->filter('postdate_ts', 'gte', $cutoff);
        }

        $minDate = (int) ($criteria['min_date'] ?? 0);
        if ($minDate > 0) {
            $query->filter('postdate_ts', 'gte', $minDate);
        }

        $maxDate = (int) ($criteria['max_date'] ?? 0);
        if ($maxDate > 0) {
            $query->filter('postdate_ts', 'lte', $maxDate);
        }

        $gid = $criteria['groups_id'] ?? null;
        if ($gid !== null && (int) $gid > 0) {
            $query->filter('groups_id', '=', (int) $gid);
        }

        $allowRar = (bool) ($criteria['password_allow_rar'] ?? false);
        if ($allowRar) {
            $query->filter('passwordstatus', 'lte', 1);
        } else {
            $query->filter('passwordstatus', 'lte', 0);
        }

        if (array_key_exists('password_status_min', $criteria) && $criteria['password_status_min'] !== null) {
            $query->filter('passwordstatus', 'gte', (int) $criteria['password_status_min']);
        }

        if (array_key_exists('has_media_info', $criteria) && $criteria['has_media_info'] !== null) {
            $query->filter('has_media_info', '=', (bool) $criteria['has_media_info'] ? 1 : 0);
        }

        $mediaUniqueId = ReleaseSearchQuery::normalizeMediaUniqueId($criteria['media_unique_id'] ?? null);
        if ($mediaUniqueId !== null) {
            $query->filter('media_unique_id', '=', $mediaUniqueId);
        }

        $minVideoWidth = (int) ($criteria['min_video_width'] ?? 0);
        if ($minVideoWidth > 0) {
            $query->filter('media_video_width', 'gte', $minVideoWidth);
        }

        $maxVideoWidth = (int) ($criteria['max_video_width'] ?? 0);
        if ($maxVideoWidth > 0) {
            $query->filter('media_video_width', 'lte', $maxVideoWidth);
        }

        $minVideoHeight = (int) ($criteria['min_video_height'] ?? 0);
        if ($minVideoHeight > 0) {
            $query->filter('media_video_height', 'gte', $minVideoHeight);
        }

        $maxVideoHeight = (int) ($criteria['max_video_height'] ?? 0);
        if ($maxVideoHeight > 0) {
            $query->filter('media_video_height', 'lte', $maxVideoHeight);
        }
    }

    public function getSecondaryIndexName(SecondarySearchIndex $index): string
    {
        $configured = $this->config['indexes'][$index->value] ?? null;
        if (is_string($configured) && $configured !== '') {
            return $configured;
        }

        return match ($index) {
            SecondarySearchIndex::Music => 'music_rt',
            SecondarySearchIndex::Books => 'books_rt',
            SecondarySearchIndex::Games => 'games_rt',
            SecondarySearchIndex::Console => 'console_rt',
            SecondarySearchIndex::Steam => 'steam_rt',
            SecondarySearchIndex::Anime => 'anime_rt',
        };
    }

    public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void
    {
        if ($id <= 0) {
            return;
        }

        try {
            $this->createIndexIfNotExists($this->getSecondaryIndexName($index));
            $this->manticoreSearch->table($this->getSecondaryIndexName($index))
                ->replaceDocument($document, $id);
        } catch (ResponseException $e) {
            Log::error('ManticoreSearch insertSecondary ResponseException: '.$e->getMessage(), [
                'secondary' => $index->value,
                'id' => $id,
            ]);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch insertSecondary error: '.$e->getMessage(), [
                'secondary' => $index->value,
                'id' => $id,
            ]);
        }
    }

    public function updateSecondary(SecondarySearchIndex $index, int $id): void
    {
        if ($id <= 0) {
            return;
        }

        try {
            match ($index) {
                SecondarySearchIndex::Music => $this->insertSecondary($index, $id, SecondaryIndexDocuments::music(MusicInfo::query()->findOrFail($id))),
                SecondarySearchIndex::Books => $this->insertSecondary($index, $id, SecondaryIndexDocuments::book(BookInfo::query()->findOrFail($id))),
                SecondarySearchIndex::Games => $this->insertSecondary($index, $id, SecondaryIndexDocuments::games(GamesInfo::query()->findOrFail($id))),
                SecondarySearchIndex::Console => $this->insertSecondary($index, $id, SecondaryIndexDocuments::console(ConsoleInfo::query()->findOrFail($id))),
                SecondarySearchIndex::Steam => $this->insertSecondary($index, $id, SecondaryIndexDocuments::steam(SteamApp::query()->findOrFail($id))),
                SecondarySearchIndex::Anime => null,
            };
        } catch (\Throwable $e) {
            Log::warning('ManticoreSearch updateSecondary skipped: '.$e->getMessage(), [
                'secondary' => $index->value,
                'id' => $id,
            ]);
        }
    }

    public function deleteSecondary(SecondarySearchIndex $index, int $id): void
    {
        if ($id <= 0) {
            return;
        }

        try {
            $this->manticoreSearch->table($this->getSecondaryIndexName($index))
                ->deleteDocument($id);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch deleteSecondary error: '.$e->getMessage(), [
                'secondary' => $index->value,
                'id' => $id,
            ]);
        }
    }

    /**
     * @param  array<int, array<string, mixed>>  $documents
     * @return array<string, mixed>
     */
    public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array
    {
        if ($documents === []) {
            return ['success' => 0, 'errors' => 0];
        }

        $success = 0;
        $errors = 0;
        $rows = [];
        $table = $this->getSecondaryIndexName($index);

        foreach ($documents as $doc) {
            $docId = (int) ($doc['id'] ?? 0);
            if ($docId <= 0) {
                $errors++;

                continue;
            }
            unset($doc['id']);
            $rows[] = array_merge(['id' => $docId], $doc);
        }

        if ($rows === []) {
            return ['success' => 0, 'errors' => $errors];
        }

        try {
            $this->createIndexIfNotExists($table);
            $this->manticoreSearch->table($table)->replaceDocuments($rows);
            $success = count($rows);
        } catch (\Throwable $e) {
            Log::error('ManticoreSearch bulkInsertSecondary error: '.$e->getMessage(), ['secondary' => $index->value]);
            $errors += count($rows);
        }

        return ['success' => $success, 'errors' => $errors];
    }

    /**
     * @return array{id: list<int>, data: list<array<string, mixed>>}
     */
    public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array
    {
        $query = trim($query);
        if ($query === '') {
            return ['id' => [], 'data' => []];
        }

        $result = $this->searchIndexes(
            $this->getSecondaryIndexName($index),
            $query,
            $index->fulltextFields(),
            [],
            $this->normalizeSearchLimit($limit)
        );

        return [
            'id' => array_map(static fn ($v): int => (int) $v, $result['id'] ?? []),
            'data' => $result['data'] ?? [],
        ];
    }

    /**
     * @return list<int>
     */
    public function searchAnimeTitle(string $query, int $limit = 100): array
    {
        $hits = $this->searchSecondary(SecondarySearchIndex::Anime, $query, $limit);
        $seen = [];
        foreach ($hits['data'] as $row) {
            $aid = (int) ($row['anidbid'] ?? 0);
            if ($aid > 0) {
                $seen[$aid] = true;
            }
        }

        return array_keys($seen);
    }
}
