namespace Sonex.Services.ImageSync;

using System.Collections.Concurrent;

internal sealed class ImageSyncEngine : IDisposable
{
    private static readonly TimeSpan TimestampTolerance = TimeSpan.FromSeconds(1);
    private static readonly TimeSpan RecentWriteThreshold = TimeSpan.FromSeconds(10);
    private static readonly int FullScanParallelism = Math.Clamp(Environment.ProcessorCount, 2, 6);

    private readonly ImageSyncOptions _options;
    private readonly ConcurrentDictionary<string, PendingAction> _pendingActions =
        new(StringComparer.OrdinalIgnoreCase);
    private readonly ConcurrentDictionary<string, SemaphoreSlim> _articleLocks =
        new(StringComparer.OrdinalIgnoreCase);

    private FileSystemWatcher? _watcher;

    public ImageSyncEngine(ImageSyncOptions options)
    {
        _options = options;
    }

    public async Task RunAsync(CancellationToken ct)
    {
        await RunFullScanAsync(ct);

        if (_options.UseWatcher)
        {
            StartWatcher();
            ConsoleHelpers.WriteSuccess("Watcher is active.");
        }

        if (_options.ScanIntervalSeconds <= 0)
        {
            await Task.Delay(Timeout.Infinite, ct);
            return;
        }

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.ScanIntervalSeconds));

        while (await timer.WaitForNextTickAsync(ct))
        {
            await RunFullScanAsync(ct);
        }
    }

    public async Task RunFullScanAsync(CancellationToken ct)
    {
        ConsoleHelpers.WriteInfo($"[SCAN] Started at {DateTime.Now:yyyy-MM-dd HH:mm:ss}");

        var recordsResult = await ImageSyncRepository.GetAllAsync(ct);
        if (!recordsResult.Success)
        {
            ConsoleHelpers.WriteError($"[SCAN] Cannot load records from database: {recordsResult.ErrorMessage}");
            return;
        }

        var existingRecordsByArticle = recordsResult.Items
            .Where(x => !string.IsNullOrWhiteSpace(x.ArticleNumber))
            .GroupBy(x => x.ArticleNumber, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(
                x => x.Key,
                x => (IReadOnlyList<ImageSyncRecord>)x.ToList(),
                StringComparer.OrdinalIgnoreCase);

        HashSet<string> articleNumbers = CollectSourceArticleNumbers(out int sourceImagesFound);
        foreach (string articleNumber in existingRecordsByArticle.Keys)
            articleNumbers.Add(articleNumber);

        int synced = 0;
        int deleted = 0;
        int skipped = 0;
        int failed = 0;

        var parallelOptions = new ParallelOptions
        {
            CancellationToken = ct,
            MaxDegreeOfParallelism = FullScanParallelism
        };

        await Parallel.ForEachAsync(articleNumbers, parallelOptions, async (articleNumber, token) =>
        {
            token.ThrowIfCancellationRequested();

            IReadOnlyList<ImageSyncRecord> existingRecords = existingRecordsByArticle.TryGetValue(articleNumber, out IReadOnlyList<ImageSyncRecord>? items)
                ? items
                : [];

            ArticleSyncOutcome outcome = await SyncArticleAsync(
                articleNumber,
                existingRecords,
                token,
                forceStabilityCheck: false);

            switch (outcome)
            {
                case ArticleSyncOutcome.Synced:
                    Interlocked.Increment(ref synced);
                    break;
                case ArticleSyncOutcome.Deleted:
                    Interlocked.Increment(ref deleted);
                    break;
                case ArticleSyncOutcome.Failed:
                    Interlocked.Increment(ref failed);
                    break;
                default:
                    Interlocked.Increment(ref skipped);
                    break;
            }
        });

        ConsoleHelpers.WriteInfo(
            $"[SCAN] Done. Articles: {articleNumbers.Count}, Images: {sourceImagesFound}, Synced: {synced}, Deleted: {deleted}, Skipped: {skipped}, Failed: {failed}");
        Console.WriteLine();
    }

    public void Dispose()
    {
        try
        {
            _watcher?.Dispose();
        }
        catch
        {
        }

        foreach (SemaphoreSlim articleLock in _articleLocks.Values)
        {
            articleLock.Dispose();
        }
    }

    private void StartWatcher()
    {
        if (_watcher is not null)
            return;

        _watcher = new FileSystemWatcher(_options.SourceRoot)
        {
            IncludeSubdirectories = true,
            Filter = "*.*",
            NotifyFilter = NotifyFilters.FileName
                         | NotifyFilters.DirectoryName
                         | NotifyFilters.Size
                         | NotifyFilters.LastWrite
                         | NotifyFilters.CreationTime,
            EnableRaisingEvents = true
        };

        _watcher.Created += (_, eventArgs) => QueuePendingAction(eventArgs.FullPath);
        _watcher.Changed += (_, eventArgs) => QueuePendingAction(eventArgs.FullPath);
        _watcher.Deleted += (_, eventArgs) => QueuePendingAction(eventArgs.FullPath);
        _watcher.Renamed += (_, eventArgs) =>
        {
            QueuePendingAction(eventArgs.OldFullPath);
            QueuePendingAction(eventArgs.FullPath);
        };
        _watcher.Error += (_, eventArgs) =>
        {
            ConsoleHelpers.WriteWarning($"[WATCHER] {eventArgs.GetException()?.Message ?? "Unknown watcher error"}");
        };
    }

    private void QueuePendingAction(string fullPath)
    {
        if (!TryGetArticleNumber(fullPath, out string? articleNumber))
            return;

        PendingAction action = _pendingActions.AddOrUpdate(
            articleNumber,
            _ => new PendingAction(1),
            (_, existing) => new PendingAction(existing.Version + 1));

        _ = ProcessPendingActionAsync(articleNumber, action);
    }

    private async Task ProcessPendingActionAsync(string articleNumber, PendingAction expectedAction)
    {
        await Task.Delay(_options.WatcherDebounceMilliseconds);

        if (!_pendingActions.TryGetValue(articleNumber, out PendingAction? currentAction) ||
            currentAction.Version != expectedAction.Version)
        {
            return;
        }

        _pendingActions.TryRemove(articleNumber, out _);
        await SyncArticleAsync(articleNumber, existingRecords: null, CancellationToken.None, forceStabilityCheck: true);
    }

    private async Task<ArticleSyncOutcome> SyncArticleAsync(
        string articleNumber,
        IReadOnlyList<ImageSyncRecord>? existingRecords,
        CancellationToken ct,
        bool forceStabilityCheck)
    {
        if (string.IsNullOrWhiteSpace(articleNumber))
            return ArticleSyncOutcome.Skipped;

        SemaphoreSlim articleLock = _articleLocks.GetOrAdd(articleNumber, _ => new SemaphoreSlim(1, 1));
        await articleLock.WaitAsync(ct);

        try
        {
            if (existingRecords is null)
            {
                var recordsResult = await ImageSyncRepository.GetByArticleNumberAsync(articleNumber, ct);
                if (!recordsResult.Success)
                {
                    ConsoleHelpers.WriteError($"[DB] Cannot load records for article {articleNumber}: {recordsResult.ErrorMessage}");
                    return ArticleSyncOutcome.Failed;
                }

                existingRecords = recordsResult.Items;
            }

            List<SourceImage>? sourceImages = await LoadArticleSourceImagesAsync(articleNumber, forceStabilityCheck, ct);
            if (sourceImages is null)
                return ArticleSyncOutcome.Skipped;

            IReadOnlyList<PlannedArticleImage> plannedImages = BuildPlannedImages(articleNumber, sourceImages);
            if (!ShouldProcessArticle(articleNumber, plannedImages, existingRecords))
                return ArticleSyncOutcome.Skipped;

            foreach (PlannedArticleImage plannedImage in plannedImages)
            {
                ImageResizer.ProcessFile(
                    plannedImage.SourceFullPath,
                    _options.DestinationRoot,
                    plannedImage.TargetRelativePath,
                    plannedImage.SourceLastWriteUtc,
                    _options.Sizes);
            }

            var replaceResult = await ImageSyncRepository.ReplaceArticleAsync(
                articleNumber,
                plannedImages.Select(ToUpsert).ToArray(),
                ct);

            if (!replaceResult.Success || replaceResult.Item != true)
            {
                ConsoleHelpers.WriteError($"[DB] Cannot save sync result for article {articleNumber}: {replaceResult.ErrorMessage}");
                return ArticleSyncOutcome.Failed;
            }

            int removedOutputs = CleanupUnexpectedOutputs(
                articleNumber,
                plannedImages.Select(x => x.TargetRelativePath).ToHashSet(StringComparer.OrdinalIgnoreCase));

            if (plannedImages.Count == 0)
            {
                if (existingRecords.Count > 0 || removedOutputs > 0)
                {
                    ConsoleHelpers.WriteWarning($"[DELETE] Article {articleNumber} removed because source files no longer exist.");
                    return ArticleSyncOutcome.Deleted;
                }

                return ArticleSyncOutcome.Skipped;
            }

            ConsoleHelpers.WriteSuccess($"[SYNC] {articleNumber} | {plannedImages.Count} images");
            return ArticleSyncOutcome.Synced;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            ConsoleHelpers.WriteError($"[ERROR] Article {articleNumber} | {ex.Message}");
            return ArticleSyncOutcome.Failed;
        }
        finally
        {
            articleLock.Release();
        }
    }

    private IEnumerable<string> EnumerateSourceFilesSafe()
    {
        return EnumerateFilesSafe(_options.SourceRoot, IsSupportedImagePath);
    }

    private IEnumerable<string> EnumerateArticleSourceFiles(string articleNumber)
    {
        return EnumerateFilesSafe(Path.Combine(_options.SourceRoot, articleNumber), IsSupportedImagePath);
    }

    private IEnumerable<string> EnumerateFilesSafe(string startDirectory, Func<string, bool> includeFile)
    {
        if (string.IsNullOrWhiteSpace(startDirectory) || !Directory.Exists(startDirectory))
            yield break;

        var directories = new Stack<string>();
        directories.Push(startDirectory);

        while (directories.Count > 0)
        {
            string currentDirectory = directories.Pop();

            string[] childDirectories;
            try
            {
                childDirectories = Directory.GetDirectories(currentDirectory);
            }
            catch (Exception ex)
            {
                ConsoleHelpers.WriteWarning($"[WARN] Cannot read directory '{currentDirectory}': {ex.Message}");
                continue;
            }

            foreach (string childDirectory in childDirectories)
                directories.Push(childDirectory);

            string[] files;
            try
            {
                files = Directory.GetFiles(currentDirectory);
            }
            catch (Exception ex)
            {
                ConsoleHelpers.WriteWarning($"[WARN] Cannot read files from '{currentDirectory}': {ex.Message}");
                continue;
            }

            foreach (string file in files)
            {
                if (includeFile(file))
                    yield return file;
            }
        }
    }

    private bool IsSupportedImagePath(string path)
    {
        if (string.IsNullOrWhiteSpace(path))
            return false;

        string extension = Path.GetExtension(path);
        return _options.SupportedExtensions.Contains(extension);
    }

    private HashSet<string> CollectSourceArticleNumbers(out int sourceImageCount)
    {
        var articleNumbers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        sourceImageCount = 0;

        foreach (string fullPath in EnumerateSourceFilesSafe())
        {
            sourceImageCount++;

            string? articleNumber = ExtractArticleNumber(ToRelativePath(fullPath));
            if (!string.IsNullOrWhiteSpace(articleNumber))
                articleNumbers.Add(articleNumber);
        }

        return articleNumbers;
    }

    private bool TryGetArticleNumber(string fullPath, out string articleNumber)
    {
        articleNumber = string.Empty;

        if (string.IsNullOrWhiteSpace(fullPath))
            return false;

        string normalizedPath = Path.GetFullPath(fullPath);
        string relativePath = Path.GetRelativePath(_options.SourceRoot, normalizedPath)
            .Replace('\\', '/');

        if (relativePath.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath))
            return false;

        string? extracted = ExtractArticleNumber(relativePath);
        if (string.IsNullOrWhiteSpace(extracted))
            return false;

        articleNumber = extracted;
        return true;
    }

    private string ToRelativePath(string fullPath)
    {
        return Path.GetRelativePath(_options.SourceRoot, fullPath)
            .Replace('\\', '/');
    }

    private async Task<List<SourceImage>?> LoadArticleSourceImagesAsync(
        string articleNumber,
        bool forceStabilityCheck,
        CancellationToken ct)
    {
        foreach (string fullPath in EnumerateArticleSourceFiles(articleNumber))
        {
            var sourceInfo = new FileInfo(fullPath);
            if (!sourceInfo.Exists)
                continue;

            if ((forceStabilityCheck || ShouldWaitForStability(sourceInfo)) &&
                !await WaitUntilFileStableAsync(fullPath, ct))
            {
                ConsoleHelpers.WriteWarning($"[SKIP] Article {articleNumber} is not stable yet.");
                return null;
            }
        }

        var sourceImages = new List<SourceImage>();
        foreach (string fullPath in EnumerateArticleSourceFiles(articleNumber))
        {
            var sourceInfo = new FileInfo(fullPath);
            if (!sourceInfo.Exists)
                continue;

            string sourceFileName = sourceInfo.Name;
            sourceImages.Add(
                new SourceImage(
                    fullPath,
                    ToRelativePath(fullPath),
                    sourceFileName,
                    Path.GetFileNameWithoutExtension(sourceFileName),
                    Path.GetExtension(sourceFileName).ToLowerInvariant(),
                    sourceInfo.Length,
                    sourceInfo.LastWriteTimeUtc,
                    DatabaseTime.FromUtc(sourceInfo.LastWriteTimeUtc)));
        }

        return sourceImages;
    }

    private IReadOnlyList<PlannedArticleImage> BuildPlannedImages(
        string articleNumber,
        IReadOnlyList<SourceImage> sourceImages)
    {
        DateTime updatedAt = DatabaseTime.Now();

        List<SourceImage> orderedImages = sourceImages
            .OrderBy(x => string.Equals(x.SourceStem, articleNumber, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
            .ThenBy(x => x.SourceStem, StringComparer.OrdinalIgnoreCase)
            .ThenBy(x => x.SourceFileName, StringComparer.OrdinalIgnoreCase)
            .ThenBy(x => x.SourceRelativePath, StringComparer.OrdinalIgnoreCase)
            .ToList();

        var plannedImages = new List<PlannedArticleImage>(orderedImages.Count);
        for (int imageNo = 0; imageNo < orderedImages.Count; imageNo++)
        {
            SourceImage sourceImage = orderedImages[imageNo];
            string targetFileName = $"{articleNumber}_{imageNo}.jpg";

            plannedImages.Add(
                new PlannedArticleImage(
                    articleNumber,
                    imageNo,
                    sourceImage.SourceFullPath,
                    sourceImage.SourceRelativePath,
                    sourceImage.SourceFileName,
                    sourceImage.SourceStem,
                    sourceImage.SourceExtension,
                    targetFileName,
                    $"{articleNumber}/{targetFileName}",
                    sourceImage.SourceLength,
                    sourceImage.SourceLastWriteUtc,
                    sourceImage.SourceLastWriteAt,
                    updatedAt));
        }

        return plannedImages;
    }

    private bool ShouldProcessArticle(
        string articleNumber,
        IReadOnlyList<PlannedArticleImage> plannedImages,
        IReadOnlyList<ImageSyncRecord> existingRecords)
    {
        if (plannedImages.Count != existingRecords.Count)
            return true;

        var existingBySourcePath = new Dictionary<string, ImageSyncRecord>(StringComparer.OrdinalIgnoreCase);
        foreach (ImageSyncRecord existingRecord in existingRecords)
        {
            if (string.IsNullOrWhiteSpace(existingRecord.SourceRelativePath) ||
                !existingBySourcePath.TryAdd(existingRecord.SourceRelativePath, existingRecord))
            {
                return true;
            }
        }

        var expectedTargetPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (PlannedArticleImage plannedImage in plannedImages)
        {
            expectedTargetPaths.Add(plannedImage.TargetRelativePath);

            if (!existingBySourcePath.TryGetValue(plannedImage.SourceRelativePath, out ImageSyncRecord? existingRecord))
                return true;

            if (!MatchesExistingRecord(plannedImage, existingRecord))
                return true;
        }

        if (!AreAllDestinationFilesPresent(expectedTargetPaths))
            return true;

        return ArticleOutputContainsUnexpectedFiles(articleNumber, expectedTargetPaths);
    }

    private static bool MatchesExistingRecord(PlannedArticleImage plannedImage, ImageSyncRecord existingRecord)
    {
        return existingRecord.ImageNo == plannedImage.ImageNo &&
               existingRecord.SourceLength == plannedImage.SourceLength &&
               string.Equals(existingRecord.ArticleNumber, plannedImage.ArticleNumber, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.SourceRelativePath, plannedImage.SourceRelativePath, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.SourceFileName, plannedImage.SourceFileName, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.SourceStem, plannedImage.SourceStem, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.SourceExtension, plannedImage.SourceExtension, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.TargetFileName, plannedImage.TargetFileName, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(existingRecord.TargetRelativePath, plannedImage.TargetRelativePath, StringComparison.OrdinalIgnoreCase) &&
               (existingRecord.SourceLastWriteAt - plannedImage.SourceLastWriteAt).Duration() <= TimestampTolerance;
    }

    private string BuildDestinationPath(ImageSize size, string targetRelativePath)
    {
        string safeRelativePath = targetRelativePath.Replace('/', Path.DirectorySeparatorChar);
        return Path.Combine(_options.DestinationRoot, size.FolderName, safeRelativePath);
    }

    private bool AreAllDestinationFilesPresent(IReadOnlyCollection<string> targetRelativePaths)
    {
        foreach (string targetRelativePath in targetRelativePaths)
        {
            foreach (ImageSize size in _options.Sizes)
            {
                if (!File.Exists(BuildDestinationPath(size, targetRelativePath)))
                    return false;
            }
        }

        return true;
    }

    private static bool ShouldWaitForStability(FileInfo sourceInfo)
    {
        TimeSpan age = DateTime.UtcNow - sourceInfo.LastWriteTimeUtc;
        return age < RecentWriteThreshold || age < TimeSpan.Zero;
    }

    private bool ArticleOutputContainsUnexpectedFiles(
        string articleNumber,
        IReadOnlyCollection<string> expectedTargetPaths)
    {
        foreach (ImageSize size in _options.Sizes)
        {
            string sizeRoot = Path.Combine(_options.DestinationRoot, size.FolderName);
            string articleDirectory = Path.Combine(sizeRoot, articleNumber);
            if (!Directory.Exists(articleDirectory))
                continue;

            foreach (string fullPath in EnumerateFilesSafe(articleDirectory, _ => true))
            {
                string relativePath = Path.GetRelativePath(sizeRoot, fullPath)
                    .Replace('\\', '/');

                if (!expectedTargetPaths.Contains(relativePath))
                    return true;
            }
        }

        return false;
    }

    private int CleanupUnexpectedOutputs(
        string articleNumber,
        IReadOnlyCollection<string> expectedTargetPaths)
    {
        int removed = 0;

        foreach (ImageSize size in _options.Sizes)
        {
            string sizeRoot = Path.Combine(_options.DestinationRoot, size.FolderName);
            string articleDirectory = Path.Combine(sizeRoot, articleNumber);
            if (!Directory.Exists(articleDirectory))
                continue;

            foreach (string fullPath in EnumerateFilesSafe(articleDirectory, _ => true).ToList())
            {
                string relativePath = Path.GetRelativePath(sizeRoot, fullPath)
                    .Replace('\\', '/');

                if (expectedTargetPaths.Contains(relativePath))
                    continue;

                File.SetAttributes(fullPath, FileAttributes.Normal);
                File.Delete(fullPath);
                removed++;

                string? destinationDirectory = Path.GetDirectoryName(fullPath);
                RemoveEmptyDirectories(destinationDirectory, sizeRoot);
            }
        }

        return removed;
    }

    private static ImageSyncRecordUpsert ToUpsert(PlannedArticleImage plannedImage)
    {
        return new ImageSyncRecordUpsert
        {
            ArticleNumber = plannedImage.ArticleNumber,
            ImageNo = plannedImage.ImageNo,
            SourceRelativePath = plannedImage.SourceRelativePath,
            SourceFileName = plannedImage.SourceFileName,
            SourceStem = plannedImage.SourceStem,
            SourceExtension = plannedImage.SourceExtension,
            SourceLength = plannedImage.SourceLength,
            SourceLastWriteAt = plannedImage.SourceLastWriteAt,
            UpdatedAt = plannedImage.UpdatedAt
        };
    }

    private async Task<bool> WaitUntilFileStableAsync(string path, CancellationToken ct)
    {
        long lastLength = -1;
        int stableCount = 0;

        for (int attempt = 0; attempt < _options.StableFileChecks * 5; attempt++)
        {
            ct.ThrowIfCancellationRequested();

            try
            {
                using var stream = new FileStream(
                    path,
                    FileMode.Open,
                    FileAccess.Read,
                    FileShare.ReadWrite | FileShare.Delete);

                long length = stream.Length;

                if (length == lastLength)
                {
                    stableCount++;
                    if (stableCount >= _options.StableFileChecks)
                        return true;
                }
                else
                {
                    lastLength = length;
                    stableCount = 1;
                }
            }
            catch
            {
                stableCount = 0;
            }

            await Task.Delay(_options.StableFileDelayMilliseconds, ct);
        }

        return false;
    }

    private static string? ExtractArticleNumber(string relativePath)
    {
        if (string.IsNullOrWhiteSpace(relativePath))
            return null;

        string[] parts = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        return parts.Length == 0 ? null : parts[0];
    }

    private static void RemoveEmptyDirectories(string? startDirectory, string stopDirectory)
    {
        string normalizedStopDirectory = Path.GetFullPath(stopDirectory)
            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

        string? current = startDirectory;
        while (!string.IsNullOrWhiteSpace(current))
        {
            string normalizedCurrent = Path.GetFullPath(current)
                .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

            if (string.Equals(normalizedCurrent, normalizedStopDirectory, StringComparison.OrdinalIgnoreCase))
                break;

            if (Directory.Exists(normalizedCurrent) &&
                !Directory.EnumerateFileSystemEntries(normalizedCurrent).Any())
            {
                Directory.Delete(normalizedCurrent, false);
                current = Path.GetDirectoryName(normalizedCurrent);
                continue;
            }

            break;
        }
    }

    private sealed record PendingAction(int Version);
    private sealed record SourceImage(
        string SourceFullPath,
        string SourceRelativePath,
        string SourceFileName,
        string SourceStem,
        string SourceExtension,
        long SourceLength,
        DateTime SourceLastWriteUtc,
        DateTime SourceLastWriteAt);

    private sealed record PlannedArticleImage(
        string ArticleNumber,
        int ImageNo,
        string SourceFullPath,
        string SourceRelativePath,
        string SourceFileName,
        string SourceStem,
        string SourceExtension,
        string TargetFileName,
        string TargetRelativePath,
        long SourceLength,
        DateTime SourceLastWriteUtc,
        DateTime SourceLastWriteAt,
        DateTime UpdatedAt);

    private enum ArticleSyncOutcome
    {
        Skipped,
        Synced,
        Deleted,
        Failed
    }
}
