<?php

declare(strict_types=1);

namespace Blacklight\processing\post;

use App\Models\AnidbEpisode;
use App\Models\AnidbInfo;
use App\Models\AnidbTitle;
use App\Models\Category;
use App\Models\Release;
use App\Models\Settings;
use Blacklight\ColorCLI;
use Blacklight\PopulateAniDB as PaDb;

class AniDB
{
    private const PROC_EXTFAIL = -1; // Release Anime title/episode # could not be extracted from searchname

    private const PROC_NOMATCH = -2; // AniDB ID was not found in anidb table using extracted title/episode #

    /** @var bool Whether to echo messages to CLI */
    public bool $echooutput;

    public PaDb $padb;

    /** @var int number of AniDB releases to process */
    private int $aniqty;

    /** @var int|null The status of the release being processed */
    private ?int $status;

    protected ColorCLI $colorCli;

    /**
     * Simple cache of looked up titles -> anidbid to reduce repeat queries within one run.
     *
     * @var array<string,int>
     */
    private array $titleCache = [];

    /**
     * @throws \Exception
     */
    public function __construct()
    {
        $this->echooutput = (bool) config('nntmux.echocli');
        $this->padb = new PaDb;
        $this->colorCli = new ColorCLI;

        $quantity = (int) Settings::settingValue('maxanidbprocessed');
        $this->aniqty = $quantity > 0 ? $quantity : 100;
        $this->status = null;
    }

    /**
     * Queues anime releases for processing.
     *
     * @param  string  $groupID  (Optional) ID of a group to work on.
     * @param  string  $guidChar  (Optional) First letter of a release GUID to use to get work.
     *
     * @throws \Exception
     */
    public function processAnimeReleases(string $groupID = '', string $guidChar = ''): void
    {
        $query = Release::query()
            ->whereNull('anidbid')
            ->where('categories_id', Category::TV_ANIME);

        if ($guidChar !== '') {
            $query->where('leftguid', 'like', $guidChar.'%');
        }

        if ($groupID !== '') {
            $query->where('groups_id', $groupID);
        }

        $results = $query->orderByDesc('postdate')
            ->limit($this->aniqty)
            ->get();

        if ($results->count() > 0) {
            // Honor AniDB API cooldown before starting a batch.
            $this->doRandomSleep();

            foreach ($results as $release) {
                $matched = $this->matchAnimeRelease($release);
                if ($matched === false) {
                    // Persist status so we do not keep retrying hopeless releases immediately.
                    Release::query()->where('id', $release->id)->update(['anidbid' => $this->status]);
                }
            }
        } else {
            $this->colorCli->info('No anidb releases to process.');
        }
    }

    /**
     * Retrieves episode info if present.
     */
    private function checkAniDBInfo(int $anidbId, int $episode = -1): array
    {
        $q = AnidbEpisode::query()->where(['anidbid' => $anidbId]);
        if ($episode >= 0) {
            $q->where('episode_no', $episode);
        }
        $result = $q->select(['anidbid', 'episode_no', 'airdate', 'episode_title'])->first();

        return $result ? $result->toArray() : [];
    }

    /**
     * Sleeps between 10 and 15 seconds for AniDB API cooldown.
     *
     * @throws \Exception
     */
    private function doRandomSleep(): void
    {
        sleep(random_int(10, 15));
    }

    /**
     * Extracts anime title and episode info from release searchname.
     * Returns ['title' => string, 'epno' => int] on success else empty array.
     */
    private function extractTitleEpisode(string $cleanName = ''): array
    {
        // Normalize common separators
        $s = str_replace(['_', '.'], ' ', $cleanName);
        $s = preg_replace('/\s+/', ' ', (string) $s);
        $s = trim((string) $s);

        // Strip leading group tags like [Group]
        $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s);
        $s = trim((string) $s);

        $title = '';
        $ep = -1;

        // 1) Look for " - NNN" first
        if (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) {
            $ep = (int) $m[1][0];
            $title = substr($s, 0, (int) $m[0][1]);
        }

        // 2) If not found, look for " E0*NNN"
        if ($ep < 0 && preg_match('/\sE0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
            $ep = (int) $m[1][0];
            $title = substr($s, 0, (int) $m[0][1]);
        }

        // 3) Keywords Movie/OVA/Complete Series
        if ($ep < 0 && preg_match('/\b(Movie|OVA|Complete Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
            $kind = strtolower($m[1][0]);
            $ep = match ($kind) {
                'movie', 'ova' => 1,
                'complete series' => 0,
                default => -1,
            };
            $title = substr($s, 0, (int) $m[0][1]);
        }

        // 4) BD/resolution releases: pick title before next bracket token
        if ($ep < 0 && preg_match('/\[(?:BD|\d{3,4}[ipx])\]/i', $s, $m, PREG_OFFSET_CAPTURE)) {
            $ep = 1;
            $title = substr($s, 0, (int) $m[0][1]);
        }

        $title = $this->cleanTitle((string) $title);

        if ($title === '' || $ep < 0) {
            $this->status = self::PROC_EXTFAIL;

            return [];
        }

        return ['title' => $title, 'epno' => $ep];
    }

    /**
     * Strip stray separators or tokens accidentally captured at the end of title.
     */
    private function cleanTitle(string $title): string
    {
        // Remove trailing "- 123", trailing dash, trailing E/E0/Episode tokens, and trailing Vol.
        $patterns = [
            '/\s*-\s*\d+\s*$/i',
            '/\s*-\s*$/',
            '/\s+E(?:pi(?:sode)?)?\s*0?\s*$/i',
            '/\s+Vol\.?\s*$/i',
        ];
        $title = preg_replace($patterns, '', $title);

        return trim((string) $title);
    }

    /**
     * Retrieve AniDB title row (id + title) by name attempt exact then partial.
     */
    private function getAnidbByName(string $searchName = ''): ?AnidbTitle
    {
        if ($searchName === '') {
            return null;
        }

        $key = strtolower($searchName);
        if (isset($this->titleCache[$key])) {
            return AnidbTitle::query()->select(['anidbid', 'title'])->where('anidbid', $this->titleCache[$key])->first();
        }

        // Exact (case-insensitive) first
        $exact = AnidbTitle::query()->whereRaw('LOWER(title) = ?', [$key])->select(['anidbid', 'title'])->first();
        if ($exact) {
            $this->titleCache[$key] = (int) $exact->anidbid;

            return $exact;
        }

        // Partial fallback
        $partial = AnidbTitle::query()->where('title', 'like', '%'.$searchName.'%')->select(['anidbid', 'title'])->first();
        if ($partial) {
            $this->titleCache[$key] = (int) $partial->anidbid;
        }

        return $partial;
    }

    /**
     * Matches the anime release to AniDB Info; fetches remotely if needed.
     *
     * @throws \Exception
     */
    private function matchAnimeRelease($release): bool
    {
        $matched = false;
        $type = 'Local';

        $cleanArr = $this->extractTitleEpisode((string) $release->searchname);
        if (empty($cleanArr)) {
            return false;
        }

        $title = $cleanArr['title'];
        $epno = $cleanArr['epno'];

        // We ignore episode number 0 (Complete Series) for matching episodes but still link the title.
        if ($this->echooutput) {
            $this->colorCli->info('Looking Up: Title: '.$title.' Episode: '.$epno);
        }

        $anidbTitle = $this->getAnidbByName($title);
        if (! $anidbTitle) {
            // Try with spaces replaced by % for broader matching
            $tmpName = preg_replace('/\s+/', '%', $title);
            $anidbTitle = $this->getAnidbByName($tmpName);
        }

        if ($anidbTitle && is_numeric($anidbTitle->anidbid) && (int) $anidbTitle->anidbid > 0) {
            $anidbId = (int) $anidbTitle->anidbid;

            $episodeInfo = ($epno > 0) ? $this->checkAniDBInfo($anidbId, $epno) : [];

            if (empty($episodeInfo) && $this->shouldUpdateInfo($anidbId)) {
                // Fetch remote info
                $this->padb->populateTable('info', $anidbId);
                $this->doRandomSleep();
                $episodeInfo = ($epno > 0) ? $this->checkAniDBInfo($anidbId, $epno) : [];
                $type = 'Remote';
            }

            $episodeTitle = $episodeInfo['episode_title'] ?? 'Unknown';
            $this->updateRelease($anidbId, (int) $release->id);

            if ($this->echooutput) {
                // Break chained calls into sequential ones; methods return void and are not chainable.
                $this->colorCli->headerOver('Matched '.$type.' AniDB ID: ');
                $this->colorCli->primary((string) $anidbId);
                $this->colorCli->alternateOver('   Title: ');
                $this->colorCli->primary($anidbTitle->title);
                $this->colorCli->alternateOver('   Episode #: ');
                $this->colorCli->primary((string) $epno);
                $this->colorCli->alternateOver('   Episode Title: ');
                $this->colorCli->primary($episodeTitle);
            }
            $matched = true;
        } else {
            $this->status = self::PROC_NOMATCH;
        }

        return $matched;
    }

    private function updateRelease(int $anidbId, int $relId): void
    {
        Release::query()->where('id', $relId)->update(['anidbid' => $anidbId]);
    }

    /**
     * Determine if we should attempt a remote AniDB info fetch (missing or stale > 1 week).
     */
    private function shouldUpdateInfo(int $anidbId): bool
    {
        $info = AnidbInfo::query()->where('anidbid', $anidbId)->first(['updated']);
        if ($info === null) {
            return true; // no info yet
        }

        return $info->updated < now()->subWeek();
    }
}
