using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library;
using TeleWave.Application.Media;
using TeleWave.Domain.Media;

namespace TeleWave.Infrastructure.Media;

/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{
    private const int CopyBufferSize = 1024 * 1024;

    public long GetAvailableFreeSpaceBytes()
    {
        try
        {
            return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
        }
        catch (Exception ex) when (ex is ArgumentException or IOException)
        {
            // Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
            return long.MaxValue;
        }
    }

    public string? ResolvePlaylistPath(Guid assetId)
    {
        var path = Path.Combine(paths.AssetDir(assetId), "index.m3u8");
        return File.Exists(path) ? path : null;
    }

    public async Task<string> SaveUploadAsync(
        Stream content,
        string extension,
        CancellationToken cancellationToken
    )
    {
        Directory.CreateDirectory(paths.UploadsDir);
        var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
        var path = paths.UploadPath(token);

        try
        {
            await using var file = new FileStream(
                path,
                FileMode.CreateNew,
                FileAccess.Write,
                FileShare.None,
                CopyBufferSize,
                useAsync: true
            );
            await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
        }
        catch
        {
            // Заливка оборвалась (клиент отвалился, отмена, диск) — недописанный файл никому не
            // нужен: токен наружу не ушёл, и подобрать его некому. Сносим сразу, а не оставляем
            // уборщику: это гигабайты. Поток к этому моменту уже закрыт — using вышел из области.
            TryDelete(path);
            throw;
        }

        return token;
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
                File.Delete(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Не наш день: файл заперт. Подберёт UploadsCleanupBackgroundService.
        }
    }

    public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
    {
        var path = paths.UploadPath(uploadToken);
        if (File.Exists(path))
            File.Delete(path);
        return Task.CompletedTask;
    }

    public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
    {
        if (!Directory.Exists(paths.ManualDir))
            return [];

        return Directory
            .EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
            .Take(Math.Max(1, max))
            .Select(path => new FileInfo(path))
            .Where(file => file.Exists)
            .Select(file => new IMediaStorage.ManualInboxFile(
                // Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
                Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
                file.Name,
                file.Length
            ))
            .OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    public Task CleanupManualLeftoversAsync(
        string relativePath,
        CancellationToken cancellationToken
    )
    {
        var path = paths.ManualPath(relativePath);
        var directory = Path.GetDirectoryName(path);
        if (directory is null || !Directory.Exists(directory))
            return Task.CompletedTask;

        var companion = new CompanionMatcher(Path.GetFileName(path));

        DeleteCompanions(directory, companion);

        // Субтитры кладут не только рядом с серией, но и в подпапку — subs_ru, Subs, Subtitles.
        // Смотрим ровно на один уровень вниз: глубже начинается уже чужое дерево, а код удаляет.
        foreach (var nested in Directory.EnumerateDirectories(directory))
            DeleteCompanions(nested, companion);

        RemoveEmptyDirectories(directory);
        return Task.CompletedTask;
    }

    /// <summary>
    /// Кто считается спутником забранной серии. Два признака, и оба буквальные: имя, начинающееся
    /// с имени серии и точки («Серия.srt», «Серия.ru.srt»), либо тот же номер сезона и серии —
    /// в подпапке субтитров файл обычно назван иначе, чем видео, но номер в нём тот же.
    ///
    /// Отбор своим сравнением, а не маской поиска: в имени файла на Linux законно встречается «*»,
    /// и маска захватила бы чужое. Код удаляет — он обязан быть буквальным.
    /// </summary>
    private sealed class CompanionMatcher(string videoFileName)
    {
        private readonly string _prefix = Path.GetFileNameWithoutExtension(videoFileName) + ".";
        private readonly (int Season, int Episode)? _numbers = EpisodeName.Parse(videoFileName);

        public bool Matches(string fileName)
        {
            // Видеофайлы не трогаем никогда: «Серия.Extended.mkv» — не мусор, а другой материал.
            if (MediaFormats.IsAllowed(fileName))
                return false;

            if (fileName.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase))
                return true;

            return _numbers is { } mine
                && EpisodeName.Parse(fileName) is { } other
                && other.Season == mine.Season
                && other.Episode == mine.Episode;
        }
    }

    private static void DeleteCompanions(string directory, CompanionMatcher companion)
    {
        var companions = Directory
            .EnumerateFiles(directory)
            .Where(file => companion.Matches(Path.GetFileName(file)))
            .ToList();

        foreach (var file in companions)
        {
            try
            {
                File.Delete(file);
            }
            catch (IOException)
            {
                // Файл занят или уже удалён — не повод валить импорт целиком.
            }
        }
    }

    /// <summary>
    /// Убирает опустевшие каталоги: сначала подпапки (пустой subs_ru делает пустым и сам сезон),
    /// затем сам каталог и его родителей — вверх до корня manual/, который нужен всегда.
    /// </summary>
    private void RemoveEmptyDirectories(string directory)
    {
        foreach (var nested in Directory.EnumerateDirectories(directory))
            TryDeleteEmpty(nested);

        var manualRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.ManualDir));
        var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));

        while (
            !string.Equals(current, manualRoot, StringComparison.Ordinal)
            && current.StartsWith(
                manualRoot + Path.DirectorySeparatorChar,
                StringComparison.Ordinal
            )
            && TryDeleteEmpty(current)
        )
        {
            current = Path.GetDirectoryName(current) ?? manualRoot;
        }
    }

    private static bool TryDeleteEmpty(string directory)
    {
        if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any())
            return false;

        try
        {
            Directory.Delete(directory);
            return true;
        }
        catch (IOException)
        {
            // Каталог занят — оставим как есть.
            return false;
        }
    }

    public Task PromoteToOriginalAsync(
        MediaSource source,
        string sourceToken,
        Guid assetId,
        string extension,
        CancellationToken cancellationToken
    )
    {
        var sourcePath = source switch
        {
            MediaSource.Inbox => paths.InboxPath(sourceToken),
            MediaSource.ManualInbox => paths.ManualPath(sourceToken),
            _ => paths.UploadPath(sourceToken),
        };

        if (!File.Exists(sourcePath))
            throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);

        Directory.CreateDirectory(paths.OriginalsDir);
        var destination = paths.OriginalPath(assetId, extension);
        File.Move(sourcePath, destination, overwrite: true);
        return Task.CompletedTask;
    }

    public Task DeleteAssetArtifactsAsync(
        Guid assetId,
        string extension,
        CancellationToken cancellationToken
    ) =>
        // Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
        // (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
        Task.Run(
            () =>
            {
                var original = paths.OriginalPath(assetId, extension);
                if (File.Exists(original))
                    File.Delete(original);

                var assetDir = paths.AssetDir(assetId);
                if (Directory.Exists(assetDir))
                    Directory.Delete(assetDir, recursive: true);
            },
            cancellationToken
        );
}
