<?php

declare(strict_types=1);

use App\Models\Country as CountryModel;
use App\Models\Release;
use App\Services\Nzb\NzbService;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use STS\ZipStream\Builder;
use STS\ZipStream\Facades\Zip as ZipStream;
use Symfony\Component\Process\Process;

if (! function_exists('getRawHtml')) {
    /**
     * @param  array<string, mixed>|null  $postData
     * @return array<string, mixed>|false|string
     */
    function getRawHtml(string $url, bool|string|null $cookie = false, ?array $postData = null): array|false|string
    {
        // Check if this is an adult site that needs age verification
        $adultSites = [
            'adultdvdempire.com',
            'adultdvdmarketplace.com',
            'aebn.net',
            'hotmovies.com',
            'popporn.com',
        ];

        $isAdultSite = false;
        foreach ($adultSites as $site) {
            if (stripos($url, $site) !== false) {
                $isAdultSite = true;
                break;
            }
        }

        // Standard method
        $cookieJar = new CookieJar;
        $client = new Client(['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]);
        if ($cookie !== false && $cookie !== null && $cookie !== '') {
            $cookie = $cookieJar->setCookie(SetCookie::fromString((string) $cookie));
            $client = new Client(['cookies' => $cookie, 'headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]);
        }
        try {
            $response = $client->get($url)->getBody()->getContents();
            $jsonResponse = json_decode($response, true);
            if (json_last_error() === JSON_ERROR_NONE) {
                $response = $jsonResponse;
            }
        } catch (RequestException $e) {
            if (function_exists('config') && config('app.debug') === true) {
                Log::error($e->getMessage());
            }
            $response = false;
        } catch (RuntimeException $e) {
            if (function_exists('config') && config('app.debug') === true) {
                Log::error($e->getMessage());
            }
            $response = false;
        }

        return $response;
    }
}

if (! function_exists('makeFieldLinks')) {
    /**
     * @param  array<string, mixed>|object  $data  Array or model (ArrayAccess)
     *
     * @throws Exception
     */
    function makeFieldLinks(array|object $data, string $field, string $type): string
    {
        // Check if field exists and is not empty
        if (! isset($data[$field]) || empty($data[$field])) {
            return '';
        }

        $tmpArr = explode(', ', $data[$field]);
        $newArr = [];
        $i = 0;
        foreach ($tmpArr as $ta) {
            if (trim($ta) === '') {
                continue;
            }
            if ($i > 7) {
                break;
            }
            $escaped = e($ta);
            $newArr[] = '<a href="'.url('/'.ucfirst($type).'?'.$field.'='.urlencode($ta)).'" title="'.$escaped.'">'.$escaped.'</a>';
            $i++;
        }

        return implode(', ', $newArr);
    }
}

if (! function_exists('getUserBrowseOrder')) {
    /**
     * @return array{0: string, 1: string}
     */
    function getUserBrowseOrder(string $orderBy): array
    {
        $order = ($orderBy === '' ? 'username_desc' : $orderBy);
        $orderArr = explode('_', $order);
        $orderField = match ($orderArr[0]) {
            'email' => 'email',
            'host' => 'host',
            'createdat' => 'created_at',
            'lastlogin' => 'lastlogin',
            'apiaccess' => 'apiaccess',
            'apirequests' => 'apirequests',
            'grabs' => 'grabs',
            'roles_id' => 'users_role_id',
            'rolechangedate' => 'rolechangedate',
            default => 'username',
        };
        $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc';

        return [$orderField, $orderSort];
    }
}

if (! function_exists('getUserBrowseOrdering')) {
    /**
     * @return array<int, string>
     */
    function getUserBrowseOrdering(): array
    {
        return [
            'username_asc',
            'username_desc',
            'email_asc',
            'email_desc',
            'host_asc',
            'host_desc',
            'createdat_asc',
            'createdat_desc',
            'lastlogin_asc',
            'lastlogin_desc',
            'apiaccess_asc',
            'apiaccess_desc',
            'apirequests_asc',
            'apirequests_desc',
            'grabs_asc',
            'grabs_desc',
            'role_asc',
            'role_desc',
            'rolechangedate_asc',
            'rolechangedate_desc',
            'verification_asc',
            'verification_desc',
        ];
    }
}

if (! function_exists('getSimilarName')) {
    function getSimilarName(string $name): string
    {
        return implode(' ', \array_slice(str_word_count(str_replace(['.', '_', '-'], ' ', $name), 2), 0, 2));
    }
}

if (! function_exists('human_filesize')) {
    function human_filesize(int|float|string $bytes, int $decimals = 0): string
    {
        $size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
        $factor = (int) floor((\strlen((string) $bytes) - 1) / 3);

        return round((float) sprintf("%.{$decimals}f", $bytes / (1024 ** $factor)), $decimals).@$size[$factor];
    }
}

if (! function_exists('bcdechex')) {
    function bcdechex(string $dec): string
    {
        $hex = '';
        do {
            $last = bcmod($dec, '16');
            $hex = dechex((int) $last).$hex;
            $dec = bcdiv(bcsub($dec, $last), '16');
        } while ($dec > 0);

        return $hex;
    }
}

if (! function_exists('runCmd')) {
    /**
     * Run CLI command.
     */
    function runCmd(string $command, bool $debug = false): string
    {
        if ($debug) {
            echo '-Running Command: '.PHP_EOL.'   '.$command.PHP_EOL;
        }

        $process = Process::fromShellCommandline('exec '.$command);
        $process->setTimeout(1800);
        $process->run();
        $output = $process->getOutput();

        if ($debug) {
            echo '-Command Output: '.PHP_EOL.'   '.$output.PHP_EOL;
        }

        return $output;
    }
}

if (! function_exists('imdb_id_is_valid')) {
    /**
     * Check whether an IMDB ID string is a valid, non-zero numeric identifier.
     * IMDB IDs are stored as plain digit strings (e.g. "0137523", "1375666", "14688458")
     * exactly as provided by upstream sources — no padding, no trimming.
     */
    function imdb_id_is_valid(int|string|null $id): bool
    {
        if ($id === null || $id === '' || $id === 0) {
            return false;
        }
        $s = preg_replace('/\D/', '', trim((string) $id));

        return $s !== '' && $s !== '0' && ((int) $s) !== 0;
    }
}

if (! function_exists('imdb_id_pending_values')) {
    /**
     * IMDb values that mean "queued/pending lookup" in legacy release rows.
     *
     * @return list<string>
     */
    function imdb_id_pending_values(): array
    {
        return ['0', '0000000', '00000000'];
    }
}

if (! function_exists('imdb_id_needs_lookup')) {
    /**
     * Determine whether a release should still be queued for IMDb lookup.
     *
     * NULL means never attempted. Legacy zero-like sentinels also mean pending.
     * Empty string means lookup already attempted and failed, so do not requeue.
     */
    function imdb_id_needs_lookup(int|string|null $id): bool
    {
        if ($id === null) {
            return true;
        }

        $value = trim((string) $id);

        return in_array($value, imdb_id_pending_values(), true);
    }
}

if (! function_exists('imdb_id_needs_lookup_sql')) {
    /**
     * Build a reusable SQL predicate for releases that still need IMDb lookup.
     */
    function imdb_id_needs_lookup_sql(string $column = 'imdbid'): string
    {
        $pendingValues = implode(', ', array_map(static fn (string $value): string => escapeString($value), imdb_id_pending_values()));

        return sprintf('(%s IS NULL OR %s IN (%s))', $column, $column, $pendingValues);
    }
}

if (! function_exists('imdb_id_pad')) {
    /**
     * @deprecated Use imdb_id_is_valid() instead. This function exists only for backward
     *             compatibility during the transition period and will be removed.
     */
    function imdb_id_pad(int|string|null $id): string
    {
        if ($id === null || $id === '') {
            return '00000000';
        }
        $s = (string) $id;
        $s = trim($s);
        if ($s === '' || $s === '0' || $s === '0000000' || $s === '00000000') {
            return '00000000';
        }

        return str_pad((string) (int) $s, 8, '0', STR_PAD_LEFT);
    }
}

if (! function_exists('escapeString')) {

    function escapeString(mixed $string): string
    {
        return DB::connection()->getPdo()->quote($string);
    }
}

if (! function_exists('regex_display_value')) {
    /**
     * Decode entity-encoded regex text at the presentation boundary.
     *
     * Blade should still render the returned value with escaped {{ }} output so regexes that
     * contain HTML-looking text remain safe while displaying named groups and quotes readably.
     */
    function regex_display_value(mixed $value): string
    {
        return html_entity_decode((string) ($value ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
}

if (! function_exists('realDuration')) {

    function realDuration(mixed $milliseconds): string
    {
        $time = round($milliseconds / 1000);

        return sprintf('%02dh:%02dm:%02ds', floor($time / 3600), floor($time / 60 % 60), $time % 60);
    }
}

if (! function_exists('is_it_json')) {
    /**
     * @throws JsonException
     */
    function is_it_json(mixed $isIt): bool
    {
        if (is_array($isIt)) {
            return false;
        }
        json_decode($isIt, true, 512, JSON_THROW_ON_ERROR);

        return json_last_error() === JSON_ERROR_NONE;
    }
}

if (! function_exists('getStreamingZip')) {
    /**
     * @param  list<string>  $guids
     *
     * @throws Exception
     */
    function getStreamingZip(array $guids = []): Builder
    {
        $nzb = app(NzbService::class);
        $zipped = ZipStream::create(now()->format('Ymdhis').'.zip');
        foreach ($guids as $guid) {
            $nzbPath = $nzb->nzbPath($guid);
            if ($nzbPath) {
                $nzbContents = unzipGzipFile($nzbPath);
                if ($nzbContents) {
                    $filename = $guid;
                    $r = Release::query()->where('guid', $guid)->first();
                    if ($r) {
                        $filename = $r['searchname'];
                    }
                    $zipped->addRaw($nzbContents, $filename.'.nzb');
                }
            }
        }

        return $zipped;
    }
}

if (! function_exists('release_flag')) {
    // Function inspired by c0r3@newznabforums adds country flags on the browse page.
    /**
     * @param  string  $text  Text to match against.
     * @param  string  $page  Type of page. browse or search.
     */
    function release_flag(string $text, string $page): bool|string
    {
        $code = $language = '';

        switch (true) {
            case stripos($text, 'Arabic') !== false:
                $code = 'PK';
                $language = 'Arabic';
                break;
            case stripos($text, 'Cantonese') !== false:
                $code = 'TW';
                $language = 'Cantonese';
                break;
            case preg_match('/Chinese|Mandarin|\bc[hn]\b/i', $text):
                $code = 'CN';
                $language = 'Chinese';
                break;
            case preg_match('/\bCzech\b/i', $text):
                $code = 'CZ';
                $language = 'Czech';
                break;
            case stripos($text, 'Danish') !== false:
                $code = 'DK';
                $language = 'Danish';
                break;
            case stripos($text, 'Finnish') !== false:
                $code = 'FI';
                $language = 'Finnish';
                break;
            case preg_match('/Flemish|\b(Dutch|nl)\b|NlSub/i', $text):
                $code = 'NL';
                $language = 'Dutch';
                break;
            case preg_match('/French|Vostfr|Multi/i', $text):
                $code = 'FR';
                $language = 'French';
                break;
            case preg_match('/German(bed)?|\bger\b/i', $text):
                $code = 'DE';
                $language = 'German';
                break;
            case preg_match('/\bGreek\b/i', $text):
                $code = 'GR';
                $language = 'Greek';
                break;
            case preg_match('/Hebrew|Yiddish/i', $text):
                $code = 'IL';
                $language = 'Hebrew';
                break;
            case preg_match('/\bHindi\b/i', $text):
                $code = 'IN';
                $language = 'Hindi';
                break;
            case preg_match('/Hungarian|\bhun\b/i', $text):
                $code = 'HU';
                $language = 'Hungarian';
                break;
            case preg_match('/Italian|\bita\b/i', $text):
                $code = 'IT';
                $language = 'Italian';
                break;
            case preg_match('/Japanese|\bjp\b/i', $text):
                $code = 'JP';
                $language = 'Japanese';
                break;
            case preg_match('/Korean|\bkr\b/i', $text):
                $code = 'KR';
                $language = 'Korean';
                break;
            case stripos($text, 'Norwegian') !== false:
                $code = 'NO';
                $language = 'Norwegian';
                break;
            case stripos($text, 'Polish') !== false:
                $code = 'PL';
                $language = 'Polish';
                break;
            case stripos($text, 'Portuguese') !== false:
                $code = 'PT';
                $language = 'Portugese';
                break;
            case stripos($text, 'Romanian') !== false:
                $code = 'RO';
                $language = 'Romanian';
                break;
            case stripos($text, 'Spanish') !== false:
                $code = 'ES';
                $language = 'Spanish';
                break;
            case preg_match('/Swe(dish|sub)/i', $text):
                $code = 'SE';
                $language = 'Swedish';
                break;
            case preg_match('/Tagalog|Filipino/i', $text):
                $code = 'PH';
                $language = 'Tagalog|Filipino';
                break;
            case preg_match('/\bThai\b/i', $text):
                $code = 'TH';
                $language = 'Thai';
                break;
            case stripos($text, 'Turkish') !== false:
                $code = 'TR';
                $language = 'Turkish';
                break;
            case stripos($text, 'Russian') !== false:
                $code = 'RU';
                $language = 'Russian';
                break;
            case stripos($text, 'Vietnamese') !== false:
                $code = 'VN';
                $language = 'Vietnamese';
                break;
        }

        if ($code !== '' && $page === 'browse') {
            return '<img title="'.$language.'" alt="'.$language.'" src="'.asset('/assets/images/flags/'.$code.'.png').'"/>';
        }

        if ($page === 'search') {
            if ($code === '') {
                return false;
            }

            return $code;
        }

        return '';
    }
}

if (! function_exists('resolveImageAssetFilename')) {
    /**
     * Resolve the real on-disk extension before publishing an image URL.
     *
     * @param  list<string>  $alternateBasenames
     */
    function resolveImageAssetFilename(string $type, string $basename, array $alternateBasenames = []): ?string
    {
        if (preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $type) !== 1
            || preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $basename) !== 1) {
            return null;
        }

        $configuredRoot = config('nntmux_settings.covers_path');
        $roots = array_values(array_unique(array_filter([
            is_string($configuredRoot) && $configuredRoot !== '' ? rtrim($configuredRoot, '/\\') : null,
            storage_path('covers'),
            public_path('covers'),
        ], static fn (mixed $root): bool => is_string($root) && $root !== '')));
        $basenames = array_values(array_unique([$basename, ...$alternateBasenames]));

        foreach (['webp', 'jpg', 'jpeg'] as $extension) {
            foreach ($basenames as $candidateBasename) {
                if (preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $candidateBasename) !== 1) {
                    continue;
                }

                foreach ($roots as $root) {
                    $path = $root.DIRECTORY_SEPARATOR.$type.DIRECTORY_SEPARATOR.$candidateBasename.'.'.$extension;
                    if (is_file($path) && is_readable($path)) {
                        return $candidateBasename.'.'.$extension;
                    }
                }
            }
        }

        return null;
    }
}

if (! function_exists('getImageAssetUrl')) {
    /**
     * Return a URL containing the extension of the image that actually exists.
     *
     * @param  list<string>  $alternateBasenames
     */
    function getImageAssetUrl(string $type, string $basename, ?string $fallbackUrl = null, array $alternateBasenames = []): ?string
    {
        $filename = resolveImageAssetFilename($type, $basename, $alternateBasenames);

        return $filename === null ? $fallbackUrl : url("/covers/{$type}/{$filename}");
    }
}

if (! function_exists('getReleaseCover')) {
    /**
     * Get the cover image URL for a release based on its type and ID
     *
     * @param  object|array<string, mixed>  $release  The release object or array
     */
    function getReleaseCover(object|array $release): string
    {
        $coverType = null;
        $coverId = null;

        // Helper function to get value from object or array
        $getValue = function ($data, $key) {
            if (is_array($data)) {
                return $data[$key] ?? null;
            } elseif (is_object($data)) {
                return $data->$key ?? null;
            }

            return null;
        };

        $isPositiveId = static function (mixed $value): bool {
            return is_numeric($value) && (int) $value > 0;
        };

        // Determine cover type and ID based on category
        $imdbid = $getValue($release, 'imdbid');
        $videos_id = $getValue($release, 'videos_id');
        $musicinfo_id = $getValue($release, 'musicinfo_id');
        $consoleinfo_id = $getValue($release, 'consoleinfo_id');
        $bookinfo_id = $getValue($release, 'bookinfo_id');
        $gamesinfo_id = $getValue($release, 'gamesinfo_id');
        $anidbid = $getValue($release, 'anidbid');

        if (! empty($imdbid) && imdb_id_is_valid($imdbid)) {
            $coverType = 'movies';
            $coverId = (string) $imdbid;
        } elseif ($isPositiveId($videos_id)) {
            $coverType = 'tvshows';
            $coverId = $videos_id;
        } elseif ($isPositiveId($musicinfo_id)) {
            $coverType = 'music';
            $coverId = $musicinfo_id;
        } elseif ($isPositiveId($consoleinfo_id)) {
            $coverType = 'console';
            $coverId = $consoleinfo_id;
        } elseif ($isPositiveId($bookinfo_id)) {
            $coverType = 'book';
            $coverId = $bookinfo_id;
        } elseif ($isPositiveId($gamesinfo_id)) {
            $coverType = 'games';
            $coverId = $gamesinfo_id;
        } elseif ($isPositiveId($anidbid)) {
            $coverType = 'anime';
            $coverId = $anidbid;
        }

        if ($coverType && $coverId) {
            $basename = in_array($coverType, ['movies', 'anime'], true)
                ? $coverId.'-cover'
                : (string) $coverId;
            $alternateBasenames = $coverType === 'anime' ? [(string) $coverId] : [];

            return getImageAssetUrl(
                $coverType,
                $basename,
                asset('assets/images/no-cover.png'),
                $alternateBasenames
            ) ?? asset('assets/images/no-cover.png');
        }

        // Return placeholder image if no cover type/ID found
        return asset('assets/images/no-cover.png');
    }
}

if (! function_exists('sanitize')) {
    /**
     * @param  array<string, mixed>  $doNotSanitize
     * @param  array<string, mixed>  $phrases
     */
    function sanitize(array|string $phrases, array $doNotSanitize = []): string
    {
        if (! is_array($phrases)) {
            $wordArray = explode(' ', str_replace('.', ' ', $phrases));
        } else {
            $wordArray = $phrases;
        }

        $keywords = [];
        $tempWords = [];
        foreach ($wordArray as $words) {
            $words = preg_split('/\s+/', $words);
            foreach ($words as $st) {
                if (Str::startsWith($st, ['!', '+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) {
                    $str = $st;
                } elseif (Str::endsWith($st, ['+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) {
                    $str = $st;
                } else {
                    $str = in_array($st, $doNotSanitize, true)
                        ? $st
                        : (preg_replace('/([+\-=&|><!(){}\[\]^"~*?:\\\\\\/])/', '\\\\$1', $st) ?? $st);
                }
                $tempWords[] = $str;
            }

            $keywords = $tempWords;
        }

        return implode(' ', $keywords);
    }
}

if (! function_exists('formatBytes')) {
    /**
     * Format bytes into human-readable file size.
     */
    function formatBytes(int|float|null $bytes = 0): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $bytes = max((int) ($bytes ?? 0), 0);
        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
        $pow = min($pow, count($units) - 1);

        $bytes /= pow(1024, $pow);

        return round($bytes, 2).' '.$units[$pow];
    }
}

if (! function_exists('formatDate')) {
    /**
     * Format a date as Y-m-d (house date style).
     */
    function formatDate(Carbon|DateTimeInterface|string|null $date): string
    {
        if ($date === null || $date === '') {
            return '';
        }

        $date = $date instanceof DateTimeInterface ? $date : Carbon::parse($date);

        return $date->format('Y-m-d');
    }
}

if (! function_exists('formatDateTime')) {
    /**
     * Format a date as Y-m-d H:i (house datetime style).
     */
    function formatDateTime(Carbon|DateTimeInterface|string|null $date): string
    {
        if ($date === null || $date === '') {
            return '';
        }

        $date = $date instanceof DateTimeInterface ? $date : Carbon::parse($date);

        return $date->format('Y-m-d H:i');
    }
}

if (! function_exists('formatDateTimeSeconds')) {
    /**
     * Format a date as Y-m-d H:i:s (house datetime style with seconds).
     */
    function formatDateTimeSeconds(Carbon|DateTimeInterface|string|null $date): string
    {
        if ($date === null || $date === '') {
            return '';
        }

        $date = $date instanceof DateTimeInterface ? $date : Carbon::parse($date);

        return $date->format('Y-m-d H:i:s');
    }
}

if (! function_exists('csp_nonce')) {
    /**
     * Generate a CSP nonce for inline scripts
     * Uses the Laravel container to ensure the nonce is truly unique per request,
     * avoiding issues with static variables in PHP-FPM environments.
     */
    function csp_nonce(): string
    {
        // Use Laravel's container to store the nonce per-request
        // This ensures the same nonce is used in both the CSP header and Blade templates
        if (! app()->bound('csp_nonce')) {
            app()->instance('csp_nonce', base64_encode(random_bytes(16)));
        }

        return app('csp_nonce');
    }
}

if (! function_exists('userDate')) {
    /**
     * Format a date/time string according to the authenticated user's timezone
     *
     * @param  string|null  $date  The date to format
     * @param  string  $format  The format string (default: 'M d, Y H:i')
     * @return string The formatted date in user's timezone
     */
    function userDate(?string $date, string $format = 'M d, Y H:i'): string
    {
        if (empty($date)) {
            return '';
        }

        try {
            // Parse the date in the app's timezone (which should be UTC)
            // If dates in DB are stored in server timezone, they'll be parsed correctly
            $appTimezone = config('app.timezone', 'UTC');
            $carbon = Carbon::parse($date, $appTimezone);

            // If user is authenticated and has a timezone set, convert to it
            if (Auth::check() && Auth::user()->timezone) {
                $carbon->setTimezone(Auth::user()->timezone);
            }

            return $carbon->format($format);
        } catch (Exception $e) {
            return $date;
        }
    }
}

if (! function_exists('userDateDiffForHumans')) {
    /**
     * Format a date/time string as a human-readable diff according to the authenticated user's timezone
     *
     * @param  string|null  $date  The date to format
     * @return string The formatted date diff in user's timezone
     */
    function userDateDiffForHumans(?string $date): string
    {
        if (empty($date)) {
            return '';
        }

        try {
            // Parse the date in the app's timezone (which should be UTC)
            // If dates in DB are stored in server timezone, they'll be parsed correctly
            $appTimezone = config('app.timezone', 'UTC');
            $carbon = Carbon::parse($date, $appTimezone);

            // If user is authenticated and has a timezone set, convert to it
            if (Auth::check() && Auth::user()->timezone) {
                $carbon->setTimezone(Auth::user()->timezone);
            }

            return $carbon->diffForHumans();
        } catch (Exception $e) {
            return $date;
        }
    }
}

if (! function_exists('getAvailableTimezones')) {
    /**
     * Get a list of available timezones grouped by region
     *
     * @return array<string, mixed> Array of timezones grouped by region
     */
    function getAvailableTimezones(): array
    {
        $timezones = [];
        $regions = [
            'Africa' => DateTimeZone::AFRICA,
            'America' => DateTimeZone::AMERICA,
            'Antarctica' => DateTimeZone::ANTARCTICA,
            'Arctic' => DateTimeZone::ARCTIC,
            'Asia' => DateTimeZone::ASIA,
            'Atlantic' => DateTimeZone::ATLANTIC,
            'Australia' => DateTimeZone::AUSTRALIA,
            'Europe' => DateTimeZone::EUROPE,
            'Indian' => DateTimeZone::INDIAN,
            'Pacific' => DateTimeZone::PACIFIC,
        ];

        foreach ($regions as $name => $region) {
            $timezones[$name] = DateTimeZone::listIdentifiers($region);
        }

        return $timezones;
    }
}

if (! function_exists('countryCode')) {
    /**
     * Get a country code for a country name.
     */
    function countryCode(string $country): string
    {
        if (\strlen($country) > 2) {
            return CountryModel::query()
                ->where('full_name', $country)
                ->orWhere('name', $country)
                ->value('iso_3166_2') ?? '';
        }

        return '';
    }
}

if (! function_exists('unzipGzipFile')) {
    /**
     * Unzip a gzip file, return the output. Return false on error / empty.
     */
    function unzipGzipFile(string $filePath): false|string
    {
        $string = '';
        $gzFile = @gzopen($filePath, 'rb');
        if ($gzFile) {
            while (! gzeof($gzFile)) {
                $temp = gzread($gzFile, 1024);
                // Check for empty string.
                // Without this the loop would be endless and consume 100% CPU.
                // Do not set $string empty here, as the data might still be good.
                if (! $temp) {
                    break;
                }
                $string .= $temp;
            }
            gzclose($gzFile);
        }

        return $string === '' ? false : $string;
    }
}

if (! function_exists('streamSslContextOptions')) {
    /**
     * Creates an array to be used with stream_context_create() to verify openssl certificates
     * when connecting to a tls or ssl connection when using stream functions (fopen/file_get_contents/etc).
     *
     * @param  bool  $forceIgnore  Force ignoring of verification (useful for self-signed certs in development).
     * @return array<string, mixed> Stream context options for SSL/TLS connections
     */
    function streamSslContextOptions(bool $forceIgnore = false): array
    {
        $cafile = config('nntmux_ssl.ssl_cafile', '');
        $capath = config('nntmux_ssl.ssl_capath', '');
        $hasCustomCerts = $cafile !== '' || $capath !== '';

        // Base options - either insecure (no certs configured) or configured
        $options = [
            'verify_peer' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_peer', false),
            'verify_peer_name' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_host', false),
            'allow_self_signed' => $forceIgnore ? true : config('nntmux_ssl.ssl_allow_self_signed', true),
        ];

        // Add certificate paths if configured
        if ($hasCustomCerts && ! $forceIgnore) {
            if ($cafile !== '') {
                $options['cafile'] = $cafile;
            }
            if ($capath !== '') {
                $options['capath'] = $capath;
            }
        }

        // Additional security options for modern TLS
        $options['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
        $options['disable_compression'] = true; // Prevent CRIME attacks
        $options['SNI_enabled'] = true; // Enable Server Name Indication

        // If we set the transport to tls and the server falls back to ssl,
        // the context options would be for tls and would not apply to ssl,
        // so set both tls and ssl context in case the server does not support tls.
        return ['tls' => $options, 'ssl' => $options];
    }
}

if (! function_exists('getCoverURL')) {
    /**
     * Get the relative cover URL using the extension that exists on disk.
     *
     * @param  array<string, mixed>  $options
     */
    function getCoverURL(array $options = []): string
    {
        $defaults = [
            'id' => null,
            'suffix' => '-cover.webp',
            'type' => '',
        ];
        $options += $defaults;
        $fileSpecTemplate = '%s/%s%s';
        $fileSpec = '';

        if (! empty($options['id']) && \in_array(
            $options['type'],
            ['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'tvshows', 'video', 'xxx'],
            false
        )
        ) {
            $suffix = preg_replace('/\.(?:webp|jpe?g)$/i', '', (string) $options['suffix']);
            $basename = (string) $options['id'].(is_string($suffix) ? $suffix : '');
            $filename = resolveImageAssetFilename((string) $options['type'], $basename);
            $fileSpec = $filename === null
                ? sprintf($fileSpecTemplate, $options['type'], 'no-cover', '.jpg')
                : $options['type'].'/'.$filename;
        }

        return $fileSpec;
    }
}

if (! function_exists('fileInfo')) {
    /**
     * Return file type/info using magic numbers.
     * Try using `file` program where available, fallback to using PHP's finfo class.
     *
     * @param  string  $path  Path to the file / folder to check.
     * @return string File info. Empty string on failure.
     *
     * @throws Exception
     */
    function fileInfo(string $path): string
    {
        $magicPath = config('nntmux_settings.magic_file_path');
        if ($magicPath !== null && Illuminate\Support\Facades\Process::run('which file')->successful()) {
            $magicSwitch = " -m $magicPath";
            $output = runCmd('file'.$magicSwitch.' -b "'.$path.'"');
        } else {
            $fileInfo = $magicPath === null ? finfo_open(FILEINFO_RAW) : finfo_open(FILEINFO_RAW, $magicPath);

            $output = finfo_file($fileInfo, $path);
            if (empty($output)) {
                $output = '';
            }
            finfo_close($fileInfo);
        }

        return $output;
    }
}

if (! function_exists('cp437toUTF')) {
    /**
     * Convert Code page 437 chars to UTF.
     */
    function cp437toUTF(string $string): string
    {
        return iconv('CP437', 'UTF-8//IGNORE//TRANSLIT', $string);
    }
}

if (! function_exists('imdb_trailers')) {
    /**
     * Fetches an embeddable video to a IMDB trailer from http://www.traileraddict.com.
     */
    function imdb_trailers(mixed $imdbID): string
    {
        $xml = getRawHtml('https://api.traileraddict.com/?imdb='.$imdbID);
        if ($xml !== false && preg_match('#(v\.traileraddict\.com/\d+)#i', $xml, $html)) {
            return 'https://'.$html[1];
        }

        return '';
    }
}

if (! function_exists('apiErrorDetails')) {
    /**
     * @return array{code:int,message:string,status:int,header:string}
     */
    function apiErrorDetails(int $errorCode = 900, string $errorText = ''): array
    {
        [$defaultText, $status, $errorHeader] = match ($errorCode) {
            100 => ['Incorrect user credentials', 401, 'HTTP/1.1 401 Unauthorized'],
            101 => ['Account suspended', 403, 'HTTP/1.1 403 Forbidden'],
            102 => ['Insufficient privileges/not authorized', 401, 'HTTP/1.1 401 Unauthorized'],
            103 => ['Registration denied', 403, 'HTTP/1.1 403 Forbidden'],
            104 => ['Registrations are closed', 403, 'HTTP/1.1 403 Forbidden'],
            105 => ['Invalid registration (Email Address Taken)', 403, 'HTTP/1.1 403 Forbidden'],
            106 => ['Invalid registration (Email Address Bad Format)', 403, 'HTTP/1.1 403 Forbidden'],
            107 => ['Registration Failed (Data error)', 400, 'HTTP/1.1 400 Bad Request'],
            200 => ['Missing parameter', 400, 'HTTP/1.1 400 Bad Request'],
            201 => ['Incorrect parameter', 400, 'HTTP/1.1 400 Bad Request'],
            202 => ['No such function', 404, 'HTTP/1.1 404 Not Found'],
            203 => ['Function not available', 400, 'HTTP/1.1 400 Bad Request'],
            300 => ['No such item', 404, 'HTTP/1.1 404 Not Found'],
            310 => ['Item already exists', 409, 'HTTP/1.1 409 Conflict'],
            500 => ['Request limit reached', 429, 'HTTP/1.1 429 Too Many Requests'],
            501 => ['Download limit reached', 429, 'HTTP/1.1 429 Too Many Requests'],
            600 => ['Failed to load NZB', 400, 'HTTP/1.1 400 Bad Request'],
            601 => ['NZB is duplicate', 409, 'HTTP/1.1 409 Conflict'],
            602 => ['NZB is for a non-existent group', 400, 'HTTP/1.1 400 Bad Request'],
            603 => ['NZB failed to write to disk', 500, 'HTTP/1.1 500 Internal Server Error'],
            910 => ['API disabled', 401, 'HTTP/1.1 401 Unauthorized'],
            default => ['Unknown error', 400, 'HTTP/1.1 400 Bad Request'],
        };

        return [
            'code' => $errorCode,
            'message' => $errorText !== '' ? $errorText : $defaultText,
            'status' => $status,
            'header' => $errorHeader,
        ];
    }
}

if (! function_exists('apiJsonError')) {
    function apiJsonError(int $errorCode = 900, string $errorText = ''): mixed
    {
        $error = apiErrorDetails($errorCode, $errorText);

        return response()
            ->json(['error' => $error['message']], $error['status'])
            ->header('X-NNTmux', 'API ERROR ['.$error['code'].'] '.$error['message']);
    }
}

if (! function_exists('showApiError')) {
    function showApiError(int $errorCode = 900, string $errorText = ''): mixed
    {
        $error = apiErrorDetails($errorCode, $errorText);
        $errorText = $error['message'];

        $response =
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".
            '<error code="'.$errorCode.'" description="'.$errorText."\"/>\n";

        return response($response, $error['status'])->header('Content-type', 'text/xml')->header('Content-Length', (string) strlen($response))->header('X-NNTmux', 'API ERROR ['.$errorCode.'] '.$errorText)->header('HTTP/1.1', $error['header']);
    }
}

if (! function_exists('getRange')) {
    function getRange(string $tableName): LengthAwarePaginator // @phpstan-ignore missingType.generics
    {
        $range = DB::table($tableName);

        return $range->orderByDesc('created_at')->paginate(config('nntmux.items_per_page'));
    }
}

if (! function_exists('isValidNewznabNzb')) {
    /**
     * Validate if the content is a valid Newznab NZB file.
     *
     * @param  string  $content  The NZB file content
     * @return bool True if valid NZB, false otherwise
     */
    function isValidNewznabNzb(string $content): bool
    {
        // Check if content starts with valid XML declaration and contains NZB namespace
        if (empty($content)) {
            return false;
        }

        // Try to load as XML
        libxml_use_internal_errors(true);
        $xml = @simplexml_load_string($content);

        if ($xml === false) {
            libxml_clear_errors();

            return false;
        }

        // Check for NZB namespace or nzb root element
        $namespaces = $xml->getNamespaces(true);
        $hasNzbNamespace = isset($namespaces['']) && str_contains($namespaces[''], 'nzb');
        $isNzbRoot = strtolower($xml->getName()) === 'nzb';

        libxml_clear_errors();

        return $hasNzbNamespace || $isNzbRoot;
    }
}

if (! function_exists('cli')) {
    /**
     * Get the CLI helper instance for colored console output.
     *
     * This replaces the Blacklight\ColorCLI class with a helper function approach.
     * Usage: cli()->primary('message'), cli()->error('message'), etc.
     *
     * @return object The CLI helper object with all output methods
     */
    function cli(): object
    {
        static $instance = null;

        if ($instance === null) {
            $instance = new class
            {
                public int $lastMessageLength = 0;

                public function debug(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-gray'>{$str}</div>");
                }

                public function info(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-magenta'>{$str}</div>");
                }

                public function notice(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-blue'>{$str}</div>");
                }

                public function warning(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-yellow'>{$str}</div>");
                }

                public function error(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-red'>{$str}</div>");
                }

                public function primary(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-green'>{$str}</div>");
                }

                public function header(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-yellow'>{$str}</div>");
                }

                public function alternate(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-magenta font-bold'>{$str}</div>");
                }

                public function tmuxOrange(string $str, bool $newline = false): void
                {
                    if ($newline) {
                        \Termwind\render('<br/>');
                    }
                    \Termwind\render("<div class='text-yellow font-bold'>{$str}</div>");
                }

                public function primaryOver(string $str): void
                {
                    echo "\033[32m{$str}\033[0m";
                }

                public function headerOver(string $str): void
                {
                    echo "\033[33m{$str}\033[0m";
                }

                public function alternateOver(string $str): void
                {
                    echo "\033[1;35m{$str}\033[0m";
                }

                public function warningOver(string $str): void
                {
                    echo "\033[31m{$str}\033[0m";
                }

                public function progress(): object
                {
                    return new class
                    {
                        private int $total = 0;

                        private int $current = 0;

                        private string $label = '';

                        public function total(int $total): self
                        {
                            $this->total = $total;

                            return $this;
                        }

                        public function current(int $current, ?string $label = null): void
                        {
                            $this->current = $current;
                            if ($label !== null) {
                                $this->label = $label;
                            }
                            $this->display();
                        }

                        public function advance(int $step = 1, ?string $label = null): void
                        {
                            $this->current += $step;
                            if ($label !== null) {
                                $this->label = $label;
                            }
                            $this->display();
                        }

                        private function display(): void
                        {
                            $percentage = $this->total > 0 ? (int) (($this->current / $this->total) * 100) : 0;
                            $bar = str_repeat('=', (int) ($percentage / 2));
                            $spaces = str_repeat(' ', 50 - (int) ($percentage / 2));
                            $label = $this->label ? " {$this->label}" : '';
                            echo "\r[{$bar}{$spaces}] {$percentage}%{$label}";
                            if ($this->current >= $this->total) {
                                echo "\n";
                            }
                        }
                    };
                }

                /**
                 * Apply ANSI color code to a string and return it (does not render)
                 */
                public function ansiString(string $string, string $color): string
                {
                    $colors = [
                        'black' => '0;30',
                        'red' => '0;31',
                        'green' => '0;32',
                        'yellow' => '0;33',
                        'blue' => '0;34',
                        'magenta' => '0;35',
                        'cyan' => '0;36',
                        'white' => '0;37',
                    ];

                    $code = $colors[$color] ?? '0;37';

                    return "\033[{$code}m{$string}\033[0m";
                }

                public function overWriteHeader(string $message, bool $reset = false): void
                {
                    if ($reset) {
                        $this->lastMessageLength = 0;
                    }

                    echo str_repeat(\chr(8), $this->lastMessageLength);
                    echo str_repeat(' ', $this->lastMessageLength);
                    echo str_repeat(\chr(8), $this->lastMessageLength);

                    $this->lastMessageLength = \strlen($message);
                    $this->headerOver($message);
                }

                public function overWritePrimary(string $message, bool $reset = false): void
                {
                    if ($reset) {
                        $this->lastMessageLength = 0;
                    }

                    echo str_repeat(\chr(8), $this->lastMessageLength);
                    echo str_repeat(' ', $this->lastMessageLength);
                    echo str_repeat(\chr(8), $this->lastMessageLength);

                    $this->lastMessageLength = \strlen($message);
                    $this->primaryOver($message);
                }

                public function overWrite(string $message, bool $reset = false): void
                {
                    if ($reset) {
                        $this->lastMessageLength = 0;
                    }

                    echo str_repeat(\chr(8), $this->lastMessageLength);
                    echo str_repeat(' ', $this->lastMessageLength);
                    echo str_repeat(\chr(8), $this->lastMessageLength);

                    $this->lastMessageLength = \strlen($message);
                    echo $message;
                }

                public function appendWrite(string $message): void
                {
                    echo $message;
                    $this->lastMessageLength += \strlen($message);
                }

                public function percentString(int $cur, int $total): string
                {
                    $percent = 100 * $cur / $total;
                    $formatString = '% '.\strlen((string) $total).'d/%d (% 2d%%)';

                    return sprintf($formatString, $cur, $total, $percent);
                }

                public function percentString2(int $first, int $last, int $total): string
                {
                    $percent1 = 100 * ($first - 1) / $total;
                    $percent2 = 100 * $last / $total;
                    $formatString = '% '.\strlen((string) $total).'d-% '.\strlen((string) $total).'d/%d (% 2d%%-% 3d%%)';

                    return sprintf($formatString, $first, $last, $total, $percent1, $percent2);
                }

                /**
                 * Convert seconds to minutes or hours, appending type at the end.
                 */
                public function convertTime(int $seconds): string
                {
                    if ($seconds > 3600) {
                        return round($seconds / 3600).' hour(s)';
                    }
                    if ($seconds > 60) {
                        return round($seconds / 60).' minute(s)';
                    }

                    return $seconds.' second(s)';
                }

                /**
                 * Convert seconds to a timer, 00h:00m:00s.
                 */
                public function convertTimer(int $seconds): string
                {
                    return ' '.sprintf('%02dh:%02dm:%02ds', floor($seconds / 3600), floor(($seconds / 60) % 60), $seconds % 60);
                }

                /**
                 * Sleep for x seconds, printing timer on screen.
                 */
                public function showSleep(int $seconds): void
                {
                    for ($i = $seconds; $i >= 0; $i--) {
                        $this->overWriteHeader('Sleeping for '.$i.' seconds.');
                        sleep(1);
                    }
                    echo PHP_EOL;
                }
            };
        }

        return $instance;
    }
}
