<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Release;
use App\Models\User;
use App\Models\UserDownload;
use App\Models\UsersRelease;
use App\Services\Nzb\NzbService;
use App\Support\FilenameSanitizer;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use STS\ZipStream\Builder;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;

class GetNzbController extends BasePageController
{
    public const string REQUEST_USER_ATTRIBUTE = 'nntmux.getnzb.user';

    private const int BUFFER_SIZE = 1000000;

    private const string NZB_SUFFIX = '.nzb';

    /**
     * Download NZB file(s) for authenticated users
     *
     * @throws Exception
     */
    public function getNzb(Request $request, ?string $guid = null): JsonResponse|Response|StreamedResponse|Builder
    {
        // Normalize guid parameter
        $this->normalizeGuidParameter($request, $guid);

        // Authenticate and authorize user
        $userData = $this->authenticateUser($request);
        if (! \is_array($userData)) {
            return $userData; // Return error response
        }

        ['uid' => $uid, 'userName' => $userName, 'maxDownloads' => $maxDownloads, 'rssToken' => $rssToken] = $userData;

        // Check download limits
        $downloadLimitError = $this->checkDownloadLimit($uid, $maxDownloads);
        if ($downloadLimitError !== null) {
            return $downloadLimitError;
        }

        // Validate and sanitize ID parameter
        $releaseId = $this->validateAndSanitizeId($request);
        if (! \is_string($releaseId)) {
            return $releaseId; // Return error response
        }

        // Handle zip download request
        if ($this->isZipRequest($request)) {
            return $this->handleZipDownload($request, $uid, $userName, $maxDownloads, $releaseId);
        }

        // Handle single NZB download
        return $this->handleSingleNzbDownload($request, $uid, $rssToken, $releaseId);
    }

    /**
     * Normalize the guid parameter into the request
     */
    private function normalizeGuidParameter(Request $request, ?string $guid): void
    {
        if ($guid !== null && ! $request->has('id')) {
            $request->merge(['id' => $guid]);
        }
    }

    /**
     * Authenticate user via session or RSS token
     *
     * @return array<string, mixed>|Response
     */
    private function authenticateUser(Request $request): array|Response
    {
        $resolvedUser = $request->attributes->get(self::REQUEST_USER_ATTRIBUTE);
        if ($resolvedUser instanceof User) {
            return $this->getUserDataFromResolvedUser($resolvedUser);
        }

        // Try session authentication first
        if ($request->user()) {
            return $this->getUserDataFromSession();
        }

        // Try RSS token authentication
        return $this->getUserDataFromRssToken($request);
    }

    /**
     * Get user data from a user resolved by an upstream API controller.
     *
     * @return array<string, mixed>|Response
     */
    private function getUserDataFromResolvedUser(User $user): array|Response
    {
        if (! $user->hasVerifiedEmail()) {
            return showApiError(100);
        }

        if ($user->is_disabled || $user->hasRole('Disabled')) {
            return showApiError(101);
        }

        $user->loadMissing('role');

        return [
            'uid' => $user->id,
            'userName' => $user->username,
            'maxDownloads' => $user->role->downloadrequests,
            'rssToken' => $user->api_token,
        ];
    }

    /**
     * Get user data from authenticated session
     *
     * @return array<string, mixed>|Response
     */
    private function getUserDataFromSession(): array|Response
    {
        if (! $this->userdata->hasVerifiedEmail()) {
            return showApiError(100);
        }

        if ($this->userdata->is_disabled || $this->userdata->hasRole('Disabled')) {
            return showApiError(101);
        }

        return [
            'uid' => $this->userdata->id,
            'userName' => $this->userdata->username,
            'maxDownloads' => $this->userdata->role->downloadrequests,
            'rssToken' => $this->userdata->api_token,
        ];
    }

    /**
     * Get user data from RSS token
     *
     * @return array<string, mixed>|Response
     */
    private function getUserDataFromRssToken(Request $request): array|Response
    {
        if ($request->missing('r')) {
            return showApiError(200);
        }

        $user = User::findVerifiedByApiToken((string) $request->input('r'));
        if (! $user) {
            return showApiError(100);
        }

        if ($user->is_disabled || $user->hasRole('Disabled')) {
            return showApiError(101);
        }

        return [
            'uid' => $user->id,
            'userName' => $user->username,
            'maxDownloads' => $user->role->downloadrequests,
            'rssToken' => $user->api_token,
        ];
    }

    /**
     * Check if user has exceeded download limits
     *
     *
     * @throws Exception
     */
    private function checkDownloadLimit(int $uid, int $maxDownloads): ?Response
    {
        $requests = UserDownload::getDownloadRequests($uid);
        if ($requests > $maxDownloads) {
            return showApiError(501);
        }

        return null;
    }

    /**
     * Validate and sanitize the release ID parameter
     */
    private function validateAndSanitizeId(Request $request): string|Response
    {
        $id = $this->scalarInput($request, 'id');

        if ($id === '') {
            return showApiError(200, 'Parameter id is required');
        }

        // Remove .nzb suffix if present
        $sanitizedId = str_ireplace(self::NZB_SUFFIX, '', $id);
        $request->merge(['id' => $sanitizedId]);

        return $sanitizedId;
    }

    /**
     * Check if this is a zip download request
     */
    private function isZipRequest(Request $request): bool
    {
        return $request->has('zip') && $request->input('zip') === '1';
    }

    /**
     * Handle zip download of multiple releases
     *
     * @throws Exception
     */
    private function handleZipDownload(
        Request $request,
        int $uid,
        string $userName,
        int $maxDownloads,
        string $releaseId
    ): JsonResponse|Response|StreamedResponse|Builder {
        $guids = explode(',', $releaseId);
        $guidCount = \count($guids);

        // Check if zip download would exceed limits
        $requests = UserDownload::getDownloadRequests($uid);
        if ($requests + $guidCount > $maxDownloads) {
            return showApiError(501);
        }

        $zip = getStreamingZip($guids);
        if ($zip === '') { // @phpstan-ignore identical.alwaysFalse
            return response()->json(['message' => 'Unable to create .zip file'], 404);
        }

        // Update statistics
        $this->updateZipDownloadStatistics($request, $uid, $guids);

        Log::channel('zipped')->info("User {$userName} downloaded zipped files from site with IP: {$request->ip()}");

        return $zip;
    }

    /**
     * Update statistics for zip downloads (batched to avoid N+1 queries).
     *
     * @param  list<string>  $guids
     */
    private function updateZipDownloadStatistics(Request $request, int $uid, array $guids): void
    {
        if ($guids === []) {
            return;
        }

        $guidCount = \count($guids);
        User::incrementGrabs($uid, $guidCount);

        // Resolve guids to release ids in one query for batch operations
        $releaseIds = Release::query()->whereIn('guid', $guids)->pluck('id')->all();

        Release::updateGrabsByGuids($guids);
        UserDownload::addDownloadRequestsBatch($uid, $releaseIds);

        $shouldDeleteFromCart = $request->has('del') && (int) $request->input('del') === 1;
        if ($shouldDeleteFromCart && $releaseIds !== []) {
            UsersRelease::delCartByUserAndReleases($uid, $releaseIds);
        }
    }

    /**
     * Handle single NZB file download
     */
    private function handleSingleNzbDownload(
        Request $request,
        int $uid,
        string $rssToken,
        string $releaseId
    ): Response|StreamedResponse {
        // Get NZB file path and validate
        $nzbPath = app(NzbService::class)->nzbPath($releaseId);
        if ($nzbPath === false) {
            return showApiError(300, 'NZB file not found!');
        }

        // Get release data
        $releaseData = Release::getByGuid($releaseId);
        if ($releaseData === null) {
            return showApiError(300, 'Release not found!');
        }

        // Update statistics
        $this->updateDownloadStatistics($request, $uid, $releaseId, $releaseData->id);

        // Build response headers
        $headers = $this->buildNzbHeaders($request, $releaseId, $uid, $rssToken, $releaseData);

        // Stream modified NZB content
        $cleanName = FilenameSanitizer::sanitize($releaseData->searchname, "release-{$releaseId}");
        $asciiFallbackName = FilenameSanitizer::asciiFallback($cleanName, "release-{$releaseId}");

        $response = response()->stream(
            fn () => $this->streamModifiedNzbContent($nzbPath, $uid),
            200,
            $headers
        );

        $response->headers->set(
            'Content-Disposition',
            HeaderUtils::makeDisposition(
                'attachment',
                $cleanName.self::NZB_SUFFIX,
                $asciiFallbackName.self::NZB_SUFFIX
            )
        );

        return $response;
    }

    /**
     * Update download statistics for single NZB
     */
    private function updateDownloadStatistics(Request $request, int $uid, string $releaseId, int $releaseDbId): void
    {
        Release::updateGrab($releaseId);
        UserDownload::addDownloadRequest($uid, $releaseDbId);
        User::incrementGrabs($uid);

        if ($request->has('del') && (int) $request->input('del') === 1) {
            UsersRelease::delCartByUserAndRelease($releaseId, $uid);
        }
    }

    /**
     * Build headers for NZB download response
     *
     * @return array<string, string>
     */
    private function buildNzbHeaders(Request $request, string $releaseId, int $uid, string $rssToken, Release $releaseData): array
    {
        $failureParams = [
            'guid' => $releaseId,
            'userid' => $uid,
        ];

        if (! $request->user() && $rssToken !== '') {
            $failureParams['api_token'] = $rssToken;
        }

        $headers = [
            'Content-Type' => 'application/x-nzb',
            'Expires' => now()->addYear()->toRfc7231String(),
            'X-DNZB-Failure' => url('/failed').'?'.http_build_query($failureParams, '', '&', PHP_QUERY_RFC3986),
            'X-DNZB-Category' => e($releaseData->category_name),
            'X-DNZB-Details' => url("/details/{$releaseId}"),
        ];

        // Add optional metadata headers
        if (! empty($releaseData->imdbid) && imdb_id_is_valid($releaseData->imdbid)) {
            $headers['X-DNZB-MoreInfo'] = "http://www.imdb.com/title/tt{$releaseData->imdbid}";
        } elseif (! empty($releaseData->tvdb) && $releaseData->tvdb > 0) {
            $headers['X-DNZB-MoreInfo'] = "http://www.thetvdb.com/?tab=series&id={$releaseData->tvdb}";
        }

        if ((int) $releaseData->nfostatus === 1) {
            $headers['X-DNZB-NFO'] = url("/nfo/{$releaseId}");
        }

        $headers['X-DNZB-RCode'] = '200';
        $headers['X-DNZB-RText'] = 'OK, NZB content follows.';

        return $headers;
    }

    /**
     * Stream modified NZB content with user-specific modifications
     */
    private function streamModifiedNzbContent(string $nzbPath, int $uid): void
    {
        if (! File::exists($nzbPath)) {
            Log::warning('NZB file not found during streaming', ['path' => $nzbPath]);
            echo '<?xml version="1.0" encoding="UTF-8"?><error>NZB file not found</error>';

            return;
        }

        $fileHandle = @gzopen($nzbPath, 'rb');
        if ($fileHandle === false) {
            Log::warning('Failed to open NZB file for streaming', ['path' => $nzbPath]);
            echo '<?xml version="1.0" encoding="UTF-8"?><error>Failed to read NZB file</error>';

            return;
        }

        $buffer = '';
        $lastChunk = false;

        // Stream and modify content in chunks
        while (! gzeof($fileHandle)) {
            $chunk = gzread($fileHandle, self::BUFFER_SIZE);
            if ($chunk === false) {
                break;
            }

            // Combine with previous buffer to handle boundaries
            $buffer .= $chunk;

            // Check if this is the last chunk
            if (gzeof($fileHandle)) { // @phpstan-ignore if.alwaysFalse
                $lastChunk = true;
            }

            // Process buffer
            if ($lastChunk) { // @phpstan-ignore if.alwaysFalse
                // On last chunk, modify poster attributes
                $buffer = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $buffer);
                echo $buffer;
            } else {
                // For intermediate chunks, keep some data in buffer to handle boundaries
                $safeLength = mb_strlen($buffer) - 1000; // Keep last 1KB in buffer
                if ($safeLength > 0) {
                    $output = mb_substr($buffer, 0, $safeLength);
                    $output = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $output);
                    echo $output;
                    $buffer = mb_substr($buffer, $safeLength);
                }
            }
        }

        gzclose($fileHandle);
    }
}
