using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Bson;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Common.Http;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Kev;
using StellaOps.Feedser.Source.Kev.Configuration;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Testing;
using Xunit;

namespace StellaOps.Feedser.Source.Kev.Tests;

[Collection("mongo-fixture")]
public sealed class KevConnectorTests : IAsyncLifetime
{
    private static readonly Uri FeedUri = new("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
    private const string CatalogEtag = "\"kev-2025-10-09\"";

    private readonly MongoIntegrationFixture _fixture;
    private readonly FakeTimeProvider _timeProvider;
    private readonly CannedHttpMessageHandler _handler;

    public KevConnectorTests(MongoIntegrationFixture fixture)
    {
        _fixture = fixture;
        _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
        _handler = new CannedHttpMessageHandler();
    }

    [Fact]
    public async Task FetchParseMap_ProducesDeterministicSnapshot()
    {
        await using var provider = await BuildServiceProviderAsync();
        SeedCatalogResponse();

        var connector = provider.GetRequiredService<KevConnector>();
        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(10, CancellationToken.None);
        Assert.NotEmpty(advisories);

        var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
        var snapshot = SnapshotSerializer.ToSnapshot(ordered);
        WriteOrAssertSnapshot(snapshot, "kev-advisories.snapshot.json");

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

        SeedNotModifiedResponse();
        await connector.FetchAsync(provider, CancellationToken.None);
        _handler.AssertNoPendingResponses();

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(KevConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.Equal("2025.10.09", state!.Cursor.TryGetValue("catalogVersion", out var versionValue) ? versionValue.AsString : null);
        Assert.True(state.Cursor.TryGetValue("catalogReleased", out var releasedValue) && releasedValue.BsonType is BsonType.DateTime);
        Assert.True(IsEmptyArray(state.Cursor, "pendingDocuments"));
        Assert.True(IsEmptyArray(state.Cursor, "pendingMappings"));
    }

    private async Task<ServiceProvider> BuildServiceProviderAsync()
    {
        await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
        _handler.Clear();

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

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

        services.AddSourceCommon();
        services.AddKevConnector(options =>
        {
            options.FeedUri = FeedUri;
            options.RequestTimeout = TimeSpan.FromSeconds(10);
        });

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

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

    private void SeedCatalogResponse()
    {
        var payload = ReadFixture("kev-catalog.json");
        _handler.AddResponse(FeedUri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(payload, Encoding.UTF8, "application/json"),
            };
            response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
            response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 9, 16, 52, 28, TimeSpan.Zero);
            return response;
        });
    }

    private void SeedNotModifiedResponse()
    {
        _handler.AddResponse(FeedUri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.NotModified);
            response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
            return response;
        });
    }

    private static bool IsEmptyArray(BsonDocument document, string field)
    {
        if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
        {
            return false;
        }

        return array.Count == 0;
    }

    private static string ReadFixture(string filename)
    {
        var path = GetExistingFixturePath(filename);
        return File.ReadAllText(path);
    }

    private static void WriteOrAssertSnapshot(string snapshot, string filename)
    {
        if (ShouldUpdateFixtures())
        {
            var target = GetWritableFixturePath(filename);
            File.WriteAllText(target, snapshot);
            return;
        }

        var expected = ReadFixture(filename);
        var normalizedExpected = Normalize(expected);
        var normalizedSnapshot = Normalize(snapshot);

        if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
        {
            var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
            File.WriteAllText(actualPath, snapshot);
        }

        Assert.Equal(normalizedExpected, normalizedSnapshot);
    }

    private static bool ShouldUpdateFixtures()
    {
        var value = Environment.GetEnvironmentVariable("UPDATE_KEV_FIXTURES");
        return string.Equals(value, "1", StringComparison.Ordinal) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
    }

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

    private static string GetExistingFixturePath(string filename)
    {
        var baseDir = AppContext.BaseDirectory;
        var primary = Path.Combine(baseDir, "Source", "Kev", "Fixtures", filename);
        if (File.Exists(primary))
        {
            return primary;
        }

        var fallback = Path.Combine(baseDir, "Kev", "Fixtures", filename);
        if (File.Exists(fallback))
        {
            return fallback;
        }

        throw new FileNotFoundException($"Unable to locate KEV fixture '{filename}'.");
    }

    private static string GetWritableFixturePath(string filename)
    {
        var baseDir = AppContext.BaseDirectory;
        var primaryDir = Path.Combine(baseDir, "Source", "Kev", "Fixtures");
        Directory.CreateDirectory(primaryDir);
        return Path.Combine(primaryDir, filename);
    }

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
    }
}
