using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Common.Json;
using StellaOps.Feedser.Source.Common.Cursors;
using StellaOps.Feedser.Source.Nvd.Configuration;
using StellaOps.Feedser.Source.Nvd.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.Feedser.Storage.Mongo.ChangeHistory;
using StellaOps.Plugin;
using Json.Schema;

namespace StellaOps.Feedser.Source.Nvd;

public sealed class NvdConnector : IFeedConnector
{
    private readonly SourceFetchService _fetchService;
    private readonly RawDocumentStorage _rawDocumentStorage;
    private readonly IDocumentStore _documentStore;
    private readonly IDtoStore _dtoStore;
    private readonly IAdvisoryStore _advisoryStore;
    private readonly IChangeHistoryStore _changeHistoryStore;
    private readonly ISourceStateRepository _stateRepository;
    private readonly IJsonSchemaValidator _schemaValidator;
    private readonly NvdOptions _options;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<NvdConnector> _logger;
    private readonly NvdDiagnostics _diagnostics;

    private static readonly JsonSchema Schema = NvdSchemaProvider.Schema;

    public NvdConnector(
        SourceFetchService fetchService,
        RawDocumentStorage rawDocumentStorage,
        IDocumentStore documentStore,
        IDtoStore dtoStore,
        IAdvisoryStore advisoryStore,
        IChangeHistoryStore changeHistoryStore,
        ISourceStateRepository stateRepository,
        IJsonSchemaValidator schemaValidator,
        IOptions<NvdOptions> options,
        NvdDiagnostics diagnostics,
        TimeProvider? timeProvider,
        ILogger<NvdConnector> 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));
        _changeHistoryStore = changeHistoryStore ?? throw new ArgumentNullException(nameof(changeHistoryStore));
        _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
        _schemaValidator = schemaValidator ?? throw new ArgumentNullException(nameof(schemaValidator));
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _options.Validate();
        _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
        _timeProvider = timeProvider ?? TimeProvider.System;
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public string SourceName => NvdConnectorPlugin.SourceName;

    public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        var now = _timeProvider.GetUtcNow();

        var windowOptions = new TimeWindowCursorOptions
        {
            WindowSize = _options.WindowSize,
            Overlap = _options.WindowOverlap,
            InitialBackfill = _options.InitialBackfill,
        };

        var window = TimeWindowCursorPlanner.GetNextWindow(now, cursor.Window, windowOptions);
        var requestUri = BuildRequestUri(window);

        var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["windowStart"] = window.Start.ToString("O"),
            ["windowEnd"] = window.End.ToString("O"),
        };
        metadata["startIndex"] = "0";

        try
        {
            _diagnostics.FetchAttempt();

            var result = await _fetchService.FetchAsync(
                new SourceFetchRequest(
                    NvdOptions.HttpClientName,
                    SourceName,
                    requestUri)
                {
                    Metadata = metadata
                },
                cancellationToken).ConfigureAwait(false);

            if (result.IsNotModified)
            {
                _diagnostics.FetchUnchanged();
                _logger.LogDebug("NVD window {Start} - {End} returned 304", window.Start, window.End);
                await UpdateCursorAsync(cursor.WithWindow(window), cancellationToken).ConfigureAwait(false);
                return;
            }

            if (!result.IsSuccess || result.Document is null)
            {
                _diagnostics.FetchFailure();
                return;
            }

            _diagnostics.FetchDocument();

            var pendingDocuments = new HashSet<Guid>(cursor.PendingDocuments)
            {
                result.Document.Id
            };

            var additionalDocuments = await FetchAdditionalPagesAsync(
                window,
                metadata,
                result.Document,
                cancellationToken).ConfigureAwait(false);

            foreach (var documentId in additionalDocuments)
            {
                pendingDocuments.Add(documentId);
            }

            var updated = cursor
                .WithWindow(window)
                .WithPendingDocuments(pendingDocuments)
                .WithPendingMappings(cursor.PendingMappings);

            await UpdateCursorAsync(updated, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _diagnostics.FetchFailure();
            _logger.LogError(ex, "NVD fetch failed for {Uri}", requestUri);
            await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
            throw;
        }
    }

    public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        if (cursor.PendingDocuments.Count == 0)
        {
            return;
        }

        var remainingFetch = cursor.PendingDocuments.ToList();
        var pendingMapping = cursor.PendingMappings.ToList();

        foreach (var documentId in cursor.PendingDocuments)
        {
            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (document is null)
            {
                _diagnostics.ParseFailure();
                remainingFetch.Remove(documentId);
                pendingMapping.Remove(documentId);
                continue;
            }

            if (!document.GridFsId.HasValue)
            {
                _logger.LogWarning("Document {DocumentId} is missing GridFS content; skipping", documentId);
                _diagnostics.ParseFailure();
                remainingFetch.Remove(documentId);
                pendingMapping.Remove(documentId);
                continue;
            }

            var rawBytes = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
            try
            {
                using var jsonDocument = JsonDocument.Parse(rawBytes);
                try
                {
                    _schemaValidator.Validate(jsonDocument, Schema, document.Uri);
                }
                catch (JsonSchemaValidationException ex)
                {
                    _logger.LogWarning(ex, "NVD schema validation failed for document {DocumentId} ({Uri})", document.Id, document.Uri);
                    await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                    remainingFetch.Remove(documentId);
                    pendingMapping.Remove(documentId);
                    _diagnostics.ParseQuarantine();
                    continue;
                }

                var sanitized = JsonSerializer.Serialize(jsonDocument.RootElement);
                var payload = BsonDocument.Parse(sanitized);

                var dtoRecord = new DtoRecord(
                    Guid.NewGuid(),
                    document.Id,
                    SourceName,
                    "nvd.cve.v2",
                    payload,
                    _timeProvider.GetUtcNow());

                await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
                _diagnostics.ParseSuccess();

                remainingFetch.Remove(documentId);
                if (!pendingMapping.Contains(documentId))
                {
                    pendingMapping.Add(documentId);
                }
            }
            catch (JsonException ex)
            {
                _logger.LogWarning(ex, "Failed to parse NVD JSON payload for document {DocumentId} ({Uri})", document.Id, document.Uri);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remainingFetch.Remove(documentId);
                pendingMapping.Remove(documentId);
                _diagnostics.ParseFailure();
            }
        }

        var updatedCursor = cursor
            .WithPendingDocuments(remainingFetch)
            .WithPendingMappings(pendingMapping);

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

    public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
    {
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        if (cursor.PendingMappings.Count == 0)
        {
            return;
        }

        var pendingMapping = cursor.PendingMappings.ToList();
        var now = _timeProvider.GetUtcNow();

        foreach (var documentId in cursor.PendingMappings)
        {
            var dto = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);

            if (dto is null || document is null)
            {
                pendingMapping.Remove(documentId);
                continue;
            }

            var json = dto.Payload.ToJson(new MongoDB.Bson.IO.JsonWriterSettings
            {
                OutputMode = MongoDB.Bson.IO.JsonOutputMode.RelaxedExtendedJson,
            });

            using var jsonDocument = JsonDocument.Parse(json);
            var advisories = NvdMapper.Map(jsonDocument, document, now)
                .GroupBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
                .Select(static group => group.First())
                .ToArray();

            var mappedCount = 0L;
            foreach (var advisory in advisories)
            {
                if (string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
                {
                    _logger.LogWarning("Skipping advisory with missing key for document {DocumentId} ({Uri})", document.Id, document.Uri);
                    continue;
                }

                var previous = await _advisoryStore.FindAsync(advisory.AdvisoryKey, cancellationToken).ConfigureAwait(false);
                await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
                if (previous is not null)
                {
                    await RecordChangeHistoryAsync(advisory, previous, document, now, cancellationToken).ConfigureAwait(false);
                }
                mappedCount++;
            }

            if (mappedCount > 0)
            {
                _diagnostics.MapSuccess(mappedCount);
            }

            await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
            pendingMapping.Remove(documentId);
        }

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

    private async Task<IReadOnlyCollection<Guid>> FetchAdditionalPagesAsync(
        TimeWindow window,
        IReadOnlyDictionary<string, string> baseMetadata,
        DocumentRecord firstDocument,
        CancellationToken cancellationToken)
    {
        if (firstDocument.GridFsId is null)
        {
            return Array.Empty<Guid>();
        }

        byte[] rawBytes;
        try
        {
            rawBytes = await _rawDocumentStorage.DownloadAsync(firstDocument.GridFsId.Value, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Unable to download NVD first page {DocumentId} to evaluate pagination", firstDocument.Id);
            return Array.Empty<Guid>();
        }

        try
        {
            using var jsonDocument = JsonDocument.Parse(rawBytes);
            var root = jsonDocument.RootElement;

            if (!TryReadInt32(root, "totalResults", out var totalResults) || !TryReadInt32(root, "resultsPerPage", out var resultsPerPage))
            {
                return Array.Empty<Guid>();
            }

            if (resultsPerPage <= 0 || totalResults <= resultsPerPage)
            {
                return Array.Empty<Guid>();
            }

            var fetchedDocuments = new List<Guid>();

            foreach (var startIndex in PaginationPlanner.EnumerateAdditionalPages(totalResults, resultsPerPage))
            {
                var metadata = new Dictionary<string, string>(StringComparer.Ordinal);
                foreach (var kvp in baseMetadata)
                {
                    metadata[kvp.Key] = kvp.Value;
                }
                metadata["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);

                var request = new SourceFetchRequest(
                    NvdOptions.HttpClientName,
                    SourceName,
                    BuildRequestUri(window, startIndex))
                {
                    Metadata = metadata
                };

                SourceFetchResult pageResult;
                try
                {
                    _diagnostics.FetchAttempt();
                    pageResult = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _diagnostics.FetchFailure();
                    _logger.LogError(ex, "NVD fetch failed for page starting at {StartIndex}", startIndex);
                    throw;
                }

                if (pageResult.IsNotModified)
                {
                    _diagnostics.FetchUnchanged();
                    continue;
                }

                if (!pageResult.IsSuccess || pageResult.Document is null)
                {
                    _diagnostics.FetchFailure();
                    _logger.LogWarning("NVD fetch for page starting at {StartIndex} returned status {Status}", startIndex, pageResult.StatusCode);
                    continue;
                }

                _diagnostics.FetchDocument();
                fetchedDocuments.Add(pageResult.Document.Id);
            }

            return fetchedDocuments;
        }
        catch (JsonException ex)
        {
            _logger.LogWarning(ex, "Failed to parse NVD first page {DocumentId} while determining pagination", firstDocument.Id);
            return Array.Empty<Guid>();
        }
    }

    private static bool TryReadInt32(JsonElement root, string propertyName, out int value)
    {
        value = 0;
        if (!root.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number)
        {
            return false;
        }

        if (property.TryGetInt32(out var intValue))
        {
            value = intValue;
            return true;
        }

        if (property.TryGetInt64(out var longValue))
        {
            if (longValue > int.MaxValue)
            {
                value = int.MaxValue;
                return true;
            }

            value = (int)longValue;
            return true;
        }

        return false;
    }

    private async Task RecordChangeHistoryAsync(
        Advisory current,
        Advisory previous,
        DocumentRecord document,
        DateTimeOffset capturedAt,
        CancellationToken cancellationToken)
    {
        if (current.Equals(previous))
        {
            return;
        }

        var currentSnapshot = SnapshotSerializer.ToSnapshot(current);
        var previousSnapshot = SnapshotSerializer.ToSnapshot(previous);

        if (string.Equals(currentSnapshot, previousSnapshot, StringComparison.Ordinal))
        {
            return;
        }

        var changes = ComputeChanges(previousSnapshot, currentSnapshot);
        if (changes.Count == 0)
        {
            return;
        }

        var documentHash = string.IsNullOrWhiteSpace(document.Sha256)
            ? ComputeHash(currentSnapshot)
            : document.Sha256;

        var record = new ChangeHistoryRecord(
            Guid.NewGuid(),
            SourceName,
            current.AdvisoryKey,
            document.Id,
            documentHash,
            ComputeHash(currentSnapshot),
            ComputeHash(previousSnapshot),
            currentSnapshot,
            previousSnapshot,
            changes,
            capturedAt);

        await _changeHistoryStore.AddAsync(record, cancellationToken).ConfigureAwait(false);
    }

    private static IReadOnlyList<ChangeHistoryFieldChange> ComputeChanges(string previousSnapshot, string currentSnapshot)
    {
        using var previousDocument = JsonDocument.Parse(previousSnapshot);
        using var currentDocument = JsonDocument.Parse(currentSnapshot);

        var previousRoot = previousDocument.RootElement;
        var currentRoot = currentDocument.RootElement;
        var fields = new HashSet<string>(StringComparer.Ordinal);

        foreach (var property in previousRoot.EnumerateObject())
        {
            fields.Add(property.Name);
        }

        foreach (var property in currentRoot.EnumerateObject())
        {
            fields.Add(property.Name);
        }

        var changes = new List<ChangeHistoryFieldChange>();
        foreach (var field in fields.OrderBy(static name => name, StringComparer.Ordinal))
        {
            var hasPrevious = previousRoot.TryGetProperty(field, out var previousValue);
            var hasCurrent = currentRoot.TryGetProperty(field, out var currentValue);

            if (!hasPrevious && hasCurrent)
            {
                changes.Add(new ChangeHistoryFieldChange(field, "Added", null, SerializeElement(currentValue)));
                continue;
            }

            if (hasPrevious && !hasCurrent)
            {
                changes.Add(new ChangeHistoryFieldChange(field, "Removed", SerializeElement(previousValue), null));
                continue;
            }

            if (hasPrevious && hasCurrent && !JsonElement.DeepEquals(previousValue, currentValue))
            {
                changes.Add(new ChangeHistoryFieldChange(field, "Modified", SerializeElement(previousValue), SerializeElement(currentValue)));
            }
        }

        return changes;
    }

    private static string SerializeElement(JsonElement element)
        => JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = false });

    private static string ComputeHash(string snapshot)
    {
        var bytes = Encoding.UTF8.GetBytes(snapshot);
        var hash = SHA256.HashData(bytes);
        return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
    }

    private async Task<NvdCursor> GetCursorAsync(CancellationToken cancellationToken)
    {
        var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
        return NvdCursor.FromBsonDocument(record?.Cursor);
    }

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

    private Uri BuildRequestUri(TimeWindow window, int startIndex = 0)
    {
        var builder = new UriBuilder(_options.BaseEndpoint);

        var parameters = new Dictionary<string, string>
        {
            ["lastModifiedStartDate"] = window.Start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
            ["lastModifiedEndDate"] = window.End.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
            ["resultsPerPage"] = "2000",
        };

        if (startIndex > 0)
        {
            parameters["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);
        }

        builder.Query = string.Join("&", parameters.Select(static kvp => $"{System.Net.WebUtility.UrlEncode(kvp.Key)}={System.Net.WebUtility.UrlEncode(kvp.Value)}"));
        return builder.Uri;
    }
}
