using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Feedser.Source.Cccs.Configuration;
using StellaOps.Feedser.Source.Cccs.Internal;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Fetch;
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.Cccs;

public sealed class CccsConnector : IFeedConnector
{
    private static readonly JsonSerializerOptions RawSerializerOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    private static readonly JsonSerializerOptions DtoSerializerOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    private const string DtoSchemaVersion = "cccs.dto.v1";

    private readonly CccsFeedClient _feedClient;
    private readonly RawDocumentStorage _rawDocumentStorage;
    private readonly IDocumentStore _documentStore;
    private readonly IDtoStore _dtoStore;
    private readonly IAdvisoryStore _advisoryStore;
    private readonly ISourceStateRepository _stateRepository;
    private readonly CccsHtmlParser _htmlParser;
    private readonly CccsDiagnostics _diagnostics;
    private readonly CccsOptions _options;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<CccsConnector> _logger;

    public CccsConnector(
        CccsFeedClient feedClient,
        RawDocumentStorage rawDocumentStorage,
        IDocumentStore documentStore,
        IDtoStore dtoStore,
        IAdvisoryStore advisoryStore,
        ISourceStateRepository stateRepository,
        CccsHtmlParser htmlParser,
        CccsDiagnostics diagnostics,
        IOptions<CccsOptions> options,
        TimeProvider? timeProvider,
        ILogger<CccsConnector> logger)
    {
        _feedClient = feedClient ?? throw new ArgumentNullException(nameof(feedClient));
        _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));
        _htmlParser = htmlParser ?? throw new ArgumentNullException(nameof(htmlParser));
        _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
        _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 => CccsConnectorPlugin.SourceName;

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

        var now = _timeProvider.GetUtcNow();
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
        var pendingDocuments = new HashSet<Guid>(cursor.PendingDocuments);
        var pendingMappings = new HashSet<Guid>(cursor.PendingMappings);
        var knownHashes = new Dictionary<string, string>(cursor.KnownEntryHashes, StringComparer.Ordinal);
        var feedsProcessed = 0;
        var totalItems = 0;
        var added = 0;
        var unchanged = 0;

        try
        {
            foreach (var feed in _options.Feeds)
            {
                cancellationToken.ThrowIfCancellationRequested();

                _diagnostics.FetchAttempt();
                var result = await _feedClient.FetchAsync(feed, _options.RequestTimeout, cancellationToken).ConfigureAwait(false);
                feedsProcessed++;
                totalItems += result.Items.Count;

                if (result.Items.Count == 0)
                {
                    _diagnostics.FetchSuccess();
                    await DelayBetweenRequestsAsync(cancellationToken).ConfigureAwait(false);
                    continue;
                }

                var items = result.Items
                    .Where(static item => !string.IsNullOrWhiteSpace(item.Title))
                    .OrderByDescending(item => ParseDate(item.DateModifiedTimestamp) ?? ParseDate(item.DateModified) ?? DateTimeOffset.MinValue)
                    .ThenByDescending(item => ParseDate(item.DateCreated) ?? DateTimeOffset.MinValue)
                    .ToList();

                foreach (var item in items)
                {
                    cancellationToken.ThrowIfCancellationRequested();

                    var documentUri = BuildDocumentUri(item, feed);
                    var rawDocument = CreateRawDocument(item, feed, result.AlertTypes);
                    var payload = JsonSerializer.SerializeToUtf8Bytes(rawDocument, RawSerializerOptions);
                    var sha = ComputeSha256(payload);

                    if (knownHashes.TryGetValue(documentUri, out var existingHash)
                        && string.Equals(existingHash, sha, StringComparison.Ordinal))
                    {
                        unchanged++;
                        _diagnostics.FetchUnchanged();
                        continue;
                    }

                    var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, documentUri, cancellationToken).ConfigureAwait(false);
                    if (existing is not null
                        && string.Equals(existing.Sha256, sha, StringComparison.OrdinalIgnoreCase)
                        && string.Equals(existing.Status, DocumentStatuses.Mapped, StringComparison.Ordinal))
                    {
                        knownHashes[documentUri] = sha;
                        unchanged++;
                        _diagnostics.FetchUnchanged();
                        continue;
                    }

                    var gridFsId = await _rawDocumentStorage.UploadAsync(
                        SourceName,
                        documentUri,
                        payload,
                        "application/json",
                        expiresAt: null,
                        cancellationToken).ConfigureAwait(false);

                    var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
                    {
                        ["cccs.language"] = rawDocument.Language,
                        ["cccs.sourceId"] = rawDocument.SourceId,
                    };

                    if (!string.IsNullOrWhiteSpace(rawDocument.SerialNumber))
                    {
                        metadata["cccs.serialNumber"] = rawDocument.SerialNumber!;
                    }

                    if (!string.IsNullOrWhiteSpace(rawDocument.AlertType))
                    {
                        metadata["cccs.alertType"] = rawDocument.AlertType!;
                    }

                    var recordId = existing?.Id ?? Guid.NewGuid();
                    var record = new DocumentRecord(
                        recordId,
                        SourceName,
                        documentUri,
                        now,
                        sha,
                        DocumentStatuses.PendingParse,
                        "application/json",
                        Headers: null,
                        Metadata: metadata,
                        Etag: null,
                        LastModified: rawDocument.Modified ?? rawDocument.Published ?? result.LastModifiedUtc,
                        GridFsId: gridFsId,
                        ExpiresAt: null);

                    var upserted = await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
                    pendingDocuments.Add(upserted.Id);
                    pendingMappings.Remove(upserted.Id);
                    knownHashes[documentUri] = sha;
                    added++;
                    _diagnostics.FetchDocument();

                    if (added >= _options.MaxEntriesPerFetch)
                    {
                        break;
                    }
                }

                _diagnostics.FetchSuccess();
                await DelayBetweenRequestsAsync(cancellationToken).ConfigureAwait(false);

                if (added >= _options.MaxEntriesPerFetch)
                {
                    break;
                }
            }
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException)
        {
            _diagnostics.FetchFailure();
            _logger.LogError(ex, "CCCS fetch failed");
            await _stateRepository.MarkFailureAsync(SourceName, now, _options.FailureBackoff, ex.Message, cancellationToken).ConfigureAwait(false);
            throw;
        }

        var trimmedHashes = TrimKnownHashes(knownHashes, _options.MaxKnownEntries);
        var updatedCursor = cursor
            .WithPendingDocuments(pendingDocuments)
            .WithPendingMappings(pendingMappings)
            .WithKnownEntryHashes(trimmedHashes)
            .WithLastFetch(now);

        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
        _logger.LogInformation(
            "CCCS fetch completed feeds={Feeds} items={Items} newDocuments={Added} unchanged={Unchanged} pendingDocuments={PendingDocuments} pendingMappings={PendingMappings}",
            feedsProcessed,
            totalItems,
            added,
            unchanged,
            pendingDocuments.Count,
            pendingMappings.Count);
    }

    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 pendingDocuments = cursor.PendingDocuments.ToList();
        var pendingMappings = cursor.PendingMappings.ToList();
        var now = _timeProvider.GetUtcNow();
        var parsed = 0;
        var parseFailures = 0;

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

            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (document is null)
            {
                pendingDocuments.Remove(documentId);
                pendingMappings.Remove(documentId);
                _diagnostics.ParseFailure();
                parseFailures++;
                continue;
            }

            if (!document.GridFsId.HasValue)
            {
                _diagnostics.ParseFailure();
                _logger.LogWarning("CCCS document {DocumentId} missing GridFS payload", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingDocuments.Remove(documentId);
                pendingMappings.Remove(documentId);
                parseFailures++;
                continue;
            }

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

            CccsRawAdvisoryDocument? raw;
            try
            {
                raw = JsonSerializer.Deserialize<CccsRawAdvisoryDocument>(payload, RawSerializerOptions);
            }
            catch (Exception ex)
            {
                _diagnostics.ParseFailure();
                _logger.LogWarning(ex, "CCCS failed to deserialize raw document {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingDocuments.Remove(documentId);
                pendingMappings.Remove(documentId);
                parseFailures++;
                continue;
            }

            if (raw is null)
            {
                _diagnostics.ParseFailure();
                _logger.LogWarning("CCCS raw document {DocumentId} produced null payload", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingDocuments.Remove(documentId);
                pendingMappings.Remove(documentId);
                parseFailures++;
                continue;
            }

            CccsAdvisoryDto dto;
            try
            {
                dto = _htmlParser.Parse(raw);
            }
            catch (Exception ex)
            {
                _diagnostics.ParseFailure();
                _logger.LogWarning(ex, "CCCS failed to parse advisory DTO for {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingDocuments.Remove(documentId);
                pendingMappings.Remove(documentId);
                parseFailures++;
                continue;
            }

            var dtoJson = JsonSerializer.Serialize(dto, DtoSerializerOptions);
            var dtoBson = BsonDocument.Parse(dtoJson);
            var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, SourceName, DtoSchemaVersion, dtoBson, now);
            await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
            await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);

            pendingDocuments.Remove(documentId);
            if (!pendingMappings.Contains(documentId))
            {
                pendingMappings.Add(documentId);
            }
            _diagnostics.ParseSuccess();
            parsed++;
        }

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

        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
        if (parsed > 0 || parseFailures > 0)
        {
            _logger.LogInformation(
                "CCCS parse completed parsed={Parsed} failures={Failures} pendingDocuments={PendingDocuments} pendingMappings={PendingMappings}",
                parsed,
                parseFailures,
                pendingDocuments.Count,
                pendingMappings.Count);
        }
    }

    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();
        var mapped = 0;
        var mappingFailures = 0;

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

            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (document is null)
            {
                pendingMappings.Remove(documentId);
                _diagnostics.MapFailure();
                mappingFailures++;
                continue;
            }

            var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
            if (dtoRecord is null)
            {
                _diagnostics.MapFailure();
                _logger.LogWarning("CCCS document {DocumentId} missing DTO payload", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                mappingFailures++;
                continue;
            }

            CccsAdvisoryDto? dto;
            try
            {
                var json = dtoRecord.Payload.ToJson();
                dto = JsonSerializer.Deserialize<CccsAdvisoryDto>(json, DtoSerializerOptions);
            }
            catch (Exception ex)
            {
                _diagnostics.MapFailure();
                _logger.LogWarning(ex, "CCCS failed to deserialize DTO for document {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                mappingFailures++;
                continue;
            }

            if (dto is null)
            {
                _diagnostics.MapFailure();
                _logger.LogWarning("CCCS DTO for document {DocumentId} evaluated to null", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                mappingFailures++;
                continue;
            }

            try
            {
                var advisory = CccsMapper.Map(dto, document, dtoRecord.ValidatedAt);
                await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                _diagnostics.MapSuccess();
                mapped++;
            }
            catch (Exception ex)
            {
                _diagnostics.MapFailure();
                _logger.LogError(ex, "CCCS mapping failed for document {DocumentId}", documentId);
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
                pendingMappings.Remove(documentId);
                mappingFailures++;
            }
        }

        var updatedCursor = cursor.WithPendingMappings(pendingMappings);
        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
        if (mapped > 0 || mappingFailures > 0)
        {
            _logger.LogInformation(
                "CCCS map completed mapped={Mapped} failures={Failures} pendingMappings={PendingMappings}",
                mapped,
                mappingFailures,
                pendingMappings.Count);
        }
    }

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

    private Task UpdateCursorAsync(CccsCursor cursor, CancellationToken cancellationToken)
    {
        var document = cursor.ToBsonDocument();
        var completedAt = cursor.LastFetchAt ?? _timeProvider.GetUtcNow();
        return _stateRepository.UpdateCursorAsync(SourceName, document, completedAt, cancellationToken);
    }

    private async Task DelayBetweenRequestsAsync(CancellationToken cancellationToken)
    {
        if (_options.RequestDelay <= TimeSpan.Zero)
        {
            return;
        }

        try
        {
            await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
        }
        catch (TaskCanceledException)
        {
            // Ignore cancellation during delay; caller handles.
        }
    }

    private static string BuildDocumentUri(CccsFeedItem item, CccsFeedEndpoint feed)
    {
        if (!string.IsNullOrWhiteSpace(item.Url))
        {
            if (Uri.TryCreate(item.Url, UriKind.Absolute, out var absolute))
            {
                return absolute.ToString();
            }

            var baseUri = new Uri("https://www.cyber.gc.ca", UriKind.Absolute);
            if (Uri.TryCreate(baseUri, item.Url, out var combined))
            {
                return combined.ToString();
            }
        }

        return $"https://www.cyber.gc.ca/api/cccs/threats/{feed.Language}/{item.Nid}";
    }

    private static CccsRawAdvisoryDocument CreateRawDocument(CccsFeedItem item, CccsFeedEndpoint feed, IReadOnlyDictionary<int, string> taxonomy)
    {
        var language = string.IsNullOrWhiteSpace(item.Language) ? feed.Language : item.Language!.Trim();
        var identifier = !string.IsNullOrWhiteSpace(item.SerialNumber)
            ? item.SerialNumber!.Trim()
            : !string.IsNullOrWhiteSpace(item.Uuid)
                ? item.Uuid!.Trim()
                : $"nid-{item.Nid}";

        var canonicalUrl = BuildDocumentUri(item, feed);
        var bodySegments = item.Body ?? Array.Empty<string>();
        var bodyHtml = string.Join(Environment.NewLine, bodySegments);
        var published = ParseDate(item.DateCreated);
        var modified = ParseDate(item.DateModifiedTimestamp) ?? ParseDate(item.DateModified);
        var alertType = ResolveAlertType(item, taxonomy);

        return new CccsRawAdvisoryDocument
        {
            SourceId = identifier,
            SerialNumber = item.SerialNumber?.Trim(),
            Uuid = item.Uuid,
            Language = language.ToLowerInvariant(),
            Title = item.Title?.Trim() ?? identifier,
            Summary = item.Summary?.Trim(),
            CanonicalUrl = canonicalUrl,
            ExternalUrl = item.ExternalUrl,
            BodyHtml = bodyHtml,
            BodySegments = bodySegments,
            AlertType = alertType,
            Subject = item.Subject,
            Banner = item.Banner,
            Published = published,
            Modified = modified,
            RawDateCreated = item.DateCreated,
            RawDateModified = item.DateModifiedTimestamp ?? item.DateModified,
        };
    }

    private static string? ResolveAlertType(CccsFeedItem item, IReadOnlyDictionary<int, string> taxonomy)
    {
        if (item.AlertType.ValueKind == JsonValueKind.Number)
        {
            var id = item.AlertType.GetInt32();
            return taxonomy.TryGetValue(id, out var label) ? label : id.ToString(CultureInfo.InvariantCulture);
        }

        if (item.AlertType.ValueKind == JsonValueKind.String)
        {
            return item.AlertType.GetString();
        }

        if (item.AlertType.ValueKind == JsonValueKind.Array)
        {
            foreach (var element in item.AlertType.EnumerateArray())
            {
                if (element.ValueKind == JsonValueKind.Number)
                {
                    var id = element.GetInt32();
                    if (taxonomy.TryGetValue(id, out var label))
                    {
                        return label;
                    }
                }
                else if (element.ValueKind == JsonValueKind.String)
                {
                    var label = element.GetString();
                    if (!string.IsNullOrWhiteSpace(label))
                    {
                        return label;
                    }
                }
            }
        }

        return null;
    }

    private static Dictionary<string, string> TrimKnownHashes(Dictionary<string, string> hashes, int maxEntries)
    {
        if (hashes.Count <= maxEntries)
        {
            return hashes;
        }

        var overflow = hashes.Count - maxEntries;
        foreach (var key in hashes.Keys.Take(overflow).ToList())
        {
            hashes.Remove(key);
        }

        return hashes;
    }

    private static DateTimeOffset? ParseDate(string? value)
        => string.IsNullOrWhiteSpace(value)
            ? null
            : DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
                ? parsed
                : null;

    private static string ComputeSha256(byte[] payload)
        => Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
}
