using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Bson;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.CertIn;
using StellaOps.Feedser.Source.CertIn.Configuration;
using StellaOps.Feedser.Source.CertIn.Internal;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Common.Http;
using StellaOps.Feedser.Source.Common.Testing;
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.Testing;

namespace StellaOps.Feedser.Source.CertIn.Tests;

[Collection("mongo-fixture")]
public sealed class CertInConnectorTests : IAsyncLifetime
{
    private readonly MongoIntegrationFixture _fixture;
    private readonly FakeTimeProvider _timeProvider;
    private readonly CannedHttpMessageHandler _handler;
    private ServiceProvider? _serviceProvider;

    public CertInConnectorTests(MongoIntegrationFixture fixture)
    {
        _fixture = fixture;
        _timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 20, 0, 0, 0, TimeSpan.Zero));
        _handler = new CannedHttpMessageHandler();
    }

    [Fact]
    public async Task FetchParseMap_GeneratesExpectedSnapshot()
    {
        var options = new CertInOptions
        {
            AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
            WindowSize = TimeSpan.FromDays(60),
            WindowOverlap = TimeSpan.FromDays(7),
            MaxPagesPerFetch = 1,
            RequestDelay = TimeSpan.Zero,
        };

        await EnsureServiceProviderAsync(options);
        var provider = _serviceProvider!;

        _handler.Clear();

        _handler.AddTextResponse(options.AlertsEndpoint, ReadFixture("alerts-page1.json"), "application/json");
        var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
        _handler.AddTextResponse(detailUri, ReadFixture("detail-CIAD-2024-0005.html"), "text/html");

        var connector = new CertInConnectorPlugin().Create(provider);

        await connector.FetchAsync(provider, CancellationToken.None);

        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);

        await connector.MapAsync(provider, CancellationToken.None);

        var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
        var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
        Assert.Single(advisories);
        var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
        var expected = ReadFixture("expected-advisory.json");
        var normalizedExpected = NormalizeLineEndings(expected);
        var normalizedActual = NormalizeLineEndings(canonical);
        if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
        {
            var actualPath = ResolveFixturePath("expected-advisory.actual.json");
            Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
            File.WriteAllText(actualPath, canonical);
        }

        Assert.Equal(normalizedExpected, normalizedActual);

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        Assert.Equal(DocumentStatuses.Mapped, document!.Status);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pending));
        Assert.Empty(pending.AsBsonArray);
    }

    [Fact]
    public async Task FetchFailure_RecordsBackoffAndReason()
    {
        var options = new CertInOptions
        {
            AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
            WindowSize = TimeSpan.FromDays(60),
            WindowOverlap = TimeSpan.FromDays(7),
            MaxPagesPerFetch = 1,
            RequestDelay = TimeSpan.Zero,
        };

        await EnsureServiceProviderAsync(options);
        _handler.Clear();
        _handler.AddResponse(options.AlertsEndpoint, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
        {
            Content = new StringContent("{}", Encoding.UTF8, "application/json"),
        });

        var provider = _serviceProvider!;
        var connector = new CertInConnectorPlugin().Create(provider);

        await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.Equal(1, state!.FailCount);
        Assert.NotNull(state.LastFailureReason);
        Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
        Assert.True(state.BackoffUntil.HasValue);
        Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
    }

    [Fact]
    public async Task Fetch_NotModifiedMaintainsDocumentState()
    {
        var options = new CertInOptions
        {
            AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
            WindowSize = TimeSpan.FromDays(30),
            WindowOverlap = TimeSpan.FromDays(7),
            MaxPagesPerFetch = 1,
            RequestDelay = TimeSpan.Zero,
        };

        await EnsureServiceProviderAsync(options);
        var provider = _serviceProvider!;
        _handler.Clear();

        var listingPayload = ReadFixture("alerts-page1.json");
        var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
        var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
        var etag = new EntityTagHeaderValue("\"certin-2024-0005\"");
        var lastModified = new DateTimeOffset(2024, 4, 15, 10, 0, 0, TimeSpan.Zero);

        _handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
        _handler.AddResponse(detailUri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
            };

            response.Headers.ETag = etag;
            response.Content.Headers.LastModified = lastModified;
            return response;
        });

        var connector = new CertInConnectorPlugin().Create(provider);

        await connector.FetchAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);
        await connector.MapAsync(provider, CancellationToken.None);

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        Assert.Equal(DocumentStatuses.Mapped, document!.Status);
        Assert.Equal(etag.Tag, document.Etag);

        _handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
        _handler.AddResponse(detailUri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.NotModified)
            {
                Content = new StringContent(string.Empty)
            };
            response.Headers.ETag = etag;
            return response;
        });

        await connector.FetchAsync(provider, CancellationToken.None);
        await connector.ParseAsync(provider, CancellationToken.None);
        await connector.MapAsync(provider, CancellationToken.None);

        document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        Assert.Equal(DocumentStatuses.Mapped, document!.Status);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
        Assert.Equal(0, pendingDocs.AsBsonArray.Count);
        Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
        Assert.Equal(0, pendingMappings.AsBsonArray.Count);
    }

    [Fact]
    public async Task Fetch_DuplicateContentSkipsRequeue()
    {
        var options = new CertInOptions
        {
            AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
            WindowSize = TimeSpan.FromDays(30),
            WindowOverlap = TimeSpan.FromDays(7),
            MaxPagesPerFetch = 1,
            RequestDelay = TimeSpan.Zero,
        };

        await EnsureServiceProviderAsync(options);
        var provider = _serviceProvider!;
        _handler.Clear();

        var listingPayload = ReadFixture("alerts-page1.json");
        var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
        var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");

        _handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
        _handler.AddTextResponse(detailUri, detailHtml, "text/html");

        var connector = new CertInConnectorPlugin().Create(provider);

        await connector.FetchAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);
        await connector.MapAsync(provider, CancellationToken.None);

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        Assert.Equal(DocumentStatuses.Mapped, document!.Status);

        _handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
        _handler.AddTextResponse(detailUri, detailHtml, "text/html");

        await connector.FetchAsync(provider, CancellationToken.None);
        await connector.ParseAsync(provider, CancellationToken.None);
        await connector.MapAsync(provider, CancellationToken.None);

        document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        Assert.Equal(DocumentStatuses.Mapped, document!.Status);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
        Assert.Equal(0, pendingDocs.AsBsonArray.Count);
        Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
        Assert.Equal(0, pendingMappings.AsBsonArray.Count);
    }

    private async Task EnsureServiceProviderAsync(CertInOptions template)
    {
        if (_serviceProvider is not null)
        {
            await ResetDatabaseAsync();
            return;
        }

        await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);

        var services = new ServiceCollection();
        services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
        services.AddSingleton<TimeProvider>(_timeProvider);
        services.AddSingleton(_handler);

        services.AddMongoStorage(options =>
        {
            options.ConnectionString = _fixture.Runner.ConnectionString;
            options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
            options.CommandTimeout = TimeSpan.FromSeconds(5);
        });

        services.AddSourceCommon();
        services.AddCertInConnector(opts =>
        {
            opts.AlertsEndpoint = template.AlertsEndpoint;
            opts.WindowSize = template.WindowSize;
            opts.WindowOverlap = template.WindowOverlap;
            opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
            opts.RequestDelay = template.RequestDelay;
        });

        services.Configure<HttpClientFactoryOptions>(CertInOptions.HttpClientName, builderOptions =>
        {
            builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
            {
                builder.PrimaryHandler = _handler;
            });
        });

        _serviceProvider = services.BuildServiceProvider();
        var bootstrapper = _serviceProvider.GetRequiredService<MongoBootstrapper>();
        await bootstrapper.InitializeAsync(CancellationToken.None);
    }

    private Task ResetDatabaseAsync()
        => _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);

    private static string ReadFixture(string filename)
        => File.ReadAllText(ResolveFixturePath(filename));

    private static string ResolveFixturePath(string filename)
    {
        var baseDirectory = AppContext.BaseDirectory;
        var primary = Path.Combine(baseDirectory, "Source", "CertIn", "Fixtures", filename);
        if (File.Exists(primary) || filename.EndsWith(".actual.json", StringComparison.OrdinalIgnoreCase))
        {
            return primary;
        }

        return Path.Combine(baseDirectory, "CertIn", "Fixtures", filename);
    }

    private static string NormalizeLineEndings(string value)
        => value.Replace("\r\n", "\n", StringComparison.Ordinal);

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        if (_serviceProvider is IAsyncDisposable asyncDisposable)
        {
            await asyncDisposable.DisposeAsync();
        }
        else
        {
            _serviceProvider?.Dispose();
        }
    }
}
