using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;

namespace TeleWave.Infrastructure.Media;

/// <summary>
/// Считает занятое место обходом дерева хранилища. Другого источника правды нет: размеры в БД
/// разошлись бы с диском при первой же ручной правке файлов.
///
/// Обход дорогой — сегментов у нарезанной библиотеки сотни тысяч, — поэтому результат живёт
/// в кэше <see cref="CacheLifetime"/> и пересчитывается по явной просьбе. Параллельные запросы
/// не запускают второй обход: они ждут первый.
/// </summary>
public sealed class FileSystemStorageInspector(
    MediaPathResolver paths,
    IOptions<StorageOptions> options
) : IStorageInspector
{
    /// <summary>Сколько цифра считается свежей. Диск меняется медленно, а обход стоит дорого.</summary>
    private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(5);

    private readonly StorageOptions _storage = options.Value;
    private readonly SemaphoreSlim _lock = new(1, 1);
    private StorageUsage? _cached;

    public async Task<StorageUsage> InspectAsync(
        IReadOnlySet<Guid> generatedAssetIds,
        bool refresh,
        CancellationToken cancellationToken
    )
    {
        if (!refresh && IsFresh(_cached))
            return _cached!;

        await _lock.WaitAsync(cancellationToken);
        try
        {
            // Пока ждали очереди, обход мог уже пройти — второй раз ходить незачем.
            if (!refresh && IsFresh(_cached))
                return _cached!;

            var usage = await Task.Run(
                () => Inspect(generatedAssetIds, cancellationToken),
                cancellationToken
            );
            _cached = usage;
            return usage;
        }
        finally
        {
            _lock.Release();
        }
    }

    private static bool IsFresh(StorageUsage? usage) =>
        usage is not null && DateTimeOffset.UtcNow - usage.ComputedAt < CacheLifetime;

    private StorageUsage Inspect(
        IReadOnlySet<Guid> generatedAssetIds,
        CancellationToken cancellationToken
    )
    {
        var known = new[]
        {
            paths.AssetsDir,
            paths.OriginalsDir,
            paths.InboxDir,
            paths.ManualDir,
            paths.UploadsDir,
            paths.BumpersDir,
            paths.ImagesDir,
        };

        var (programs, bumpers) = MeasureAssets(generatedAssetIds, cancellationToken);
        var areas = new List<StorageAreaUsage>
        {
            new(StorageArea.Assets, programs.Bytes, programs.Files),
            new(StorageArea.BumperAssets, bumpers.Bytes, bumpers.Files),
            Area(StorageArea.Originals, paths.OriginalsDir, cancellationToken),
            Area(StorageArea.Inbox, paths.InboxDir, cancellationToken),
            Area(StorageArea.ManualInbox, paths.ManualDir, cancellationToken),
            Area(StorageArea.Uploads, paths.UploadsDir, cancellationToken),
            Area(StorageArea.BumperSources, paths.BumpersDir, cancellationToken),
            Area(StorageArea.Images, paths.ImagesDir, cancellationToken),
            MeasureOther(known, cancellationToken),
        };

        var (total, free) = Volume();
        return new StorageUsage(
            _storage.RootPath,
            total,
            free,
            areas.Sum(a => a.Bytes),
            areas.Sum(a => a.Files),
            _storage.MinFreeSpaceBytes,
            areas,
            DateTimeOffset.UtcNow
        );
    }

    /// <summary>
    /// Сегменты, разложенные на программы и заставки. Каталог у них общий (<c>assets/{id}</c>),
    /// и разделить их можно только по идентификатору: имя каталога — это идентификатор ассета.
    /// </summary>
    private (Measurement Programs, Measurement Bumpers) MeasureAssets(
        IReadOnlySet<Guid> generatedAssetIds,
        CancellationToken cancellationToken
    )
    {
        var programs = new Measurement();
        var bumpers = new Measurement();
        if (!Directory.Exists(paths.AssetsDir))
            return (programs, bumpers);

        foreach (var directory in SafeEnumerateDirectories(paths.AssetsDir))
        {
            cancellationToken.ThrowIfCancellationRequested();
            var measured = Measure(directory, cancellationToken);
            var generated =
                Guid.TryParse(Path.GetFileName(directory), out var assetId)
                && generatedAssetIds.Contains(assetId);
            if (generated)
                bumpers.Add(measured);
            else
                programs.Add(measured);
        }

        // Файлы, лежащие в самом assets/ мимо каталогов ассетов, — тоже занятое место.
        programs.Add(MeasureFiles(paths.AssetsDir));
        return (programs, bumpers);
    }

    /// <summary>Всё, что лежит под корнем мимо известных каталогов, — обычно ноль, но увидеть это надо.</summary>
    private StorageAreaUsage MeasureOther(
        IReadOnlyCollection<string> known,
        CancellationToken cancellationToken
    )
    {
        var root = Path.GetFullPath(_storage.RootPath);
        var measurement = new Measurement();
        if (!Directory.Exists(root))
            return new StorageAreaUsage(StorageArea.Other, 0, 0);

        measurement.Add(MeasureFiles(root));
        foreach (var directory in SafeEnumerateDirectories(root))
        {
            if (known.Any(k => PathsEqual(k, directory)))
                continue;
            measurement.Add(Measure(directory, cancellationToken));
        }

        return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files);
    }

    private static StorageAreaUsage Area(
        StorageArea area,
        string directory,
        CancellationToken cancellationToken
    )
    {
        var measurement = Measure(directory, cancellationToken);
        return new StorageAreaUsage(area, measurement.Bytes, measurement.Files);
    }

    /// <summary>Размер тома хранилища; нули — файловая система не отдала метрику.</summary>
    private (long Total, long Free) Volume()
    {
        try
        {
            var drive = new DriveInfo(Path.GetFullPath(_storage.RootPath));
            return (drive.TotalSize, drive.AvailableFreeSpace);
        }
        catch (Exception ex)
            when (ex is ArgumentException or IOException or UnauthorizedAccessException)
        {
            // Экзотическая точка монтирования — показываем только занятое хранилищем.
            return (0, 0);
        }
    }

    private static Measurement Measure(string directory, CancellationToken cancellationToken)
    {
        var measurement = new Measurement();
        if (!Directory.Exists(directory))
            return measurement;

        // Перечисление с игнорированием недоступного: одна запертая папка не должна ронять отчёт.
        var enumeration = new EnumerationOptions
        {
            RecurseSubdirectories = true,
            IgnoreInaccessible = true,
        };

        foreach (var file in Directory.EnumerateFiles(directory, "*", enumeration))
        {
            cancellationToken.ThrowIfCancellationRequested();
            measurement.Add(SizeOf(file));
        }

        return measurement;
    }

    private static Measurement MeasureFiles(string directory)
    {
        var measurement = new Measurement();
        if (!Directory.Exists(directory))
            return measurement;

        foreach (
            var file in Directory.EnumerateFiles(
                directory,
                "*",
                new EnumerationOptions { IgnoreInaccessible = true }
            )
        )
            measurement.Add(SizeOf(file));

        return measurement;
    }

    private static IEnumerable<string> SafeEnumerateDirectories(string directory)
    {
        try
        {
            return Directory.EnumerateDirectories(
                directory,
                "*",
                new EnumerationOptions { IgnoreInaccessible = true }
            );
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            return [];
        }
    }

    private static long SizeOf(string file)
    {
        try
        {
            return new FileInfo(file).Length;
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Файл исчез между перечислением и замером — обычное дело при живой нарезке.
            return 0;
        }
    }

    private static bool PathsEqual(string left, string right) =>
        string.Equals(
            Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
            Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
            OperatingSystem.IsWindows()
                ? StringComparison.OrdinalIgnoreCase
                : StringComparison.Ordinal
        );

    /// <summary>Накопитель обхода: байты и число файлов идут вместе всюду, где считается место.</summary>
    private sealed class Measurement
    {
        public long Bytes { get; private set; }
        public int Files { get; private set; }

        public void Add(long bytes)
        {
            Bytes += bytes;
            Files++;
        }

        public void Add(Measurement other)
        {
            Bytes += other.Bytes;
            Files += other.Files;
        }
    }
}
