using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Distro.Debian.Configuration;
using StellaOps.Feedser.Source.Distro.Debian.Internal;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Storage.Mongo.Dtos;
using StellaOps.Plugin;

namespace StellaOps.Feedser.Source.Distro.Debian;

public sealed class DebianConnector : IFeedConnector
{
    private const string SchemaVersion = "debian.v1";

    private readonly SourceFetchService _fetchService;
    private readonly RawDocumentStorage _rawDocumentStorage;
    private readonly IDocumentStore _documentStore;
    private readonly IDtoStore _dtoStore;
    private readonly IAdvisoryStore _advisoryStore;
    private readonly ISourceStateRepository _stateRepository;
    private readonly DebianOptions _options;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<DebianConnector> _logger;

    private static readonly Action<ILogger, string, int, Exception?> LogMapped =
        LoggerMessage.Define<string, int>(
            LogLevel.Information,
            new EventId(1, "DebianMapped"),
            "Debian advisory {AdvisoryId} mapped with {AffectedCount} packages");

    public DebianConnector(
        SourceFetchService fetchService,
        RawDocumentStorage rawDocumentStorage,
        IDocumentStore documentStore,
        IDtoStore dtoStore,
        IAdvisoryStore advisoryStore,
        ISourceStateRepository stateRepository,
        IOptions<DebianOptions> options,
        TimeProvider? timeProvider,
        ILogger<DebianConnector> logger)
    {
        _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
        _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
        _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
        _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
        _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
        _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
        _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
        _options.Validate();
        _timeProvider = timeProvider ?? TimeProvider.System;
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public string SourceName => DebianConnectorPlugin.SourceName;

    public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(services);

        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        var now = _timeProvider.GetUtcNow();

        var pendingDocuments = new HashSet<Guid>(cursor.PendingDocuments);
        var pendingMappings = new HashSet<Guid>(cursor.PendingMappings);
        var fetchCache = new Dictionary<string, DebianFetchCacheEntry>(cursor.FetchCache, StringComparer.OrdinalIgnoreCase);
        var touchedResources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        var listUri = _options.ListEndpoint;
        var listKey = listUri.ToString();
        touchedResources.Add(listKey);

        var existingList = await _documentStore.FindBySourceAndUriAsync(SourceName, listKey, cancellationToken).ConfigureAwait(false);
        cursor.TryGetCache(listKey, out var cachedListEntry);

        var listRequest = new SourceFetchRequest(DebianOptions.HttpClientName, SourceName, listUri)
        {
            Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["type"] = "index"
            },
            AcceptHeaders = new[] { "text/plain", "text/plain; charset=utf-8" },
            TimeoutOverride = _options.FetchTimeout,
            ETag = existingList?.Etag ?? cachedListEntry?.ETag,
            LastModified = existingList?.LastModified ?? cachedListEntry?.LastModified,
        };

        SourceFetchResult listResult;
        try
        {
            listResult = await _fetchService.FetchAsync(listRequest, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Debian list fetch failed");
            await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
            throw;
        }

        var lastPublished = cursor.LastPublished ?? (now - _options.InitialBackfill);
        var processedIds = new HashSet<string>(cursor.ProcessedAdvisoryIds, StringComparer.OrdinalIgnoreCase);
        var newProcessedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var maxPublished = cursor.LastPublished ?? DateTimeOffset.MinValue;
        var processedUpdated = false;

        if (listResult.IsNotModified)
        {
            if (existingList is not null)
            {
                fetchCache[listKey] = DebianFetchCacheEntry.FromDocument(existingList);
            }
        }
        else if (listResult.IsSuccess && listResult.Document is not null)
        {
            fetchCache[listKey] = DebianFetchCacheEntry.FromDocument(listResult.Document);

            if (!listResult.Document.GridFsId.HasValue)
            {
                _logger.LogWarning("Debian list document {DocumentId} missing GridFS payload", listResult.Document.Id);
            }
            else
            {
                byte[] bytes;
                try
                {
                    bytes = await _rawDocumentStorage.DownloadAsync(listResult.Document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to download Debian list document {DocumentId}", listResult.Document.Id);
                    throw;
                }

                var text = System.Text.Encoding.UTF8.GetString(bytes);
                var entries = DebianListParser.Parse(text);
                if (entries.Count > 0)
                {
                    var windowStart = (cursor.LastPublished ?? (now - _options.InitialBackfill)) - _options.ResumeOverlap;
                    if (windowStart < DateTimeOffset.UnixEpoch)
                    {
                        windowStart = DateTimeOffset.UnixEpoch;
                    }

                    ProvenanceDiagnostics.ReportResumeWindow(SourceName, windowStart, _logger);

                    var candidates = entries
                        .Where(entry => entry.Published >= windowStart)
                        .OrderBy(entry => entry.Published)
                        .ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
                        .ToList();

                    if (candidates.Count == 0)
                    {
                        candidates = entries
                            .OrderByDescending(entry => entry.Published)
                            .ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
                            .Take(_options.MaxAdvisoriesPerFetch)
                            .OrderBy(entry => entry.Published)
                            .ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
                            .ToList();
                    }
                    else if (candidates.Count > _options.MaxAdvisoriesPerFetch)
                    {
                        candidates = candidates
                            .OrderByDescending(entry => entry.Published)
                            .ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
                            .Take(_options.MaxAdvisoriesPerFetch)
                            .OrderBy(entry => entry.Published)
                            .ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
                            .ToList();
                    }

                    foreach (var entry in candidates)
                    {
                        cancellationToken.ThrowIfCancellationRequested();

                        var detailUri = new Uri(_options.DetailBaseUri, entry.AdvisoryId);
                        var cacheKey = detailUri.ToString();
                        touchedResources.Add(cacheKey);

                        cursor.TryGetCache(cacheKey, out var cachedDetail);
                        if (!fetchCache.TryGetValue(cacheKey, out var cachedInRun))
                        {
                            cachedInRun = cachedDetail;
                        }

                        var metadata = BuildDetailMetadata(entry);
                        var existingDetail = await _documentStore.FindBySourceAndUriAsync(SourceName, cacheKey, cancellationToken).ConfigureAwait(false);

                        var request = new SourceFetchRequest(DebianOptions.HttpClientName, SourceName, detailUri)
                        {
                            Metadata = metadata,
                            AcceptHeaders = new[] { "text/html", "application/xhtml+xml" },
                            TimeoutOverride = _options.FetchTimeout,
                            ETag = existingDetail?.Etag ?? cachedInRun?.ETag,
                            LastModified = existingDetail?.LastModified ?? cachedInRun?.LastModified,
                        };

                        SourceFetchResult result;
                        try
                        {
                            result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "Failed to fetch Debian advisory {AdvisoryId}", entry.AdvisoryId);
                            await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
                            throw;
                        }

                        if (result.IsNotModified)
                        {
                            if (existingDetail is not null)
                            {
                                fetchCache[cacheKey] = DebianFetchCacheEntry.FromDocument(existingDetail);
                                if (string.Equals(existingDetail.Status, DocumentStatuses.Mapped, StringComparison.Ordinal))
                                {
                                    pendingDocuments.Remove(existingDetail.Id);
                                    pendingMappings.Remove(existingDetail.Id);
                                }
                            }

                            continue;
                        }

                        if (!result.IsSuccess || result.Document is null)
                        {
                            continue;
                        }

                        fetchCache[cacheKey] = DebianFetchCacheEntry.FromDocument(result.Document);
                        pendingDocuments.Add(result.Document.Id);
                        pendingMappings.Remove(result.Document.Id);

                        if (_options.RequestDelay > TimeSpan.Zero)
                        {
                            try
                            {
                                await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
                            }
                            catch (TaskCanceledException)
                            {
                                break;
                            }
                        }

                        if (entry.Published > maxPublished)
                        {
                            maxPublished = entry.Published;
                            newProcessedIds.Clear();
                            processedUpdated = true;
                        }

                        if (entry.Published == maxPublished)
                        {
                            newProcessedIds.Add(entry.AdvisoryId);
                            processedUpdated = true;
                        }
                    }
                }
            }
        }

        if (fetchCache.Count > 0 && touchedResources.Count > 0)
        {
            var stale = fetchCache.Keys.Where(key => !touchedResources.Contains(key)).ToArray();
            foreach (var key in stale)
            {
                fetchCache.Remove(key);
            }
        }

        if (!processedUpdated && cursor.LastPublished.HasValue)
        {
            maxPublished = cursor.LastPublished.Value;
            newProcessedIds = new HashSet<string>(cursor.ProcessedAdvisoryIds, StringComparer.OrdinalIgnoreCase);
        }

        var updatedCursor = cursor
            .WithPendingDocuments(pendingDocuments)
            .WithPendingMappings(pendingMappings)
            .WithFetchCache(fetchCache);

        if (processedUpdated && maxPublished > DateTimeOffset.MinValue)
        {
            updatedCursor = updatedCursor.WithProcessed(maxPublished, newProcessedIds);
        }

        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
    }

    public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(services);

        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        if (cursor.PendingDocuments.Count == 0)
        {
            return;
        }

        var remaining = cursor.PendingDocuments.ToList();
        var pendingMappings = cursor.PendingMappings.ToList();

        foreach (var documentId in cursor.PendingDocuments)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (document is null)
            {
                remaining.Remove(documentId);
                continue;
            }

            if (!document.GridFsId.HasValue)
            {
                _logger.LogWarning("Debian document {DocumentId} missing GridFS payload", document.Id);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remaining.Remove(documentId);
                continue;
            }

            var metadata = ExtractMetadata(document);
            if (metadata is null)
            {
                _logger.LogWarning("Debian document {DocumentId} missing required metadata", document.Id);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remaining.Remove(documentId);
                continue;
            }

            byte[] bytes;
            try
            {
                bytes = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to download Debian document {DocumentId}", document.Id);
                throw;
            }

            var html = System.Text.Encoding.UTF8.GetString(bytes);
            DebianAdvisoryDto dto;
            try
            {
                dto = DebianHtmlParser.Parse(html, metadata);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to parse Debian advisory {AdvisoryId}", metadata.AdvisoryId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remaining.Remove(document.Id);
                continue;
            }

            var payload = ToBson(dto);
            var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, SourceName, SchemaVersion, payload, _timeProvider.GetUtcNow());
            await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
            await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);

            remaining.Remove(document.Id);
            if (!pendingMappings.Contains(document.Id))
            {
                pendingMappings.Add(document.Id);
            }
        }

        var updatedCursor = cursor
            .WithPendingDocuments(remaining)
            .WithPendingMappings(pendingMappings);

        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
    }

    public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(services);

        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        if (cursor.PendingMappings.Count == 0)
        {
            return;
        }

        var pendingMappings = cursor.PendingMappings.ToList();

        foreach (var documentId in cursor.PendingMappings)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (dtoRecord is null || document is null)
            {
                pendingMappings.Remove(documentId);
                continue;
            }

            DebianAdvisoryDto dto;
            try
            {
                dto = FromBson(dtoRecord.Payload);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to deserialize Debian DTO for document {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                continue;
            }

            var advisory = DebianMapper.Map(dto, document, _timeProvider.GetUtcNow());
            await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
            await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
            pendingMappings.Remove(documentId);
            LogMapped(_logger, dto.AdvisoryId, advisory.AffectedPackages.Length, null);
        }

        var updatedCursor = cursor.WithPendingMappings(pendingMappings);
        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
    }

    private async Task<DebianCursor> GetCursorAsync(CancellationToken cancellationToken)
    {
        var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
        return state is null ? DebianCursor.Empty : DebianCursor.FromBson(state.Cursor);
    }

    private async Task UpdateCursorAsync(DebianCursor cursor, CancellationToken cancellationToken)
    {
        var document = cursor.ToBsonDocument();
        await _stateRepository.UpdateCursorAsync(SourceName, document, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
    }

    private static Dictionary<string, string> BuildDetailMetadata(DebianListEntry entry)
    {
        var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["debian.id"] = entry.AdvisoryId,
            ["debian.published"] = entry.Published.ToString("O", CultureInfo.InvariantCulture),
            ["debian.title"] = entry.Title,
            ["debian.package"] = entry.SourcePackage
        };

        if (entry.CveIds.Count > 0)
        {
            metadata["debian.cves"] = string.Join(' ', entry.CveIds);
        }

        return metadata;
    }

    private static DebianDetailMetadata? ExtractMetadata(DocumentRecord document)
    {
        if (document.Metadata is null)
        {
            return null;
        }

        if (!document.Metadata.TryGetValue("debian.id", out var id) || string.IsNullOrWhiteSpace(id))
        {
            return null;
        }

        if (!document.Metadata.TryGetValue("debian.published", out var publishedRaw)
            || !DateTimeOffset.TryParse(publishedRaw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published))
        {
            published = document.FetchedAt;
        }

        var title = document.Metadata.TryGetValue("debian.title", out var t) ? t : id;
        var package = document.Metadata.TryGetValue("debian.package", out var pkg) && !string.IsNullOrWhiteSpace(pkg)
            ? pkg
            : id;

        IReadOnlyList<string> cveList = Array.Empty<string>();
        if (document.Metadata.TryGetValue("debian.cves", out var cvesRaw) && !string.IsNullOrWhiteSpace(cvesRaw))
        {
            cveList = cvesRaw
                .Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
                .Where(static s => !string.IsNullOrWhiteSpace(s))
                .Select(static s => s!)
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToArray();
        }

        return new DebianDetailMetadata(
            id.Trim(),
            new Uri(document.Uri, UriKind.Absolute),
            published.ToUniversalTime(),
            title,
            package,
            cveList);
    }

    private static BsonDocument ToBson(DebianAdvisoryDto dto)
    {
        var packages = new BsonArray();
        foreach (var package in dto.Packages)
        {
            var packageDoc = new BsonDocument
            {
                ["package"] = package.Package,
                ["release"] = package.Release,
                ["status"] = package.Status,
            };

            if (!string.IsNullOrWhiteSpace(package.IntroducedVersion))
            {
                packageDoc["introduced"] = package.IntroducedVersion;
            }

            if (!string.IsNullOrWhiteSpace(package.FixedVersion))
            {
                packageDoc["fixed"] = package.FixedVersion;
            }

            if (!string.IsNullOrWhiteSpace(package.LastAffectedVersion))
            {
                packageDoc["last"] = package.LastAffectedVersion;
            }

            if (package.Published.HasValue)
            {
                packageDoc["published"] = package.Published.Value.UtcDateTime;
            }

            packages.Add(packageDoc);
        }

        var references = new BsonArray(dto.References.Select(reference =>
        {
            var doc = new BsonDocument
            {
                ["url"] = reference.Url
            };

            if (!string.IsNullOrWhiteSpace(reference.Kind))
            {
                doc["kind"] = reference.Kind;
            }

            if (!string.IsNullOrWhiteSpace(reference.Title))
            {
                doc["title"] = reference.Title;
            }

            return doc;
        }));

        return new BsonDocument
        {
            ["advisoryId"] = dto.AdvisoryId,
            ["sourcePackage"] = dto.SourcePackage,
            ["title"] = dto.Title,
            ["description"] = dto.Description ?? string.Empty,
            ["cves"] = new BsonArray(dto.CveIds),
            ["packages"] = packages,
            ["references"] = references,
        };
    }

    private static DebianAdvisoryDto FromBson(BsonDocument document)
    {
        var advisoryId = document.GetValue("advisoryId", "").AsString;
        var sourcePackage = document.GetValue("sourcePackage", advisoryId).AsString;
        var title = document.GetValue("title", advisoryId).AsString;
        var description = document.TryGetValue("description", out var desc) ? desc.AsString : null;

        var cves = document.TryGetValue("cves", out var cveArray) && cveArray is BsonArray cvesBson
            ? cvesBson.OfType<BsonValue>()
                .Select(static value => value.ToString())
                .Where(static s => !string.IsNullOrWhiteSpace(s))
                .Select(static s => s!)
                .ToArray()
            : Array.Empty<string>();

        var packages = new List<DebianPackageStateDto>();
        if (document.TryGetValue("packages", out var packageArray) && packageArray is BsonArray packagesBson)
        {
            foreach (var element in packagesBson.OfType<BsonDocument>())
            {
                packages.Add(new DebianPackageStateDto(
                    element.GetValue("package", sourcePackage).AsString,
                    element.GetValue("release", string.Empty).AsString,
                    element.GetValue("status", "unknown").AsString,
                    element.TryGetValue("introduced", out var introducedValue) ? introducedValue.AsString : null,
                    element.TryGetValue("fixed", out var fixedValue) ? fixedValue.AsString : null,
                    element.TryGetValue("last", out var lastValue) ? lastValue.AsString : null,
                    element.TryGetValue("published", out var publishedValue)
                        ? publishedValue.BsonType switch
                        {
                            BsonType.DateTime => DateTime.SpecifyKind(publishedValue.ToUniversalTime(), DateTimeKind.Utc),
                            BsonType.String when DateTimeOffset.TryParse(publishedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
                            _ => (DateTimeOffset?)null,
                        }
                        : null));
            }
        }

        var references = new List<DebianReferenceDto>();
        if (document.TryGetValue("references", out var referenceArray) && referenceArray is BsonArray refBson)
        {
            foreach (var element in refBson.OfType<BsonDocument>())
            {
                references.Add(new DebianReferenceDto(
                    element.GetValue("url", "").AsString,
                    element.TryGetValue("kind", out var kind) ? kind.AsString : null,
                    element.TryGetValue("title", out var titleValue) ? titleValue.AsString : null));
            }
        }

        return new DebianAdvisoryDto(
            advisoryId,
            sourcePackage,
            title,
            description,
            cves,
            packages,
            references);
    }
}
