using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Normalization.Text;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Cve.Configuration;
using StellaOps.Feedser.Source.Cve.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.Cve;

public sealed class CveConnector : IFeedConnector
{
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
    {
        PropertyNameCaseInsensitive = true,
        WriteIndented = false,
    };

    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 CveOptions _options;
    private readonly CveDiagnostics _diagnostics;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<CveConnector> _logger;

    public CveConnector(
        SourceFetchService fetchService,
        RawDocumentStorage rawDocumentStorage,
        IDocumentStore documentStore,
        IDtoStore dtoStore,
        IAdvisoryStore advisoryStore,
        ISourceStateRepository stateRepository,
        IOptions<CveOptions> options,
        CveDiagnostics diagnostics,
        TimeProvider? timeProvider,
        ILogger<CveConnector> 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();
        _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
        _timeProvider = timeProvider ?? TimeProvider.System;
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public string SourceName => CveConnectorPlugin.SourceName;

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

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

        if (!_options.HasCredentials())
        {
            if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
            {
                return;
            }

            _logger.LogWarning("CVEs fetch skipped: no credentials configured and no seed data found at {SeedDirectory}.", _options.SeedDirectory ?? "(seed directory not configured)");
            return;
        }

        var pendingDocuments = cursor.PendingDocuments.ToHashSet();
        var pendingMappings = cursor.PendingMappings.ToHashSet();
        var initialPendingDocuments = pendingDocuments.Count;
        var initialPendingMappings = pendingMappings.Count;
        var documentsFetched = 0;
        var detailFailures = 0;
        var detailUnchanged = 0;
        var listSuccessCount = 0;
        var listUnchangedCount = 0;

        var since = cursor.CurrentWindowStart ?? cursor.LastModifiedExclusive ?? now - _options.InitialBackfill;
        if (since > now)
        {
            since = now;
        }

        var windowEnd = cursor.CurrentWindowEnd ?? now;
        if (windowEnd <= since)
        {
            windowEnd = since + TimeSpan.FromMinutes(1);
        }

        var page = cursor.NextPage <= 0 ? 1 : cursor.NextPage;
        var pagesFetched = 0;
        var hasMorePages = true;
        DateTimeOffset? maxModified = cursor.LastModifiedExclusive;

        while (hasMorePages && pagesFetched < _options.MaxPagesPerFetch)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var requestUri = BuildListRequestUri(since, windowEnd, page, _options.PageSize);
            var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["since"] = since.ToString("O"),
                ["until"] = windowEnd.ToString("O"),
                ["page"] = page.ToString(CultureInfo.InvariantCulture),
                ["pageSize"] = _options.PageSize.ToString(CultureInfo.InvariantCulture),
            };

            SourceFetchContentResult listResult;
            try
            {
                _diagnostics.FetchAttempt();
                listResult = await _fetchService.FetchContentAsync(
                    new SourceFetchRequest(
                        CveOptions.HttpClientName,
                        SourceName,
                        requestUri)
                    {
                        Metadata = metadata,
                        AcceptHeaders = new[] { "application/json" },
                    },
                    cancellationToken).ConfigureAwait(false);
            }
            catch (HttpRequestException ex) when (IsAuthenticationFailure(ex))
            {
                _logger.LogWarning("CVEs fetch requires API credentials ({StatusCode}); falling back to seed data if available.", ex.StatusCode);
                if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
                {
                    return;
                }

                _logger.LogWarning("CVEs fetch aborted: no seed data available (SeedDirectory={SeedDirectory}).", _options.SeedDirectory ?? "(seed directory not configured)");
                return;
            }
            catch (HttpRequestException ex)
            {
                _diagnostics.FetchFailure();
                await _stateRepository.MarkFailureAsync(SourceName, now, _options.FailureBackoff, ex.Message, cancellationToken).ConfigureAwait(false);
                throw;
            }

            if (listResult.IsNotModified)
            {
                _diagnostics.FetchUnchanged();
                listUnchangedCount++;
                break;
            }

            if (!listResult.IsSuccess || listResult.Content is null)
            {
                _diagnostics.FetchFailure();
                break;
            }

            _diagnostics.FetchSuccess();
            listSuccessCount++;

            var pageModel = CveListParser.Parse(listResult.Content, page, _options.PageSize);

            if (pageModel.Items.Count == 0)
            {
                hasMorePages = false;
            }

            foreach (var item in pageModel.Items)
            {
                cancellationToken.ThrowIfCancellationRequested();

                var detailUri = BuildDetailRequestUri(item.CveId);
                var detailMetadata = new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    ["cveId"] = item.CveId,
                    ["page"] = page.ToString(CultureInfo.InvariantCulture),
                    ["since"] = since.ToString("O"),
                    ["until"] = windowEnd.ToString("O"),
                };

                SourceFetchResult detailResult;
                try
                {
                    detailResult = await _fetchService.FetchAsync(
                        new SourceFetchRequest(
                            CveOptions.HttpClientName,
                            SourceName,
                            detailUri)
                        {
                            Metadata = detailMetadata,
                            AcceptHeaders = new[] { "application/json" },
                        },
                        cancellationToken).ConfigureAwait(false);
                }
                catch (HttpRequestException ex) when (IsAuthenticationFailure(ex))
                {
                    _diagnostics.FetchFailure();
                    _logger.LogWarning(ex, "Failed fetching CVE record {CveId} due to authentication. Seeding if possible.", item.CveId);
                    if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
                    {
                        return;
                    }

                    _logger.LogWarning("CVE record {CveId} skipped; missing credentials and no seed data available.", item.CveId);
                    continue;
                }
                catch (HttpRequestException ex)
                {
                    _diagnostics.FetchFailure();
                    _logger.LogWarning(ex, "Failed fetching CVE record {CveId}", item.CveId);
                    continue;
                }

                if (detailResult.IsNotModified)
                {
                    _diagnostics.FetchUnchanged();
                    detailUnchanged++;
                    continue;
                }

                if (!detailResult.IsSuccess || detailResult.Document is null)
                {
                    _diagnostics.FetchFailure();
                    detailFailures++;
                    continue;
                }

                _diagnostics.FetchDocument();
                if (pendingDocuments.Add(detailResult.Document.Id))
                {
                    documentsFetched++;
                }
                pendingMappings.Add(detailResult.Document.Id);
            }

            if (pageModel.MaxModified.HasValue)
            {
                if (!maxModified.HasValue || pageModel.MaxModified > maxModified)
                {
                    maxModified = pageModel.MaxModified;
                }
            }

            hasMorePages = pageModel.HasMorePages;
            page = pageModel.NextPageCandidate;
            pagesFetched++;

            if (hasMorePages && _options.RequestDelay > TimeSpan.Zero)
            {
                await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
            }
        }

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

        if (hasMorePages)
        {
            updatedCursor = updatedCursor
                .WithCurrentWindowStart(since)
                .WithCurrentWindowEnd(windowEnd)
                .WithNextPage(page);
        }
        else
        {
            var nextSince = maxModified ?? windowEnd;
            updatedCursor = updatedCursor
                .WithLastModifiedExclusive(nextSince)
                .WithCurrentWindowStart(null)
                .WithCurrentWindowEnd(null)
                .WithNextPage(1);
        }

        var nextWindowStart = hasMorePages ? since : maxModified ?? windowEnd;
        DateTimeOffset? nextWindowEnd = hasMorePages ? windowEnd : null;
        var nextPage = hasMorePages ? page : 1;
        var windowStartString = since.ToString("O");
        var windowEndString = windowEnd.ToString("O");
        var nextWindowStartString = nextWindowStart.ToString("O");
        var nextWindowEndString = nextWindowEnd?.ToString("O") ?? "(none)";

        _logger.LogInformation(
            "CVEs fetch window {WindowStart}->{WindowEnd} pages={PagesFetched} listSuccess={ListSuccess} detailDocuments={DocumentsFetched} detailFailures={DetailFailures} detailUnchanged={DetailUnchanged} pendingDocuments={PendingDocumentsBefore}->{PendingDocumentsAfter} pendingMappings={PendingMappingsBefore}->{PendingMappingsAfter} hasMorePages={HasMorePages} nextWindowStart={NextWindowStart} nextWindowEnd={NextWindowEnd} nextPage={NextPage}",
            windowStartString,
            windowEndString,
            pagesFetched,
            listSuccessCount,
            documentsFetched,
            detailFailures,
            detailUnchanged,
            initialPendingDocuments,
            pendingDocuments.Count,
            initialPendingMappings,
            pendingMappings.Count,
            hasMorePages,
            nextWindowStartString,
            nextWindowEndString,
            nextPage);

        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 remainingDocuments = cursor.PendingDocuments.ToList();

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

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

            if (!document.GridFsId.HasValue)
            {
                _diagnostics.ParseFailure();
                _logger.LogWarning("CVEs document {DocumentId} missing GridFS content", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remainingDocuments.Remove(documentId);
                continue;
            }

            byte[] rawBytes;
            try
            {
                rawBytes = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _diagnostics.ParseFailure();
                _logger.LogError(ex, "Unable to download CVE raw document {DocumentId}", documentId);
                throw;
            }

            CveRecordDto dto;
            try
            {
                dto = CveRecordParser.Parse(rawBytes);
            }
            catch (JsonException ex)
            {
                _diagnostics.ParseQuarantine();
                _logger.LogError(ex, "Malformed CVE JSON for {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                remainingDocuments.Remove(documentId);
                continue;
            }

            var payload = BsonDocument.Parse(JsonSerializer.Serialize(dto, SerializerOptions));
            var dtoRecord = new DtoRecord(
                Guid.NewGuid(),
                document.Id,
                SourceName,
                "cve/5.0",
                payload,
                _timeProvider.GetUtcNow());

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

            remainingDocuments.Remove(documentId);
            _diagnostics.ParseSuccess();
        }

        var updatedCursor = cursor
            .WithPendingDocuments(remainingDocuments);

        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)
            {
                _logger.LogWarning("Skipping CVE mapping for {DocumentId}: DTO or document missing", documentId);
                pendingMappings.Remove(documentId);
                continue;
            }

            CveRecordDto dto;
            try
            {
                dto = JsonSerializer.Deserialize<CveRecordDto>(dtoRecord.Payload.ToJson(), SerializerOptions)
                    ?? throw new InvalidOperationException("Deserialized DTO was null.");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to deserialize CVE DTO for {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                continue;
            }

            var recordedAt = dtoRecord.ValidatedAt;
            var advisory = CveMapper.Map(dto, document, recordedAt);

            await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
            await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
            pendingMappings.Remove(documentId);
            _diagnostics.MapSuccess(1);
        }

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

    private async Task<bool> TrySeedFromDirectoryAsync(CveCursor cursor, DateTimeOffset now, CancellationToken cancellationToken)
    {
        var seedDirectory = _options.SeedDirectory;
        if (string.IsNullOrWhiteSpace(seedDirectory) || !Directory.Exists(seedDirectory))
        {
            return false;
        }

        var detailFiles = Directory.EnumerateFiles(seedDirectory, "CVE-*.json", SearchOption.AllDirectories)
            .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
            .ToArray();

        if (detailFiles.Length == 0)
        {
            return false;
        }

        var seeded = 0;
        DateTimeOffset? maxModified = cursor.LastModifiedExclusive;

        foreach (var file in detailFiles)
        {
            cancellationToken.ThrowIfCancellationRequested();

            byte[] payload;
            try
            {
                payload = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Unable to read CVE seed file {File}", file);
                continue;
            }

            CveRecordDto dto;
            try
            {
                dto = CveRecordParser.Parse(payload);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Seed file {File} did not contain a valid CVE record", file);
                continue;
            }

            if (string.IsNullOrWhiteSpace(dto.CveId))
            {
                _logger.LogWarning("Seed file {File} missing CVE identifier", file);
                continue;
            }

            var uri = $"seed://{dto.CveId}";
            var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri, cancellationToken).ConfigureAwait(false);
            var documentId = existing?.Id ?? Guid.NewGuid();

            var sha256 = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
            var lastModified = dto.Modified ?? dto.Published ?? now;
            ObjectId gridId = ObjectId.Empty;

            try
            {
                if (existing?.GridFsId is ObjectId existingGrid && existingGrid != ObjectId.Empty)
                {
                    gridId = existingGrid;
                }
                else
                {
                    gridId = await _rawDocumentStorage.UploadAsync(SourceName, uri, payload, "application/json", cancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Unable to store CVE seed payload for {CveId}", dto.CveId);
                continue;
            }

            var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
            {
                ["seed.file"] = Path.GetFileName(file),
                ["seed.directory"] = seedDirectory,
            };

            var document = new DocumentRecord(
                documentId,
                SourceName,
                uri,
                now,
                sha256,
                DocumentStatuses.Mapped,
                "application/json",
                Headers: null,
                Metadata: metadata,
                Etag: null,
                LastModified: lastModified,
                GridFsId: gridId);

            await _documentStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);

            var advisory = CveMapper.Map(dto, document, now);
            await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);

            if (!maxModified.HasValue || lastModified > maxModified)
            {
                maxModified = lastModified;
            }

            seeded++;
        }

        if (seeded == 0)
        {
            return false;
        }

        var updatedCursor = cursor
            .WithPendingDocuments(Array.Empty<Guid>())
            .WithPendingMappings(Array.Empty<Guid>())
            .WithLastModifiedExclusive(maxModified ?? now)
            .WithCurrentWindowStart(null)
            .WithCurrentWindowEnd(null)
            .WithNextPage(1);

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

        _logger.LogWarning("Seeded {SeededCount} CVE advisories from {SeedDirectory}; live fetch will resume when credentials are configured.", seeded, seedDirectory);
        return true;
    }

    private static bool IsAuthenticationFailure(HttpRequestException exception)
        => exception.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;

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

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

    private static Uri BuildListRequestUri(DateTimeOffset since, DateTimeOffset until, int page, int pageSize)
    {
        var query = $"time_modified.gte={Uri.EscapeDataString(since.ToString("O"))}&time_modified.lte={Uri.EscapeDataString(until.ToString("O"))}&page={page}&size={pageSize}";
        return new Uri($"cve?{query}", UriKind.Relative);
    }

    private static Uri BuildDetailRequestUri(string cveId)
    {
        var encoded = Uri.EscapeDataString(cveId);
        return new Uri($"cve/{encoded}", UriKind.Relative);
    }
}
