<?php

declare(strict_types=1);

namespace App\Services\Releases;

use App\Enums\SecondarySearchIndex;
use App\Facades\Search;
use App\Models\AnidbInfo;
use App\Models\Category;
use App\Models\Release;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\Search\DTO\ReleaseSearchQuery;
use App\Services\Search\DTO\SearchCursor;
use App\Support\ReleaseSearchIndexDocument;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Service for searching releases.
 */
class ReleaseSearchService
{
    private const CACHE_VERSION_KEY = 'releases:cache_version';

    private const SEARCH_INDEX_BUFFER_PAGES = 10;

    private const SEARCH_INDEX_MIN_CANDIDATES = 250;

    private const SEARCH_INDEX_MAX_CANDIDATES = 2000;

    // RAR/ZIP Password indicator.
    public const PASSWD_NONE = 0;

    public const PASSWD_RAR = 1;

    public function __construct() {}

    /**
     * Function for searching on the site (by subject, searchname or advanced).
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCats
     * @param  array<string, mixed>  $orderBy
     * @param  array<string, mixed>  $searchArr
     * @return array|Collection|mixed
     */
    public function search(
        array $searchArr,
        mixed $groupName,
        mixed $sizeFrom,
        mixed $sizeTo,
        mixed $daysNew,
        mixed $daysOld,
        int $offset = 0,
        int $limit = 1000,
        array|string $orderBy = '',
        int $maxAge = -1,
        array $excludedCats = [],
        string $type = 'basic',
        array $cat = [-1],
        int $minSize = 0
    ): mixed {
        if (config('app.debug')) {
            Log::debug('ReleaseSearchService::search called', [
                'searchArr' => $searchArr,
                'limit' => $limit,
                'type' => $type,
            ]);
        }

        // Filter out -1 values and empty strings
        $searchFields = Arr::where($searchArr, static function ($value) {
            return $value !== -1 && $value !== '' && $value !== null;
        });

        if ($searchFields === []) {
            return collect();
        }

        $categoryIds = $this->resolveSearchCategoryIds($cat, $excludedCats);
        if ($categoryIds === []) {
            return collect();
        }

        $orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy);

        if (Search::isAvailable()) {
            $groupId = null;
            if ((int) $groupName !== -1) {
                $resolved = UsenetGroup::getIDByName((string) $groupName);
                if ($resolved) {
                    $groupId = (int) $resolved;
                }
            }

            [$sizeMinFromRange, $sizeMaxFromRange] = $this->resolveSizeRangeBounds($sizeFrom, $sizeTo);
            $minSizeCriteria = max((int) $minSize, $sizeMinFromRange);
            $maxSizeCriteria = $sizeMaxFromRange;
            [$minDateCriteria, $maxDateCriteria] = $this->resolvePostdateBounds($daysNew, $daysOld);

            $criteria = [
                'phrases' => $searchFields,
                'category_ids' => $categoryIds,
                'excluded_category_ids' => $excludedCats,
                'min_size' => $minSizeCriteria,
                'max_size' => $maxSizeCriteria,
                'max_age_days' => $maxAge,
                'min_date' => $minDateCriteria,
                'max_date' => $maxDateCriteria,
                'groups_id' => $groupId,
                'password_allow_rar' => $this->passwordAllowRar(),
                'sort_field' => $this->browseOrderToIndexSortField((string) $orderBy[0]),
                'sort_dir' => $orderBy[1] ?? 'desc',
                'try_fuzzy' => true,
            ];

            $searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset));
            $filtered = $searchPage->legacy();

            if ($filtered['ids'] !== []) {
                $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']);
                $idList = implode(',', $ids);
                $baseSql = $this->buildSearchBaseSql(sprintf('WHERE r.id IN (%s)', $idList));
                $sql = sprintf('SELECT * FROM (%s) r ORDER BY FIELD(r.id, %s)', $baseSql, $idList);
                $cacheKey = md5($this->getCacheVersion().$sql);
                $cachedReleases = Cache::get($cacheKey);
                if ($cachedReleases !== null) {
                    return $cachedReleases;
                }

                $releases = Release::fromQuery($sql);
                if ($releases->isNotEmpty()) {
                    $releases[0]->_totalrows = (int) $filtered['total'];
                }

                $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
                Cache::put($cacheKey, $releases, $expiresAt);

                return $releases;
            }

            if (config('nntmux.mysql_search_fallback', false) !== true) {
                return collect();
            }
        }

        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
        $searchResult = $this->performIndexSearch($searchFields, $searchLimit);

        if (config('app.debug')) {
            Log::debug('ReleaseSearchService::search after performIndexSearch', [
                'result_count' => count($searchResult),
            ]);
        }

        if (count($searchResult) === 0) {
            return collect();
        }

        // Build WHERE clause
        $whereSql = $this->buildSearchWhereClause(
            $searchResult,
            $groupName,
            $sizeFrom,
            $sizeTo,
            $daysNew,
            $daysOld,
            $maxAge,
            $excludedCats,
            $cat,
            $minSize
        );

        // Build base SQL
        $baseSql = $this->buildSearchBaseSql($whereSql);

        // Build final SQL with pagination
        $sql = sprintf(
            'SELECT * FROM (%s) r ORDER BY r.%s %s LIMIT %d OFFSET %d',
            $baseSql,
            $orderBy[0], // @phpstan-ignore offsetAccess.notFound
            $orderBy[1], // @phpstan-ignore offsetAccess.notFound
            $limit,
            $offset
        );

        // Check cache
        $cacheKey = md5($this->getCacheVersion().$sql);
        $releases = Cache::get($cacheKey);
        if ($releases !== null) {
            return $releases;
        }

        // Execute query
        $releases = Release::fromQuery($sql);

        // Add total count for pagination
        if ($releases->isNotEmpty()) {
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
        }

        // Cache results
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
        Cache::put($cacheKey, $releases, $expiresAt);

        return $releases;
    }

    /**
     * Search function for API.
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCats
     * @return Collection|mixed
     */
    public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0, string $orderBy = 'posted_desc', ?SearchCursor $cursor = null): mixed
    {
        if (config('app.debug')) {
            Log::debug('ReleaseSearchService::apiSearch called', [
                'searchName' => $searchName,
                'groupName' => $groupName,
                'offset' => $offset,
                'limit' => $limit,
            ]);
        }

        $hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null;
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);

        if (Search::isAvailable()) {
            $groupId = null;
            if ((int) $groupName !== -1) {
                $resolved = UsenetGroup::getIDByName((string) $groupName);
                if ($resolved) {
                    $groupId = (int) $resolved;
                }
            }

            $categoryIdsRaw = Category::getCategorySearch($cat, null, true);
            $categoryIds = null;
            if (is_array($categoryIdsRaw)) {
                $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw);
            } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
                $categoryIds = [(int) $categoryIdsRaw];
            }

            $criteria = [
                'phrases' => $hasText ? $searchName : null,
                'category_ids' => $categoryIds,
                'excluded_category_ids' => $excludedCats,
                'min_size' => $minSize,
                'max_age_days' => $maxAge,
                'groups_id' => $groupId,
                'password_allow_rar' => $this->passwordAllowRar(),
                'sort_field' => $this->browseOrderToIndexSortField($orderField),
                'sort_dir' => $orderDir,
                'try_fuzzy' => true,
                'include_documents' => true,
            ];

            $criteria['track_total'] = $cursor === null;
            $searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset, $cursor));
            $filtered = $searchPage->legacy();

            if ($searchPage->documents !== []) {
                $releases = collect(array_map(function (array $document): object {
                    $row = ReleaseSearchIndexDocument::toReleaseRow($document);
                    $row['title'] = $row['episode_title'];
                    $row['firstaired'] = $row['firstaired_ts'] > 0
                        ? date('Y-m-d H:i:s', $row['firstaired_ts'])
                        : null;

                    return (object) $row;
                }, $searchPage->documents));
                if ($releases->isNotEmpty()) {
                    $releases[0]->_totalrows = (int) $filtered['total'];
                    $releases[0]->_search_last_sort = $searchPage->lastSortValues;
                    $releases[0]->_search_has_more = $searchPage->hasMore;
                }

                return $releases;
            }
            if ($cursor !== null && $filtered['total'] === 0) {
                $filtered['total'] = $cursor->total;
            }

            if ($filtered['ids'] === [] && $hasText && config('nntmux.mysql_search_fallback', false) === true) {
                return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
            }

            if ($filtered['ids'] === []) {
                return collect();
            }

            $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']);
            $idList = implode(',', $ids);
            $whereSql = 'WHERE r.id IN ('.$idList.')';

            $sql = sprintf(
                "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
                    cp.title AS parent_category, c.title AS sub_category,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    g.name AS group_name,
                    m.imdbid, m.tmdbid, m.traktid,
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
                    tve.firstaired, tve.title, tve.series, tve.episode
            FROM releases r
            INNER JOIN categories c ON c.id = r.categories_id
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
            %s
            ORDER BY r.%s %s",
                $whereSql,
                $orderField,
                $orderDir
            );

            $cacheKey = md5($this->getCacheVersion().$sql);
            $cachedReleases = Cache::get($cacheKey);
            if ($cachedReleases !== null) {
                return $cachedReleases;
            }

            $releases = Release::fromQuery($sql);

            if ($releases->isNotEmpty()) {
                $releases[0]->_totalrows = $filtered['total'];
                $releases[0]->_search_last_sort = $searchPage->lastSortValues;
                $releases[0]->_search_has_more = $searchPage->hasMore;
            }

            $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
            Cache::put($cacheKey, $releases, $expiresAt);

            return $releases;
        }

        return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
    }

    /**
     * Original API search path (SQL filters + optional index ID list from fuzzy only).
     *
     * @param  array<int|string, mixed>  $cat
     */
    private function apiSearchLegacyMysql(mixed $searchName, mixed $groupName, int $offset, int $limit, int $maxAge, array $excludedCats, array $cat, int $minSize, string $orderBy = 'posted_desc'): mixed
    {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);

        $searchResult = [];
        $hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null;
        if ($hasText) {
            $fuzzyResult = Search::searchReleasesWithFuzzy($searchName, $searchLimit);
            $searchResult = $fuzzyResult['ids'] ?? [];

            if (config('app.debug') && ($fuzzyResult['fuzzy'] ?? false)) {
                Log::debug('apiSearch: Using fuzzy search results');
            }

            if ($searchResult === [] && config('nntmux.mysql_search_fallback', false) === true) {
                if (config('app.debug')) {
                    Log::debug('apiSearch: Falling back to MySQL search');
                }
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $searchLimit);
            }

            if ($searchResult === []) {
                if (config('app.debug')) {
                    Log::debug('apiSearch: No results from any search engine');
                }

                return collect();
            }
        }

        $conditions = [
            sprintf('r.passwordstatus %s', $this->showPasswords()),
        ];

        if ($maxAge > 0) {
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
        }

        if ((int) $groupName !== -1) {
            $groupId = UsenetGroup::getIDByName($groupName);
            if ($groupId) {
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
            }
        }

        $catQuery = Category::getCategorySearch($cat);
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery));
        if (! empty($catQuery) && $catQuery !== '1=1') {
            $conditions[] = $catQuery;
        }

        if ($excludedCats !== []) {
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats)));
        }

        if ($searchResult !== []) {
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $searchResult)));
        }

        if ($minSize > 0) {
            $conditions[] = sprintf('r.size >= %d', $minSize);
        }

        $whereSql = 'WHERE '.implode(' AND ', $conditions);

        $sql = sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
                    cp.title AS parent_category, c.title AS sub_category,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    g.name AS group_name,
                    m.imdbid, m.tmdbid, m.traktid,
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
                    tve.firstaired, tve.title, tve.series, tve.episode
            FROM releases r
            INNER JOIN categories c ON c.id = r.categories_id
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
            %s
            ORDER BY r.%s %s
            LIMIT %d OFFSET %d",
            $whereSql,
            $orderField,
            $orderDir,
            $limit,
            $offset
        );

        $cacheKey = md5($this->getCacheVersion().$sql);
        $cachedReleases = Cache::get($cacheKey);
        if ($cachedReleases !== null) {
            return $cachedReleases;
        }

        $releases = Release::fromQuery($sql);

        if ($releases->isNotEmpty()) {
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
            $countResult = Release::fromQuery($countSql);
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
        }

        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
        Cache::put($cacheKey, $releases, $expiresAt);

        return $releases;
    }

    /**
     * API: music releases linked to rows from the music metadata index.
     *
     * @param  array<int|string, mixed>  $cat
     * @param  array<int, int>  $excludedCats
     */
    public function apiMusicSearch(
        string $q,
        mixed $groupName,
        int $offset,
        int $limit,
        int $maxAge,
        array $excludedCats,
        array $cat,
        int $minSize,
        string $orderBy = 'posted_desc'
    ): mixed {
        $q = trim($q);
        if ($q === '' || ! Search::isAvailable()) {
            return collect();
        }

        $musicInfoIds = Search::searchSecondary(SecondarySearchIndex::Music, $q, 2000)['id'];

        return $this->apiSearchByMetadataForeignKey($musicInfoIds, 'musicinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
    }

    /**
     * API: book releases linked to rows from the books metadata index.
     *
     * @param  array<int|string, mixed>  $cat
     * @param  array<int, int>  $excludedCats
     */
    public function apiBookSearch(
        string $q,
        mixed $groupName,
        int $offset,
        int $limit,
        int $maxAge,
        array $excludedCats,
        array $cat,
        int $minSize,
        string $orderBy = 'posted_desc'
    ): mixed {
        $q = trim($q);
        if ($q === '' || ! Search::isAvailable()) {
            return collect();
        }

        $bookIds = Search::searchSecondary(SecondarySearchIndex::Books, $q, 2000)['id'];

        return $this->apiSearchByMetadataForeignKey($bookIds, 'bookinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
    }

    /**
     * @param  list<int>  $metadataIds
     * @param  array<int|string, mixed>  $cat
     * @param  array<int, int>  $excludedCats
     */
    private function apiSearchByMetadataForeignKey(
        array $metadataIds,
        string $column,
        mixed $groupName,
        int $offset,
        int $limit,
        int $maxAge,
        array $excludedCats,
        array $cat,
        int $minSize,
        string $orderBy = 'posted_desc'
    ): mixed {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        if ($metadataIds === []) {
            return collect();
        }

        if (! in_array($column, ['musicinfo_id', 'bookinfo_id'], true)) {
            return collect();
        }

        $conditions = [
            sprintf('r.passwordstatus %s', $this->showPasswords()),
            sprintf('r.%s IN (%s)', $column, implode(',', array_map(static fn (int $id): int => $id, $metadataIds))),
        ];

        if ($maxAge > 0) {
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
        }

        if ((int) $groupName !== -1) {
            $groupId = UsenetGroup::getIDByName($groupName);
            if ($groupId) {
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
            }
        }

        $catQuery = Category::getCategorySearch($cat);
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery));
        if (! empty($catQuery) && $catQuery !== '1=1') {
            $conditions[] = $catQuery;
        }

        if ($excludedCats !== []) {
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats)));
        }

        if ($minSize > 0) {
            $conditions[] = sprintf('r.size >= %d', $minSize);
        }

        $whereSql = 'WHERE '.implode(' AND ', $conditions);

        $sql = sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
                    cp.title AS parent_category, c.title AS sub_category,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    g.name AS group_name,
                    m.imdbid, m.tmdbid, m.traktid,
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
                    tve.firstaired, tve.title, tve.series, tve.episode
            FROM releases r
            INNER JOIN categories c ON c.id = r.categories_id
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
            %s
            ORDER BY r.%s %s
            LIMIT %d OFFSET %d",
            $whereSql,
            $orderField,
            $orderDir,
            $limit,
            $offset
        );

        $cacheKey = md5($this->getCacheVersion().$sql);
        $cachedReleases = Cache::get($cacheKey);
        if ($cachedReleases !== null) {
            return $cachedReleases;
        }

        $releases = Release::fromQuery($sql);

        if ($releases->isNotEmpty()) {
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
            $countResult = Release::fromQuery($countSql);
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
        }

        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
        Cache::put($cacheKey, $releases, $expiresAt);

        return $releases;
    }

    /**
     * Search for TV shows via API.
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCategories
     * @param  array<string, mixed>  $siteIdArr
     * @return array|Collection|\Illuminate\Support\Collection|mixed
     */
    public function tvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
    {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        $hasStrictTvSelector = $this->hasTvLookupIdentifiers($siteIdArr);
        $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0);
        $rawCacheKey = md5(serialize(func_get_args()).'tvSearch');
        $cacheKey = null;
        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
        if ($shouldCache) {
            $cacheKey = md5($this->getCacheVersion().'tvSearch:v2:'.$rawCacheKey);
            $cached = Cache::get($cacheKey);
            if ($cached !== null) {
                return $cached;
            }
        }

        $conditions = [
            sprintf('r.passwordstatus %s', $this->showPasswords()),
        ];

        $videoJoinCondition = '';
        $episodeJoinCondition = '';
        $needsEpisodeJoin = false;
        $needsDatabaseLookup = false;

        // OPTIMIZATION: Try to find releases using search index external IDs first
        $externalIds = [];
        if (! empty($siteIdArr)) {
            foreach ($siteIdArr as $column => $id) {
                $hasValue = $column === 'imdb'
                    ? ($id !== null && $id !== '' && imdb_id_is_valid((string) $id))
                    : ($id > 0);

                if ($hasValue && $column !== 'id') {
                    // Map column names to search index field names
                    $fieldName = match ($column) {
                        'tvdb' => 'tvdb',
                        'trakt' => 'traktid', // Note: in releases index we use traktid
                        'tvmaze' => 'tvmaze',
                        'tvrage' => 'tvrage',
                        'imdb' => 'imdbid',
                        'tmdb' => 'tmdbid',
                        default => null,
                    };
                    if ($fieldName) {
                        $externalIds[$fieldName] = $column === 'imdb' ? (string) $id : (int) $id;
                    }
                }
            }
        }

        // Try to get releases directly from search index using external IDs
        $searchResult = [];
        if (! empty($externalIds)) {
            $searchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);

            if (config('app.debug') && ! empty($searchResult)) {
                Log::debug('tvSearch: Found releases via search index by external IDs', [
                    'externalIds' => $externalIds,
                    'count' => count($searchResult),
                ]);
            }
        }

        // If search index didn't return results, fall back to database lookup
        if (empty($searchResult) && $hasStrictTvSelector) {
            $siteConditions = [];
            foreach ($siteIdArr as $column => $id) {
                if ($id > 0) {
                    $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id);
                }
            }

            if (! empty($siteConditions)) {
                $needsDatabaseLookup = true;
                $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0;

                $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : '';
                $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : '';
                $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '';

                $lookupSql = sprintf(
                    'SELECT v.id AS video_id, tve.id AS episode_id FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s',
                    implode(' OR ', $siteConditions),
                    $seriesFilter,
                    $episodeFilter,
                    $airDateFilter
                );

                $results = Release::fromQuery($lookupSql);

                if ($results->isEmpty()) {
                    $this->logStrictExternalLookupMiss('tvSearch database lookup', $siteIdArr, [
                        'series' => $series,
                        'episode' => $episode,
                        'airDate' => $airDate,
                    ]);

                    return collect();
                }

                $videoIds = $results->pluck('video_id')->filter()->unique()->toArray();
                $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray();

                if (! empty($videoIds)) {
                    $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds)));
                    $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds)));
                }

                if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) {
                    $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds)));
                    $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds)));
                    $needsEpisodeJoin = true;
                }

                if ($siteUsesVideoIdOnly) {
                    $needsEpisodeJoin = false;
                }
            }
        }

        // If search index found releases via external IDs, add them to conditions
        $hasSearchResultFromExternalIds = ! empty($searchResult);
        if ($hasSearchResultFromExternalIds) {
            if (Search::isAvailable()) {
                $searchResult = $this->intersectReleaseIdsWithSearchFilters(
                    $searchResult,
                    $cat,
                    'tv',
                    $maxAge,
                    $minSize,
                    $excludedCategories
                );
                if ($searchResult === []) {
                    return collect();
                }
            }
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));

            $episodePredicates = $this->buildEpisodeJoinPredicates($series, $episode, $airDate);
            if ($episodePredicates !== []) {
                foreach ($episodePredicates as $predicate) {
                    $conditions[] = $predicate;
                }
                $needsEpisodeJoin = true;
            }
        }

        // Only do name-based search if we don't already have results from external IDs
        if (! $hasSearchResultFromExternalIds && ! $hasStrictTvSelector && ! empty($name)) {
            $searchName = $name;
            $hasValidSiteIds = false;
            foreach ($siteIdArr as $column => $id) {
                if ($id > 0) {
                    $hasValidSiteIds = true;
                    break;
                }
            }

            if (! $hasValidSiteIds) {
                // Build search name with season/episode for the full-text search
                if (! empty($series) && (int) $series < 1900) {
                    $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
                    $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
                    if (! empty($episode) && ! str_contains($episode, '/')) {
                        $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
                        $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
                    }
                } elseif (! empty($airDate)) {
                    $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
                }
            }

            $searchResult = Search::searchReleases(['searchname' => $searchName], $searchLimit);

            // Fall back to MySQL if search engine failed (only if enabled)
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $searchLimit);
            }

            if (empty($searchResult)) {
                return collect();
            }

            if (Search::isAvailable()) {
                $searchResult = $this->intersectReleaseIdsWithSearchFilters(
                    $searchResult,
                    $cat,
                    'tv',
                    $maxAge,
                    $minSize,
                    $excludedCategories
                );
                if ($searchResult === []) {
                    return collect();
                }
            }

            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));

            // Try to add episode conditions if season/episode data is provided and no valid site IDs
            // This will filter results to only those with matching episode data in tv_episodes table
            // If this results in no matches, we'll fall back to results without episode conditions
            if (! $hasValidSiteIds && (! empty($series) || ! empty($airDate))) {
                $episodeConditions = $this->buildEpisodeJoinPredicates($series, $episode, $airDate);

                if (! empty($episodeConditions)) {
                    // Check if any of the found releases have matching episode data
                    $checkSql = sprintf(
                        'SELECT r.id FROM releases r INNER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id WHERE r.id IN (%s) AND %s LIMIT 1',
                        implode(',', array_map('intval', $searchResult)),
                        implode(' AND ', $episodeConditions)
                    );
                    $hasEpisodeMatches = Release::fromQuery($checkSql);

                    if ($hasEpisodeMatches->isNotEmpty()) {
                        // Some releases have matching episode data, add the conditions
                        foreach ($episodeConditions as $cond) {
                            $conditions[] = $cond;
                        }
                        $needsEpisodeJoin = true;
                    }
                    // If no matches with episode data, don't add episode conditions
                    // The search will return results based on searchname match only
                }
            }
        }

        $applySqlCategorySizeExcluded = $needsDatabaseLookup || empty($searchResult) || ! Search::isAvailable();

        $catQuery = Category::getCategorySearch($cat, 'tv');
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
        if ($applySqlCategorySizeExcluded) {
            if (! empty($catQuery) && $catQuery !== '1=1') {
                $conditions[] = $catQuery;
            }

            if ($maxAge > 0) {
                $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
            }
            if ($minSize > 0) {
                $conditions[] = sprintf('r.size >= %d', $minSize);
            }
            if (! empty($excludedCategories)) {
                $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
            }
        }

        $whereSql = 'WHERE '.implode(' AND ', $conditions);

        $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT';

        // Optimized select list – only fields required by XML (extended) and transformers
        $baseSql = sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
                    r.adddate, r.videos_id, r.tv_episodes_id,
                    v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
                    tve.series, tve.episode, tve.firstaired,
                    cp.title AS parent_category, c.title AS sub_category,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    g.name AS group_name
            FROM releases r
            INNER JOIN categories c ON c.id = r.categories_id
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s
            %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            %s",
            $videoJoinCondition,
            $joinType,
            $episodeJoinCondition,
            $whereSql
        );

        $limitClause = '';
        if ($limit > 0) {
            $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset);
        }

        $sql = sprintf('%s ORDER BY r.%s %s%s', $baseSql, $orderField, $orderDir, $limitClause);
        $releases = Release::fromQuery($sql);

        if ($hasStrictTvSelector && $releases->isEmpty()) {
            $this->logStrictExternalLookupMiss('tvSearch release lookup', $siteIdArr, [
                'series' => $series,
                'episode' => $episode,
                'airDate' => $airDate,
            ]);
        }

        if ($releases->isNotEmpty()) {
            $countSql = sprintf(
                'SELECT COUNT(*) as count FROM releases r %s %s %s',
                (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''),
                ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''),
                $whereSql
            );
            $countResult = Release::fromQuery($countSql);
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
        }

        if ($shouldCache && $cacheKey !== null) {
            $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
            Cache::put($cacheKey, $releases, $expiresAt);
        }

        return $releases;
    }

    /**
     * Search TV Shows via APIv2.
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCategories
     * @param  array<string, mixed>  $siteIdArr
     * @return Collection|mixed
     */
    public function apiTvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
    {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
        $hasStrictTvSelector = $this->hasTvLookupIdentifiers($siteIdArr);

        // OPTIMIZATION: Try to find releases using search index external IDs first
        $externalIds = [];
        foreach ($siteIdArr as $column => $Id) {
            $hasValue = $column === 'imdb'
                ? ($Id !== null && $Id !== '' && imdb_id_is_valid((string) $Id))
                : ($Id > 0);

            if ($hasValue && $column !== 'id') {
                $fieldName = match ($column) {
                    'tvdb' => 'tvdb',
                    'trakt' => 'traktid',
                    'tvmaze' => 'tvmaze',
                    'tvrage' => 'tvrage',
                    'imdb' => 'imdbid',
                    'tmdb' => 'tmdbid',
                    default => null,
                };
                if ($fieldName) {
                    $externalIds[$fieldName] = $column === 'imdb' ? (string) $Id : (int) $Id;
                }
            }
        }

        // Try to get releases directly from search index using external IDs
        $indexSearchResult = [];
        if (! empty($externalIds)) {
            $indexSearchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);

            if (config('app.debug') && ! empty($indexSearchResult)) {
                Log::debug('apiTvSearch: Found releases via search index by external IDs', [
                    'externalIds' => $externalIds,
                    'count' => count($indexSearchResult),
                ]);
            }
        }

        // Resolve show / episode filters from videos + tv_episodes when the index missed, or
        // when the index hit but the client narrowed by season/episode/airdate (index has no S/E).
        $siteSQL = $this->buildApiTvSiteSqlArray($siteIdArr);
        $showSql = '';
        if (\count($siteSQL) > 0) {
            $shouldLookupShow = empty($indexSearchResult)
                || $this->tvApiRequestTargetsEpisode($series, $episode, $airDate);
            if ($shouldLookupShow) {
                $strictEpisodeResolution = ! empty($indexSearchResult)
                    && $this->tvApiRequestTargetsEpisode($series, $episode, $airDate);
                $fragment = $this->lookupApiTvShowSqlFragment(
                    $siteSQL,
                    $series,
                    $episode,
                    $airDate,
                    $strictEpisodeResolution
                );
                if ($fragment === null) {
                    $this->logStrictExternalLookupMiss('apiTvSearch show lookup', $siteIdArr, [
                        'series' => $series,
                        'episode' => $episode,
                        'airDate' => $airDate,
                    ]);

                    return empty($indexSearchResult) ? [] : collect();
                }
                $showSql = $fragment;
            }
        }
        if (! $hasStrictTvSelector && ! empty($name) && $showSql === '' && empty($indexSearchResult)) {
            if (! empty($series) && (int) $series < 1900) {
                $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
                if (! empty($episode) && ! str_contains($episode, '/')) {
                    $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
                }
                if (empty($episode)) {
                    $name .= '*';
                }
            } elseif (! empty($airDate)) {
                $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate));
            }
        }
        $searchResult = $indexSearchResult; // Use index search result if we have it
        if (! $hasStrictTvSelector && empty($searchResult) && ! empty($name)) {
            $searchResult = Search::searchReleases(['searchname' => $name], $searchLimit);

            // Fall back to MySQL if search engine failed (only if enabled)
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
            }

            if (count($searchResult) === 0) {
                return collect();
            }
        }

        $skipSqlReleaseFilters = false;
        if ($searchResult !== [] && Search::isAvailable()) {
            $searchResult = $this->intersectReleaseIdsWithSearchFilters(
                $searchResult,
                $cat,
                'tv',
                $maxAge,
                $minSize,
                $excludedCategories
            );
            $skipSqlReleaseFilters = true;
            if ($searchResult === []) {
                return collect();
            }
        }

        $catSearchSql = $skipSqlReleaseFilters ? '' : Category::getCategorySearch($cat, 'tv');
        $whereSql = sprintf(
            'WHERE r.passwordstatus %s %s %s %s %s %s %s',
            $this->showPasswords(),
            $showSql,
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', array_map('intval', $searchResult)).')' : ''),
            $catSearchSql,
            ($skipSqlReleaseFilters || $maxAge <= 0 ? '' : sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge)),
            ($skipSqlReleaseFilters || $minSize <= 0 ? '' : sprintf('AND r.size >= %d', $minSize)),
            ($skipSqlReleaseFilters || empty($excludedCategories) ? '' : sprintf('AND r.categories_id NOT IN(%s)', implode(',', array_map('intval', $excludedCategories))))
        );
        $baseSql = sprintf(
            "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
                r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
                tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category,
                CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name
            FROM releases r
            LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
            LEFT JOIN categories c ON c.id = r.categories_id
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            %s",
            $whereSql
        );
        $sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset);
        $cacheKey = md5($this->getCacheVersion().'apiTvSearch:v2:'.$sql);
        $releases = Cache::get($cacheKey);
        if ($releases !== null) {
            return $releases;
        }
        $releases = Release::fromQuery($sql);
        if ($hasStrictTvSelector && $releases->isEmpty()) {
            $this->logStrictExternalLookupMiss('apiTvSearch release lookup', $siteIdArr, [
                'series' => $series,
                'episode' => $episode,
                'airDate' => $airDate,
            ]);
        }
        if ($releases->isNotEmpty()) {
            $releases[0]->_totalrows = $this->getPagerCount(
                preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql)
            );
        }
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
        Cache::put($cacheKey, $releases, $expiresAt);

        return $releases;
    }

    /**
     * Search anime releases.
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCategories
     * @return Collection|mixed
     */
    public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1, string $orderBy = 'posted_desc'): mixed
    {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        if ($anilistId > 0) {
            $resolved = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid');
            if ($resolved !== null) {
                $aniDbID = (int) $resolved;
            } elseif ($name === '' && (int) $aniDbID <= -1) {
                // AniList id was the only selector but nothing in DB maps to it — do not fall through unfiltered.
                return collect();
            }
        }

        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
        $searchResult = [];
        $anidbIdFilter = '';
        if ($name !== '') {
            if (Search::isAvailable()) {
                $anidbIds = Search::searchAnimeTitle($name, $searchLimit);
                if ($anidbIds === []) {
                    return collect();
                }
                $anidbIdFilter = 'AND r.anidbid IN ('.implode(',', array_map('intval', $anidbIds)).')';
            } else {
                $fuzzyResult = Search::searchReleasesWithFuzzy($name, $searchLimit);
                $searchResult = $fuzzyResult['ids'] ?? [];

                if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
                    $searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
                }

                if (count($searchResult) === 0) {
                    return collect();
                }
            }
        }

        $whereSql = sprintf(
            'WHERE r.passwordstatus %s
			%s %s %s %s %s %s',
            $this->showPasswords(),
            ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''),
            $anidbIdFilter,
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '',
            Category::getCategorySearch($cat, 'anime'),
            ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '')
        );
        $baseSql = sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.haspreview, r.jpgstatus,  cp.title AS parent_category, c.title AS sub_category,
				CONCAT(cp.title, ' > ', c.title) AS category_name,
				g.name AS group_name,
				rn.releases_id AS nfoid
			FROM releases r
			LEFT JOIN categories c ON c.id = r.categories_id
			LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
			LEFT JOIN usenet_groups g ON g.id = r.groups_id
			LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
			%s",
            $whereSql
        );
        $sql = sprintf(
            '%s
			ORDER BY %s %s
			LIMIT %d OFFSET %d',
            $baseSql,
            $orderField,
            $orderDir,
            $limit,
            $offset
        );
        $cacheKey = md5($this->getCacheVersion().$sql);
        $releases = Cache::get($cacheKey);
        if ($releases !== null) {
            return $releases;
        }
        $releases = Release::fromQuery($sql);
        if ($releases->isNotEmpty()) {
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
        }
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
        Cache::put($cacheKey, $releases, $expiresAt);

        return $releases;
    }

    /**
     * Movies search through API and site.
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCategories
     * @return Collection|mixed
     */
    public function moviesSearch(string $imDbId = '', int $tmDbId = -1, int $traktId = -1, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
    {
        [$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
        $searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
        $searchResult = [];

        // OPTIMIZATION: If we have external IDs, use the search index to find releases directly
        // This avoids expensive database JOINs by using indexed external ID fields in releases_rt
        $externalIds = [];
        if ($imDbId !== '' && imdb_id_is_valid($imDbId)) {
            $externalIds['imdbid'] = $imDbId;
        }
        if ($tmDbId !== -1 && $tmDbId > 0) {
            $externalIds['tmdbid'] = $tmDbId;
        }
        if ($traktId !== -1 && $traktId > 0) {
            $externalIds['traktid'] = $traktId;
        }
        $hasExternalIds = $externalIds !== [];

        // Use search index for external ID lookups (much faster than database JOINs)
        if ($hasExternalIds) {
            $searchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);

            if (config('app.debug') && ! empty($searchResult)) {
                Log::debug('moviesSearch: Found releases via search index by external IDs', [
                    'externalIds' => $externalIds,
                    'count' => count($searchResult),
                ]);
            }
        }

        // Only perform name searches when the request does not already target a specific external ID.
        if (! $hasExternalIds && ! empty($name)) {
            $searchResult = Search::searchReleases(['searchname' => $name], $searchLimit);

            // Fall back to MySQL if search engine returned no results (only if enabled)
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
            }

            // Only return empty if we were specifically searching by name but found nothing
            if (empty($searchResult)) {
                return collect();
            }
        }

        if (! empty($searchResult) && Search::isAvailable()) {
            $searchResult = $this->intersectReleaseIdsWithSearchFilters(
                $searchResult,
                $cat,
                'movies',
                $maxAge,
                $minSize,
                $excludedCategories
            );
            if ($searchResult === []) {
                return collect();
            }
        }

        // Build the base conditions for movie search
        // Note: we don't have MOVIE_ROOT constant that marks a parent category,
        // so we'll rely on the category search logic instead
        $conditions = [
            sprintf('r.passwordstatus %s', $this->showPasswords()),
        ];

        if (! empty($searchResult)) {
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
        }

        // When we have external IDs but no index results, fall back to database query
        // This handles the case where the index might be empty/out of sync
        $needsMovieJoin = false;
        if (empty($searchResult) && ! empty($externalIds)) {
            $needsMovieJoin = true;
            if ($imDbId !== '' && imdb_id_is_valid($imDbId)) {
                $conditions[] = sprintf('r.imdbid = %s', escapeString($imDbId));
            }
            if ($tmDbId !== -1 && $tmDbId > 0) {
                $conditions[] = sprintf('m.tmdbid = %d', $tmDbId);
            }
            if ($traktId !== -1 && $traktId > 0) {
                $conditions[] = sprintf('m.traktid = %d', $traktId);
            }
        }

        $applySqlMovieFilters = $searchResult === [] || ! Search::isAvailable();

        if ($applySqlMovieFilters) {
            if (! empty($excludedCategories)) {
                $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
            }

            $catQuery = Category::getCategorySearch($cat, 'movies');
            $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
            if (! empty($catQuery) && $catQuery !== '1=1') {
                $conditions[] = $catQuery;
            }
            if ($maxAge > 0) {
                $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
            }
            if ($minSize > 0) {
                $conditions[] = sprintf('r.size >= %d', $minSize);
            }
        }

        $whereSql = 'WHERE '.implode(' AND ', $conditions);

        // Only join movieinfo if we need to filter by tmdbid/traktid (database fallback)
        // When using search index, we already have the release IDs and don't need the join
        $joinSql = $needsMovieJoin ? 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid' : 'LEFT JOIN movieinfo m ON m.id = r.movieinfo_id';

        // Select only fields required by XML/API transformers
        $baseSql = sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id,
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
                    r.adddate,
                    %s
                    cp.title AS parent_category, c.title AS sub_category,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    g.name AS group_name
             FROM releases r
             INNER JOIN categories c ON c.id = r.categories_id
             INNER JOIN root_categories cp ON cp.id = c.root_categories_id
             %s
             LEFT JOIN usenet_groups g ON g.id = r.groups_id
             %s",
            'm.imdbid, m.tmdbid, m.traktid,',
            $joinSql,
            $whereSql
        );

        $sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset);
        $cacheKey = md5('moviesSearch:v2:'.$sql.serialize(func_get_args()));
        if (($releases = Cache::get($cacheKey)) !== null) {
            return $releases;
        }

        $releases = Release::fromQuery($sql);

        if ($hasExternalIds && $releases->isEmpty()) {
            $this->logStrictExternalLookupMiss('moviesSearch release lookup', $externalIds, [
                'name' => $name,
            ]);
        }

        if ($releases->isNotEmpty()) {
            // Optimize: Execute count query using same WHERE clause
            $countSql = sprintf(
                'SELECT COUNT(*) as count FROM releases r %s %s',
                $needsMovieJoin ? $joinSql : '',
                $whereSql
            );
            $countResult = DB::selectOne($countSql);
            $releases[0]->_totalrows = $countResult->count ?? 0;
        }

        Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));

        return $releases;
    }

    /**
     * @param  array<int, int>  $excludedCats
     * @return array<string, mixed>
     */
    public function searchSimilar(mixed $currentID, mixed $name, array $excludedCats = []): bool|array
    {
        // Get the category for the parent of this release.
        $ret = false;
        $currRow = Release::getCatByRelId($currentID);
        if ($currRow !== null) {
            $catRow = Category::find($currRow['categories_id']);
            $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null;

            if ($parentCat === null) {
                return $ret;
            }

            $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, (int) config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]);
            if (! $results) {
                return $ret;
            }

            $ret = [];
            foreach ($results as $res) {
                if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) {
                    $ret[] = $res;
                }
            }
        }

        return $ret;
    }

    /**
     * Perform index search using Elasticsearch or Manticore, with MySQL fallback
     *
     * @param  array<string, mixed>  $searchArr
     * @return array<string, mixed>
     */
    private function performIndexSearch(array $searchArr, int $limit): array
    {
        // Filter out -1 values and empty strings
        $searchFields = Arr::where($searchArr, static function ($value) {
            return $value !== -1 && $value !== '' && $value !== null;
        });

        if (empty($searchFields)) {
            if (config('app.debug')) {
                Log::debug('performIndexSearch: searchFields is empty after filtering', [
                    'original' => $searchArr,
                ]);
            }

            return [];
        }

        if (config('app.debug')) {
            Log::debug('performIndexSearch: starting search', [
                'search_driver' => config('search.default'),
                'searchFields' => $searchFields,
                'limit' => $limit,
            ]);
        }

        // Use the unified Search facade with fuzzy fallback
        // This will try exact search first, then fuzzy if no results
        $searchResult = Search::searchReleasesWithFuzzy($searchFields, $limit);
        $result = $searchResult['ids'] ?? [];

        if (config('app.debug')) {
            Log::debug('performIndexSearch: Search result', [
                'count' => count($result),
                'fuzzy_used' => $searchResult['fuzzy'] ?? false,
            ]);
        }

        // If search returned results, use them
        if (! empty($result)) {
            return $result;
        }

        // Fallback to MySQL LIKE search when search engine is unavailable (only if enabled)
        if (config('nntmux.mysql_search_fallback', false) === true) {
            if (config('app.debug')) {
                Log::debug('performIndexSearch: Falling back to MySQL search');
            }

            return $this->performMySQLSearch($searchFields, $limit);
        }

        return [];
    }

    /**
     * SQL predicates on tv_episodes (alias tve) for season / episode / airdate, matching
     * newznab-style tvsearch parameters. Empty array means no narrowing.
     *
     * @return list<string>
     */
    private function buildEpisodeJoinPredicates(string $series, string $episode, string $airDate): array
    {
        $predicates = [];
        if (! empty($series) && (int) $series < 1900) {
            $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
            $predicates[] = sprintf('tve.series = %d', $seriesNum);
            if (! empty($episode) && ! str_contains($episode, '/')) {
                $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
                $predicates[] = sprintf('tve.episode = %d', $episodeNum);
            }
        } elseif (! empty($airDate)) {
            $predicates[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
        }

        return $predicates;
    }

    /**
     * @param  array<string, mixed>  $siteIdArr
     * @return list<string>
     */
    private function buildApiTvSiteSqlArray(array $siteIdArr): array
    {
        $siteSQL = [];
        foreach ($siteIdArr as $column => $Id) {
            if ($Id > 0) {
                $siteSQL[] = sprintf('v.%s = %d', $column, $Id);
            }
        }

        return $siteSQL;
    }

    private function tvApiRequestTargetsEpisode(string $series, string $episode, string $airDate): bool
    {
        if ($airDate !== '') {
            return true;
        }
        if ($series !== '' && (int) $series < 1900) {
            return true;
        }
        if ($episode !== '' && ! str_contains($episode, '/')) {
            return true;
        }

        return false;
    }

    /**
     * Build the extra WHERE fragment for API TV search (r.tv_episodes_id / r.videos_id / tve.series).
     *
     * @param  list<string>  $siteSQL
     */
    private function lookupApiTvShowSqlFragment(
        array $siteSQL,
        string $series,
        string $episode,
        string $airDate,
        bool $strictEpisodeResolution
    ): ?string {
        $showQry = sprintf(
            "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1",
            implode(' OR ', $siteSQL),
            ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''),
            ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''),
            ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '')
        );
        $show = Release::fromQuery($showQry);
        if ($show->isEmpty()) {
            return null;
        }

        $showSql = '';
        if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') {
            $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
        } elseif (! empty($episode) && $show[0]->episodes !== '') {
            $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes);
        } elseif (! empty($series) && empty($episode)) {
            $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
        }
        if ((int) ($show[0]->video ?? 0) > 0) {
            $showSql .= ' AND r.videos_id = '.$show[0]->video;
        }

        if ($strictEpisodeResolution
            && $this->tvApiRequestTargetsEpisode($series, $episode, $airDate)
            && ($show[0]->episodes === '' || $show[0]->episodes === null)) {
            return null;
        }

        return $showSql;
    }

    /**
     * Search-backed pages still apply SQL filters and ordering after the index lookup,
     * so fetch a buffered candidate set without handing MySQL thousands of IDs.
     */
    private function determineSearchCandidateLimit(int $offset, int $limit): int
    {
        $pageSize = max(1, $limit);
        $requestedRows = max($pageSize, $offset + $pageSize);
        $bufferedRows = max(
            $requestedRows,
            $offset + ($pageSize * self::SEARCH_INDEX_BUFFER_PAGES),
            self::SEARCH_INDEX_MIN_CANDIDATES
        );

        return min($bufferedRows, self::SEARCH_INDEX_MAX_CANDIDATES);
    }

    /**
     * Narrow release IDs using the search index (category, age, size, password).
     *
     * @param  array<int|string, mixed>  $cat
     * @param  array<int|string, mixed>  $excludedCategories
     * @return list<int>
     */
    private function intersectReleaseIdsWithSearchFilters(
        array $releaseIds,
        array $cat,
        ?string $categorySearchType,
        int $maxAge,
        int $minSize,
        array $excludedCategories
    ): array {
        if ($releaseIds === [] || ! Search::isAvailable()) {
            return array_values(array_unique(array_map(static fn ($id): int => (int) $id, $releaseIds)));
        }

        $unique = array_values(array_unique(array_filter(
            array_map(static fn ($id): int => (int) $id, $releaseIds),
            static fn (int $id): bool => $id > 0
        )));

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

        $categoryIdsRaw = Category::getCategorySearch($cat, $categorySearchType, true);
        $categoryIds = null;
        if (is_array($categoryIdsRaw)) {
            $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw);
        } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
            $categoryIds = [(int) $categoryIdsRaw];
        }

        $maxMatches = (int) config('search.drivers.manticore.max_matches', 10000);
        $intersectLimit = min(max(count($unique), 1), max(1000, $maxMatches));

        $filtered = Search::searchReleasesFiltered([
            'phrases' => null,
            'release_ids' => $unique,
            'category_ids' => $categoryIds,
            'excluded_category_ids' => array_map(static fn ($id): int => (int) $id, $excludedCategories),
            'min_size' => $minSize,
            'max_age_days' => $maxAge,
            'password_allow_rar' => $this->passwordAllowRar(),
            'sort_field' => 'postdate_ts',
            'sort_dir' => 'desc',
            'try_fuzzy' => false,
        ], $intersectLimit, 0);

        return $filtered['ids'];
    }

    /**
     * Fallback MySQL search when full-text search engines are unavailable
     *
     * @param  array<string, mixed>  $searchFields
     * @return array<string, mixed>
     */
    private function performMySQLSearch(array $searchFields, int $limit): array
    {
        try {
            $query = Release::query()->select('id');

            foreach ($searchFields as $field => $value) {
                if (! empty($value)) {
                    // Split search terms and search for each
                    $terms = preg_split('/\s+/', trim($value));
                    foreach ($terms as $term) {
                        $term = trim($term);
                        if (strlen($term) >= 2) {
                            $query->where($field, 'LIKE', '%'.$term.'%');
                        }
                    }

                    if ($field === 'searchname' && count($terms) > 1) {
                        $normalizedPhrase = $this->normalizeMysqlFallbackPhrase($value);
                        if ($normalizedPhrase !== '') {
                            $query->whereRaw(
                                "LOWER(REPLACE(REPLACE(REPLACE($field, '.', ' '), '-', ' '), '_', ' ')) LIKE ?",
                                [$normalizedPhrase.'%']
                            );
                        }
                    }
                }
            }

            $results = $query->limit($limit)->pluck('id')->toArray();

            if (config('app.debug')) {
                Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
            }

            return $results;
        } catch (\Throwable $e) {
            Log::error('performMySQLSearch: MySQL fallback failed', [
                'error' => $e->getMessage(),
            ]);

            return [];
        }
    }

    private function normalizeMysqlFallbackPhrase(string $value): string
    {
        $normalized = strtolower(trim($value));
        $normalized = str_replace(['.', '-', '_'], ' ', $normalized);
        $normalized = preg_replace('/\s+/', ' ', $normalized);

        return trim((string) $normalized);
    }

    /**
     * @param  array<string, mixed>  $siteIdArr
     */
    private function hasTvLookupIdentifiers(array $siteIdArr): bool
    {
        foreach ($siteIdArr as $column => $id) {
            if ($column === 'imdb' && imdb_id_is_valid((string) $id)) {
                return true;
            }

            if ((int) $id > 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param  array<string, mixed>  $identifiers
     * @param  array<string, mixed>  $context
     */
    private function logStrictExternalLookupMiss(string $operation, array $identifiers, array $context = []): void
    {
        if (! config('app.debug')) {
            return;
        }

        Log::debug($operation.' returned no releases', array_merge([
            'identifiers' => $identifiers,
        ], $context));
    }

    /**
     * Build WHERE clause for search query
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     * @param  array<int, int>  $excludedCats
     * @param  array<string, mixed>  $searchResult
     */
    private function buildSearchWhereClause(
        array $searchResult,
        mixed $groupName,
        mixed $sizeFrom,
        mixed $sizeTo,
        mixed $daysNew,
        mixed $daysOld,
        int $maxAge,
        array $excludedCats,
        array $cat,
        int $minSize
    ): string {
        $conditions = [
            sprintf('r.passwordstatus %s', $this->showPasswords()),
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
        ];

        // Add optional conditions
        if ($maxAge > 0) {
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
        }

        if ((int) $groupName !== -1) {
            $groupId = UsenetGroup::getIDByName($groupName);
            if ($groupId) {
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
            }
        }

        // Size conditions
        $sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
        if (! empty($sizeConditions)) {
            $conditions = array_merge($conditions, $sizeConditions);
        }

        if ($minSize > 0) {
            $conditions[] = sprintf('r.size >= %d', $minSize);
        }

        // Category conditions - only add if not empty
        $catQuery = $this->buildCategoryCondition($cat);
        if (! empty($catQuery) && $catQuery !== '1=1') {
            $conditions[] = $catQuery;
        }

        // Date conditions
        if ((int) $daysNew !== -1) {
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
        }

        if ((int) $daysOld !== -1) {
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
        }

        // Excluded categories
        if (! empty($excludedCats)) {
            $excludedCatsClean = array_map('intval', $excludedCats);
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
        }

        return 'WHERE '.implode(' AND ', $conditions);
    }

    /**
     * Build size conditions for WHERE clause
     *
     * @return array<string, mixed>
     */
    private function buildSizeConditions(mixed $sizeFrom, mixed $sizeTo): array
    {
        $conditions = [];
        [$sizeMin, $sizeMax] = $this->resolveSizeRangeBounds($sizeFrom, $sizeTo);
        if ($sizeMin > 0) {
            $conditions[] = sprintf('r.size > %d', $sizeMin);
        }
        if ($sizeMax > 0) {
            $conditions[] = sprintf('r.size < %d', $sizeMax);
        }

        return $conditions;
    }

    /**
     * Resolve UI size bucket inputs into byte bounds used by SQL/index filters.
     *
     * @return array{0:int,1:int}
     */
    private function resolveSizeRangeBounds(mixed $sizeFrom, mixed $sizeTo): array
    {
        $sizeRange = [
            1 => 1,
            2 => 2.5,
            3 => 5,
            4 => 10,
            5 => 20,
            6 => 30,
            7 => 40,
            8 => 80,
            9 => 160,
            10 => 320,
            11 => 640,
        ];

        $sizeMin = 0;
        $sizeMax = 0;
        if (array_key_exists($sizeFrom, $sizeRange)) {
            $sizeMin = 104857600 * (int) $sizeRange[$sizeFrom];
        }
        if (array_key_exists($sizeTo, $sizeRange)) {
            $sizeMax = 104857600 * (int) $sizeRange[$sizeTo];
        }

        return [$sizeMin, $sizeMax];
    }

    /**
     * Resolve day-based age inputs to unix timestamps used by indexed filtering.
     *
     * @return array{0:int,1:int}
     */
    private function resolvePostdateBounds(mixed $daysNew, mixed $daysOld): array
    {
        $minDate = 0;
        $maxDate = 0;

        if ((int) $daysOld !== -1 && (int) $daysOld >= 0) {
            $minDate = time() - ((int) $daysOld * 86400);
        }
        if ((int) $daysNew !== -1 && (int) $daysNew >= 0) {
            $maxDate = time() - ((int) $daysNew * 86400);
        }

        return [$minDate, $maxDate];
    }

    /**
     * Resolve leaf category IDs for search/browse filters (expands root categories).
     *
     * @param  array<int|string, mixed>  $cat
     * @param  array<int|string, mixed>  $excludedCats
     * @return array<int>|null null = no category filter; [] = filter matches nothing
     */
    private function resolveSearchCategoryIds(array $cat, array $excludedCats): ?array
    {
        $categoryIdsRaw = Category::getCategorySearch($cat, null, true);

        $categoryIds = null;
        if (is_array($categoryIdsRaw)) {
            $categoryIds = array_values(array_filter(
                array_map(static fn ($id): int => (int) $id, $categoryIdsRaw),
                static fn (int $id): bool => $id > 0
            ));
        } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
            $categoryIds = [(int) $categoryIdsRaw];
        }

        if (! is_array($categoryIds) || $categoryIds === []) {
            return $categoryIds;
        }

        if ($excludedCats === []) {
            return $categoryIds;
        }

        $excluded = array_map(static fn ($id): int => (int) $id, $excludedCats);

        return array_values(array_diff($categoryIds, $excluded));
    }

    /**
     * Build category condition for web search (basic and advanced).
     *
     * @param  array<int|string, mixed>  $cat  Category IDs (list or associative)
     */
    private function buildCategoryCondition(array $cat): string
    {
        $catSearch = Category::getCategorySearch($cat);
        // Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
        $catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catSearch));

        // Don't return '1=1' as it's not needed
        return ($catSearch === '1=1') ? '' : $catSearch;
    }

    /**
     * Build base SQL for web search query.
     * Selects only columns needed by search/index.blade.php and browse/index.blade.php views.
     */
    private function buildSearchBaseSql(string $whereSql): string
    {
        return sprintf(
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size,
                    r.totalpart, r.fromname, r.grabs, r.comments, r.adddate,
                    r.videos_id, r.haspreview, r.jpgstatus, r.nfostatus,
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
                    MAX(df.failed) AS failed_count,
                    COUNT(DISTINCT rr.id) AS total_report_count,
                    COUNT(DISTINCT CASE WHEN rr.status IN ('pending', 'reviewed', 'resolved') THEN rr.id ELSE NULL END) AS report_count,
                    GROUP_CONCAT(DISTINCT CASE WHEN rr.status IN ('pending', 'reviewed', 'resolved') THEN rr.reason ELSE NULL END SEPARATOR ', ') AS report_reasons,
                    GROUP_CONCAT(DISTINCT rr.reason SEPARATOR ', ') AS all_report_reasons,
                    SUBSTRING_INDEX(GROUP_CONCAT(rr.reason ORDER BY rr.created_at DESC SEPARATOR ','), ',', 1) AS latest_report_reason,
                    SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(rr.description, ''), 'No additional report details were provided.') ORDER BY rr.created_at DESC SEPARATOR ' || '), ' || ', 1) AS latest_report_description,
                    COUNT(DISTINCT CASE WHEN rr.response IS NOT NULL AND rr.response != '' AND rr.response_is_public = 1 THEN rr.id ELSE NULL END) AS report_response_count,
                    MAX(CASE WHEN rr.response IS NOT NULL AND rr.response != '' AND rr.response_is_public = 1 THEN rr.responded_at ELSE NULL END) AS latest_report_response_at,
                    g.name AS group_name,
                    rn.releases_id AS nfoid,
                    re.releases_id AS reid,
                    m.imdbid
            FROM releases r
            LEFT OUTER JOIN video_data re ON re.releases_id = r.id
            LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id
            LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
            LEFT JOIN categories c ON c.id = r.categories_id
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
            LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
            LEFT OUTER JOIN release_reports rr ON rr.releases_id = r.id
            %s
            GROUP BY r.id",
            $whereSql
        );
    }

    /**
     * Build the lightweight paged ID query used by compatibility tests and targeted callers.
     *
     * @param  array{0: string, 1: string}  $orderBy
     */
    protected function buildSearchPageIdsSql(string $whereSql, array $orderBy, int $limit, int $offset): string
    {
        return sprintf(
            'SELECT r.id FROM releases r %s ORDER BY r.%s %s LIMIT %d OFFSET %d',
            $whereSql,
            $orderBy[0],
            $orderBy[1],
            $limit,
            $offset,
        );
    }

    /**
     * Get a lightweight releases count directly from the releases table for compatibility callers.
     */
    protected function getReleasesCountForWhere(string $whereSql): int
    {
        return $this->getPagerCount(sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql));
    }

    /**
     * Get the passworded releases clause.
     */
    public function showPasswords(): string
    {
        $show = (int) Settings::settingValue('showpasswordedrelease');
        $setting = $show;

        return match ($setting) {
            1 => '<= '.self::PASSWD_RAR,
            default => '<= '.self::PASSWD_NONE,
        };
    }

    /**
     * When true, search/browse includes RAR-passworded releases (passwordstatus <= 1).
     */
    private function passwordAllowRar(): bool
    {
        return (int) Settings::settingValue('showpasswordedrelease') === 1;
    }

    /**
     * Use to order releases on site.
     *
     * @param  array<string, mixed>  $orderBy
     * @return array<string, mixed>
     */
    public function getBrowseOrder(array|string $orderBy): array
    {
        $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy));
        $orderField = match ($orderArr[0]) {
            'cat' => 'categories_id',
            'name' => 'searchname',
            'size' => 'size',
            'files' => 'totalpart',
            'stats' => 'grabs',
            'added' => 'adddate',
            default => 'postdate',
        };

        return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; // @phpstan-ignore return.type
    }

    private function browseOrderToIndexSortField(string $orderField): string
    {
        return match ($orderField) {
            'postdate' => 'postdate_ts',
            'adddate' => 'adddate_ts',
            'categories_id' => 'categories_id',
            'searchname' => 'searchname',
            'size' => 'size',
            'totalpart' => 'totalpart',
            'grabs' => 'grabs',
            default => 'postdate_ts',
        };
    }

    private function getCacheVersion(): int
    {
        return (int) Cache::get(self::CACHE_VERSION_KEY, 1);
    }

    /**
     * Get the count of releases for pager.
     *
     * @param  string  $query  The query to get the count from.
     */
    protected function getPagerCount(string $query): int
    {
        $maxResults = (int) config('nntmux.max_pager_results');
        $cacheExpiry = config('nntmux.cache_expiry_short');

        // Generate cache key from original query
        $cacheKey = 'pager_count_'.md5($query);

        // Check cache first
        $count = Cache::get($cacheKey);
        if ($count !== null) {
            return (int) $count;
        }

        // Check if this is already a COUNT query
        if (preg_match('/SELECT\s+COUNT\s*\(/is', $query)) {
            // It's already a COUNT query, just execute it
            try {
                $result = DB::select($query);
                if (isset($result[0])) {
                    // Handle different possible column names
                    $count = $result[0]->count ?? $result[0]->total ?? 0;
                    // Check for COUNT(*) result without alias
                    if ($count === 0) {
                        foreach ($result[0] as $value) {
                            $count = (int) $value;
                            break;
                        }
                    }
                } else {
                    $count = 0;
                }

                // Cap the count at max results to prevent extremely large pagers
                if ($maxResults > 0 && $count > $maxResults) {
                    $count = $maxResults;
                }

                // Cache the result
                Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));

                return $count;
            } catch (\Exception $e) {
                return 0;
            }
        }

        // For regular SELECT queries, optimize for counting
        $countQuery = $query;

        // Remove ORDER BY clause (not needed for COUNT)
        $countQuery = preg_replace('/ORDER\s+BY\s+[^)]+$/is', '', $countQuery);

        $hadGroupBy = preg_match('/GROUP\s+BY/is', $countQuery);

        // Remove GROUP BY if it's only grouping by r.id
        $countQuery = preg_replace('/GROUP\s+BY\s+r\.id\s*$/is', '', $countQuery);

        // Check if query has DISTINCT in SELECT
        $hasDistinct = preg_match('/SELECT\s+DISTINCT/is', $countQuery);

        // Replace SELECT clause with COUNT
        if ($hasDistinct || $hadGroupBy || preg_match('/GROUP\s+BY/is', $countQuery)) {
            // For queries with DISTINCT or GROUP BY, count distinct r.id
            $countQuery = preg_replace(
                '/SELECT\s+.+?\s+FROM/is',
                'SELECT COUNT(DISTINCT r.id) as count FROM',
                $countQuery
            );
        } else {
            // For simple queries, use COUNT(*)
            $countQuery = preg_replace(
                '/SELECT\s+.+?\s+FROM/is',
                'SELECT COUNT(*) as count FROM',
                $countQuery
            );
        }

        // Remove LIMIT/OFFSET from the count query
        $countQuery = preg_replace('/LIMIT\s+\d+(\s+OFFSET\s+\d+)?$/is', '', $countQuery);

        try {
            // Execute the count query directly
            $result = DB::select($countQuery);
            $count = isset($result[0]) ? (int) $result[0]->count : 0;

            // Cap the count at max results to prevent extremely large pagers
            if ($maxResults > 0 && $count > $maxResults) {
                $count = $maxResults;
            }

            // Cache the result
            Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));

            return $count;
        } catch (\Exception $e) {
            // If optimization fails, try a simpler approach
            try {
                // Extract the core table and WHERE conditions
                if (preg_match('/FROM\s+releases\s+r\s+(.+?)(?:ORDER\s+BY|LIMIT|$)/is', $query, $matches)) {
                    $conditions = $matches[1];
                    // Remove JOINs but keep WHERE
                    $conditions = preg_replace('/(?:LEFT\s+|INNER\s+)?(?:OUTER\s+)?JOIN\s+.+?(?=WHERE|LEFT|INNER|JOIN|$)/is', '', $conditions);

                    $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM releases r %s', trim($conditions));

                    $result = DB::select($fallbackQuery);
                    $count = isset($result[0]) ? (int) $result[0]->count : 0;

                    // Cap the count at max results
                    if ($maxResults > 0 && $count > $maxResults) {
                        $count = $maxResults;
                    }

                    Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));

                    return $count;
                }
            } catch (\Exception $fallbackException) {
                // Log the error for debugging
                Log::error('getPagerCount failed', [
                    'query' => $query,
                    'error' => $fallbackException->getMessage(),
                ]);
            }

            return 0;
        }
    }
}
