<?php

declare(strict_types=1);

namespace App\Services\Nzb;

use App\Models\Collection;
use App\Models\Release;
use App\Models\Settings;
use App\Services\Binaries\BinariesConfig;
use App\Services\CollectionCleanupService;
use App\Support\Data\NzbCreationResult;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;

/**
 * Service for managing NZB files on disk.
 * Handles reading, writing, and path management for NZB files.
 */
class NzbService
{
    public const NZB_NONE = 0; // Release has no NZB file yet.

    public const NZB_ADDED = 1; // Release had an NZB file created.

    protected const NZB_DTD_NAME = 'nzb';

    protected const NZB_DTD_PUBLIC = '-//newzBin//DTD NZB 1.1//EN';

    protected const NZB_DTD_EXTERNAL = 'http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd';

    protected const NZB_XML_NS = 'http://www.newzbin.com/DTD/2003/nzb';

    /**
     * Levels deep to store NZB files.
     */
    protected int $nzbSplitLevel;

    /**
     * Path to store NZB files.
     */
    protected string $siteNzbPath;

    /**
     * Candidate base paths to search for existing NZB files.
     *
     * @var list<string>
     */
    protected array $siteNzbPaths = [];

    /**
     * String used for head in NZB XML file.
     */
    protected string $nzbCommentString;

    protected string $siteCommentString;

    public function __construct(
        private readonly CollectionCleanupService $collectionCleanupService,
        ?BinariesConfig $binariesConfig = null,
    ) {
        $this->binariesConfig = $binariesConfig ?? BinariesConfig::fromSettings();
        try {
            $nzbSplitLevel = (int) Settings::settingValue('nzbsplitlevel');
        } catch (QueryException $e) {
            // Table doesn't exist yet (e.g., during migrations or tests)
            $nzbSplitLevel = 1;
        }
        $this->nzbSplitLevel = $nzbSplitLevel;
        $this->siteNzbPaths = $this->buildCandidateNzbBasePaths((string) config('nntmux_settings.path_to_nzbs'));
        $this->siteNzbPath = $this->selectPreferredNzbBasePath($this->siteNzbPaths);
        $this->nzbCommentString = sprintf(
            'NZB Generated by: NNTmux %s',
            now()->format('F j, Y, g:i a O')
        );
        $this->siteCommentString = sprintf(
            'NZB downloaded from %s',
            config('app.name')
        );
    }

    private readonly BinariesConfig $binariesConfig;

    /**
     * Write an NZB file for a release.
     *
     * @throws Throwable
     */
    public function writeNzbForReleaseId(Release $release): bool
    {
        return $this->createNzbForRelease($release)->success;
    }

    public function createNzbForRelease(Release $release): NzbCreationResult
    {
        try {
            $collections = Collection::whereReleasesId($release->id)
                ->join('usenet_groups', 'collections.groups_id', '=', 'usenet_groups.id')
                ->select(['collections.*', DB::raw('UNIX_TIMESTAMP(collections.date) AS udate'), 'usenet_groups.name as groupname'])
                ->orderBy('collections.id')
                ->get()
                ->keyBy('id');
        } catch (Throwable $e) {
            return NzbCreationResult::transient('Failed to load release collections: '.$e->getMessage());
        }

        if ($collections->isEmpty()) {
            return NzbCreationResult::deterministic('Release has no collections to write into an NZB.');
        }

        $collectionIds = $collections->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
        try {
            $emptyCollection = DB::selectOne(
                'SELECT c.id FROM collections c
                 LEFT JOIN binaries b ON b.collections_id = c.id
                 WHERE c.releases_id = ? GROUP BY c.id HAVING COUNT(b.id) = 0 LIMIT 1',
                [$release->id]
            );
            if ($emptyCollection !== null) {
                return NzbCreationResult::deterministic("Collection {$emptyCollection->id} has no binaries.", $collectionIds);
            }

            $emptyBinary = DB::selectOne(
                'SELECT b.id FROM binaries b
                 INNER JOIN collections c ON c.id = b.collections_id
                 LEFT JOIN parts p ON p.binaries_id = b.id
                 WHERE c.releases_id = ? GROUP BY b.id HAVING COUNT(p.partnumber) = 0 LIMIT 1',
                [$release->id]
            );
            if ($emptyBinary !== null) {
                return NzbCreationResult::deterministic("Binary {$emptyBinary->id} has no parts.", $collectionIds);
            }

            $groupsByCollection = $this->loadCollectionGroups((int) $release->id, $collections);
        } catch (Throwable $e) {
            return NzbCreationResult::transient('Failed to load NZB binaries or parts: '.$e->getMessage(), $collectionIds);
        }

        $XMLWriter = new \XMLWriter;
        $XMLWriter->openMemory();
        $XMLWriter->setIndent(true);
        $XMLWriter->setIndentString('  ');

        $path = null;
        $tempPath = null;
        $gz = null;

        try {
            $path = ($this->buildNzbPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
            $tempPath = $this->temporaryNzbPath($path);
            $gz = $this->openGzipFile($tempPath);

            if ($gz === false) {
                return NzbCreationResult::transient("Failed to open temporary NZB file for writing: {$tempPath}", $collectionIds, $path);
            }

            $XMLWriter->startDocument('1.0', 'UTF-8');
            $XMLWriter->startDtd(self::NZB_DTD_NAME, self::NZB_DTD_PUBLIC, self::NZB_DTD_EXTERNAL);
            $XMLWriter->endDtd();
            $XMLWriter->writeComment($this->nzbCommentString);

            $XMLWriter->startElement('nzb');
            $XMLWriter->writeAttribute('xmlns', self::NZB_XML_NS);
            $XMLWriter->startElement('head');
            $XMLWriter->startElement('meta');
            $XMLWriter->writeAttribute('type', 'category');
            $XMLWriter->text(! empty($release->category->parent) ? $release->category->parent->title.' >'.$release->category->title : 'Other > Misc');
            $XMLWriter->endElement();
            $XMLWriter->startElement('meta');
            $XMLWriter->writeAttribute('type', 'name');
            $XMLWriter->text($release->name);
            $XMLWriter->endElement();
            $XMLWriter->endElement(); // head
            if (! $this->flushXmlWriter($XMLWriter, $gz)) {
                return NzbCreationResult::transient("Failed to write NZB header to temporary file: {$tempPath}", $collectionIds, $path);
            }

            $cursor = ['collection_id' => 0, 'name' => '', 'binary_id' => 0, 'partnumber' => 0];
            $openBinaryId = 0;
            do {
                $page = $this->loadNzbRowPage((int) $release->id, $cursor);
                foreach ($page as $row) {
                    $binaryId = (int) $row->binary_id;
                    if ($binaryId !== $openBinaryId) {
                        if ($openBinaryId > 0) {
                            $XMLWriter->endElement(); // segments
                            $XMLWriter->endElement(); // file
                            if (! $this->flushXmlWriter($XMLWriter, $gz)) {
                                return NzbCreationResult::transient("Failed to write NZB file entry to temporary file: {$tempPath}", $collectionIds, $path);
                            }
                        }

                        $collection = $collections->get((int) $row->collection_id);
                        $groups = $groupsByCollection[(int) $row->collection_id] ?? [];
                        if ($collection === null || $groups === []) {
                            return NzbCreationResult::deterministic("Collection {$row->collection_id} has no valid cross-post groups.", $collectionIds, $path);
                        }

                        $subject = $this->buildBinarySubject((string) $row->binary_name, (int) $row->totalparts);
                        $XMLWriter->startElement('file');
                        $XMLWriter->writeAttribute('poster', (string) $collection->fromname);
                        $XMLWriter->writeAttribute('date', (string) $collection->udate);
                        $XMLWriter->writeAttribute('subject', (string) $subject);
                        $XMLWriter->startElement('groups');
                        foreach ($groups as $group) {
                            $XMLWriter->writeElement('group', $group);
                        }
                        $XMLWriter->endElement(); // groups
                        $XMLWriter->startElement('segments');
                        $openBinaryId = $binaryId;
                    }

                    $messageId = $this->normalizeSegmentMessageId($row->messageid);
                    if ($messageId === '') {
                        return NzbCreationResult::deterministic("Part {$row->partnumber} for binary {$binaryId} has an empty message ID.", $collectionIds, $path);
                    }

                    $XMLWriter->startElement('segment');
                    $XMLWriter->writeAttribute('bytes', (string) $row->size);
                    $XMLWriter->writeAttribute('number', (string) $row->partnumber);
                    $XMLWriter->text($messageId);
                    $XMLWriter->endElement();

                    $cursor = [
                        'collection_id' => (int) $row->collection_id,
                        'name' => (string) $row->binary_name,
                        'binary_id' => $binaryId,
                        'partnumber' => (int) $row->partnumber,
                    ];
                }
            } while (\count($page) === $this->binariesConfig->nzbStreamRows);

            if ($openBinaryId > 0) {
                $XMLWriter->endElement(); // segments
                $XMLWriter->endElement(); // file
            }

            $XMLWriter->writeComment($this->siteCommentString);
            $XMLWriter->endElement(); // nzb
            $XMLWriter->endDocument();
            if (! $this->flushXmlWriter($XMLWriter, $gz)) {
                return NzbCreationResult::transient("Failed to write NZB footer to temporary file: {$tempPath}", $collectionIds, $path);
            }

            $closed = gzclose($gz);
            $gz = null;
            if (! $closed) {
                return NzbCreationResult::transient("Failed to close temporary NZB file: {$tempPath}", $collectionIds, $path);
            }

            if (! $this->moveTemporaryNzbIntoPlace($tempPath, $path)) {
                return NzbCreationResult::transient("Failed to move temporary NZB into place: {$tempPath} -> {$path}", $collectionIds, $path);
            }
            $tempPath = null;

            if (! File::isFile($path) || ! is_readable($path)) {
                return NzbCreationResult::transient("Final NZB file is missing or unreadable: {$path}", $collectionIds, $path);
            }

            // Mark release as having NZB.
            $release->update($this->successfulReleaseUpdateValues());
        } catch (Throwable $e) {
            return NzbCreationResult::transient('Failed to write NZB file: '.$e->getMessage(), $collectionIds, $path);
        } finally {
            unset($XMLWriter);
            if (is_resource($gz)) {
                gzclose($gz);
            }
            if ($tempPath !== null && File::isFile($tempPath)) {
                File::delete($tempPath);
            }
        }

        // Delete CBP (Collections, Binaries, Parts) for release that has its NZB created.
        // Use a transaction to ensure cascading deletes complete properly.
        try {
            $this->collectionCleanupService->deleteCollectionsAndDescendants(
                $collectionIds,
                'NZB cleanup',
                false
            );
        } catch (Throwable $e) {
            // Log the error but don't fail the NZB creation since the file was written successfully
            Log::warning('Failed to delete collections for release '.$release->id.': '.$e->getMessage());
        }

        // Chmod to fix issues some users have with file permissions.
        chmod($path, 0777);

        return NzbCreationResult::success($path, $collectionIds);
    }

    /**
     * @param  \Illuminate\Support\Collection<int|string, mixed>  $collections
     * @return array<int, list<string>>
     */
    private function loadCollectionGroups(int $releaseId, \Illuminate\Support\Collection $collections): array
    {
        $groups = [];
        if (Schema::hasTable('collection_groups')) {
            $rows = DB::select(
                'SELECT cg.collections_id, cg.group_name
                 FROM collection_groups cg INNER JOIN collections c ON c.id = cg.collections_id
                 WHERE c.releases_id = ? ORDER BY cg.collections_id, cg.group_name',
                [$releaseId]
            );
            foreach ($rows as $row) {
                $groups[(int) $row->collections_id][] = (string) $row->group_name;
            }
        }

        foreach ($collections as $collection) {
            $collectionId = (int) $collection->id;
            if (($groups[$collectionId] ?? []) === []) {
                $groups[$collectionId] = $this->groupsFromXref((string) $collection->xref);
            }
        }

        return $groups;
    }

    /**
     * @param  array{collection_id: int, name: string, binary_id: int, partnumber: int}  $cursor
     * @return list<object>
     */
    private function loadNzbRowPage(int $releaseId, array $cursor): array
    {
        $limit = $this->binariesConfig->nzbStreamRows;

        return DB::select(
            'SELECT b.collections_id AS collection_id, b.id AS binary_id, b.name AS binary_name,
                    b.totalparts, p.messageid, p.size, p.partnumber
             FROM binaries b
             INNER JOIN collections c ON c.id = b.collections_id
             INNER JOIN parts p ON p.binaries_id = b.id
             WHERE c.releases_id = ? AND (
                b.collections_id > ? OR
                (b.collections_id = ? AND b.name > ?) OR
                (b.collections_id = ? AND b.name = ? AND b.id > ?) OR
                (b.collections_id = ? AND b.name = ? AND b.id = ? AND p.partnumber > ?)
             )
             ORDER BY b.collections_id, b.name, b.id, p.partnumber
             LIMIT '.$limit,
            [
                $releaseId,
                $cursor['collection_id'],
                $cursor['collection_id'], $cursor['name'],
                $cursor['collection_id'], $cursor['name'], $cursor['binary_id'],
                $cursor['collection_id'], $cursor['name'], $cursor['binary_id'], $cursor['partnumber'],
            ]
        );
    }

    /**
     * Build a folder path on the hard drive where the NZB file will be stored.
     *
     * @param  string  $releaseGuid  The guid of the release.
     * @param  int  $levelsToSplit  How many sub-paths the folder will be in.
     * @param  bool  $createIfNotExist  Create the folder if it doesn't exist.
     * @return string $nzbpath The path to store the NZB file.
     */
    public function buildNzbPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string
    {
        $nzbPath = $this->siteNzbPath;

        for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
            $nzbPath .= $releaseGuid[$i].'/';

            if ($createIfNotExist && ! File::isDirectory($nzbPath)) {
                if (! File::makeDirectory($nzbPath, 0775) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
                    throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
                }

                File::chmod($nzbPath, 02775);
            }
        }

        return $nzbPath;
    }

    /**
     * Build a path on a specific NZB base directory.
     */
    protected function buildNzbPathAtBasePath(string $basePath, string $releaseGuid, int $levelsToSplit): string
    {
        $nzbPath = '';

        for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
            $nzbPath .= $releaseGuid[$i].'/';
        }

        return $basePath.$nzbPath;
    }

    /**
     * Retrieve path + filename of the NZB to be stored.
     *
     * @param  string  $releaseGuid  The guid of the release.
     * @param  int  $levelsToSplit  How many sub-paths the folder will be in. (optional)
     * @param  bool  $createIfNotExist  Create the folder if it doesn't exist. (optional)
     * @return string Path+filename.
     */
    public function getNzbPath(string $releaseGuid, int $levelsToSplit = 0, bool $createIfNotExist = false): string
    {
        if ($levelsToSplit === 0) {
            $levelsToSplit = $this->nzbSplitLevel;
        }

        return $this->buildNzbPath($releaseGuid, $levelsToSplit, $createIfNotExist).$releaseGuid.'.nzb.gz';
    }

    /**
     * Determine if an NZB exists, returning the path+filename, if not return false.
     *
     * @param  string  $releaseGuid  The guid of the release.
     * @return false|string On success: (string) Path+file name of the nzb.
     *                      On failure: false.
     */
    public function nzbPath(string $releaseGuid): bool|string
    {
        $levelsToSplit = $this->nzbSplitLevel === 0 ? 1 : $this->nzbSplitLevel;

        foreach ($this->siteNzbPaths as $basePath) {
            $nzbFile = $this->buildNzbPathAtBasePath($basePath, $releaseGuid, $levelsToSplit).$releaseGuid.'.nzb.gz';
            if (is_file($nzbFile)) {
                return $nzbFile;
            }
        }

        $nzbFile = $this->getNzbPath($releaseGuid);

        return is_file($nzbFile) ? $nzbFile : false;
    }

    /**
     * Read and decompress an NZB file contents.
     *
     * @param  string  $releaseGuid  The release GUID
     * @return string|false The decompressed NZB contents or false on failure
     */
    public function readNzbContents(string $releaseGuid): string|false
    {
        $nzbPath = $this->nzbPath($releaseGuid);
        if ($nzbPath === false) {
            return false;
        }

        $contents = unzipGzipFile($nzbPath);

        return ! empty($contents) ? $contents : false;
    }

    /**
     * Delete an NZB file.
     *
     * @param  string  $releaseGuid  The release GUID
     * @return bool True if deleted, false otherwise
     */
    public function deleteNzb(string $releaseGuid): bool
    {
        $nzbPath = $this->nzbPath($releaseGuid);
        if ($nzbPath === false) {
            return false;
        }

        return File::delete($nzbPath);
    }

    /**
     * @return list<string>
     */
    public function findStaleTemporaryNzbPaths(int $olderThanSeconds = 86400): array
    {
        $cutoff = time() - max(1, $olderThanSeconds);
        $paths = [];

        foreach (array_unique($this->siteNzbPaths) as $basePath) {
            if (! File::isDirectory($basePath)) {
                continue;
            }

            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::LEAVES_ONLY
            );

            foreach ($iterator as $file) {
                if (! $file->isFile() || $file->isLink()) {
                    continue;
                }

                $path = $file->getPathname();
                if (! $this->isTemporaryNzbPath($path)) {
                    continue;
                }

                try {
                    if ($file->getMTime() <= $cutoff) {
                        $paths[] = $path;
                    }
                } catch (Throwable $e) {
                    Log::channel('nzb_creation')->warning('Failed to inspect temporary NZB file', [
                        'path' => $path,
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }

        sort($paths);

        return $paths;
    }

    public function cleanupStaleTemporaryNzbs(int $olderThanSeconds = 86400): int
    {
        $deleted = 0;

        foreach ($this->findStaleTemporaryNzbPaths($olderThanSeconds) as $path) {
            if (! File::isFile($path)) {
                continue;
            }

            try {
                if (File::delete($path)) {
                    $deleted++;

                    continue;
                }

                Log::channel('nzb_creation')->warning('Failed to delete stale temporary NZB file', [
                    'path' => $path,
                ]);
            } catch (Throwable $e) {
                Log::channel('nzb_creation')->warning('Failed to delete stale temporary NZB file', [
                    'path' => $path,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        return $deleted;
    }

    /**
     * Get the default NZB split level.
     */
    public function getNzbSplitLevel(): int
    {
        return $this->nzbSplitLevel;
    }

    /**
     * Get the base NZB storage path.
     */
    public function getSiteNzbPath(): string
    {
        return $this->siteNzbPath;
    }

    /**
     * @return list<string>
     */
    protected function buildCandidateNzbBasePaths(string $configuredPath): array
    {
        $defaultPath = $this->normalizeNzbBasePath(storage_path('nzb'));
        $configuredPath = $configuredPath !== '' ? $configuredPath : $defaultPath;
        $configuredPath = $this->normalizeNzbBasePath($configuredPath);

        $candidatePaths = [$configuredPath];
        $remappedStoragePath = $this->remapStoragePathToCurrentApplication($configuredPath);

        if ($remappedStoragePath !== null && ! in_array($remappedStoragePath, $candidatePaths, true)) {
            $candidatePaths[] = $remappedStoragePath;
        }

        if (! in_array($defaultPath, $candidatePaths, true)) {
            $candidatePaths[] = $defaultPath;
        }

        return $candidatePaths;
    }

    /**
     * @param  list<string>  $candidatePaths
     */
    protected function selectPreferredNzbBasePath(array $candidatePaths): string
    {
        $configuredPath = $candidatePaths[0] ?? $this->normalizeNzbBasePath(storage_path('nzb'));
        $remappedStoragePath = $candidatePaths[1] ?? null;

        if (
            $remappedStoragePath !== null &&
            $configuredPath !== $remappedStoragePath &&
            ! is_dir($configuredPath) &&
            is_dir($remappedStoragePath) &&
            str_contains(str_replace('\\', '/', $configuredPath), '/storage/')
        ) {
            return $remappedStoragePath;
        }

        return $configuredPath;
    }

    protected function remapStoragePathToCurrentApplication(string $path): ?string
    {
        $normalizedPath = str_replace('\\', '/', $path);
        $storageMarker = '/storage/';

        if (! str_contains($normalizedPath, $storageMarker)) {
            return null;
        }

        $relativeStoragePath = Str::after($normalizedPath, $storageMarker);

        return $this->normalizeNzbBasePath(storage_path($relativeStoragePath));
    }

    protected function normalizeNzbBasePath(string $path): string
    {
        $normalizedPath = str_replace('\\', '/', trim($path));

        return rtrim($normalizedPath, '/').'/';
    }

    /**
     * Build the NZB subject line in the shape expected by downstream parsers.
     */
    private function buildBinarySubject(string $binaryName, int $totalParts): string
    {
        return rtrim($binaryName).' (1/'.$totalParts.')';
    }

    /**
     * Normalize stored message IDs before writing them into NZB segments.
     */
    private function normalizeSegmentMessageId(string $messageId): string
    {
        $messageId = trim($messageId);

        if ($messageId === '') {
            return '';
        }

        if (
            \strlen($messageId) >= 2
            && (($messageId[0] === '"' && str_ends_with($messageId, '"'))
                || ($messageId[0] === "'" && str_ends_with($messageId, "'")))
        ) {
            $messageId = substr($messageId, 1, -1);
        }

        $messageId = trim($messageId);

        if (str_starts_with($messageId, '<') && str_ends_with($messageId, '>')) {
            $messageId = substr($messageId, 1, -1);
        }

        return trim($messageId);
    }

    /**
     * @return list<string>
     */
    private function groupsFromXref(string $xref): array
    {
        if (preg_match_all('#(\S+):\S+#', $xref, $hits)) {
            return array_values(array_unique($hits[1]));
        }

        if (preg_match_all('#(\S+)#', $xref, $hits)) {
            return array_values(array_unique($hits[1]));
        }

        return [];
    }

    private function temporaryNzbPath(string $path): string
    {
        return $path.'.tmp.'.getmypid().'.'.bin2hex(random_bytes(6));
    }

    private function isTemporaryNzbPath(string $path): bool
    {
        return preg_match('/\.nzb\.gz\.tmp\.\d+\.[0-9a-f]{12}$/', basename($path)) === 1;
    }

    /**
     * @return resource|false
     */
    protected function openGzipFile(string $path): mixed
    {
        return gzopen($path, 'wb7');
    }

    /**
     * @param  resource  $gz
     *
     * @phpstan-impure
     */
    private function flushXmlWriter(\XMLWriter $XMLWriter, mixed $gz): bool
    {
        $buffer = $XMLWriter->outputMemory(true);
        if ($buffer === '') {
            return true;
        }

        $written = gzwrite($gz, $buffer);

        return $written !== false && $written === \strlen($buffer);
    }

    protected function moveTemporaryNzbIntoPlace(string $temporaryPath, string $finalPath): bool
    {
        return rename($temporaryPath, $finalPath);
    }

    /**
     * @return array<string, mixed>
     */
    private function successfulReleaseUpdateValues(): array
    {
        $values = ['nzbstatus' => self::NZB_ADDED];

        if (NzbCreationCandidateQuery::supportsClaims()) {
            $values += [
                NzbCreationCandidateQuery::ATTEMPTS_COLUMN => 0,
                NzbCreationCandidateQuery::LAST_ERROR_COLUMN => null,
                NzbCreationCandidateQuery::CLAIMED_AT_COLUMN => null,
                NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN => null,
            ];
        }

        return $values;
    }
}
