using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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 MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
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.Vndr.Oracle;
using StellaOps.Feedser.Source.Vndr.Oracle.Configuration;
using StellaOps.Feedser.Source.Vndr.Oracle.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.Testing;
using Xunit.Abstractions;

namespace StellaOps.Feedser.Source.Vndr.Oracle.Tests;

[Collection("mongo-fixture")]
public sealed class OracleConnectorTests : IAsyncLifetime
{
    private readonly MongoIntegrationFixture _fixture;
    private readonly FakeTimeProvider _timeProvider;
    private readonly CannedHttpMessageHandler _handler;
    private readonly ITestOutputHelper _output;

    private static readonly Uri AdvisoryOne = new("https://www.oracle.com/security-alerts/cpuapr2024-01.html");
    private static readonly Uri AdvisoryTwo = new("https://www.oracle.com/security-alerts/cpuapr2024-02.html");
    private static readonly Uri CalendarUri = new("https://www.oracle.com/security-alerts/cpuapr2024.html");

    public OracleConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
    {
        _fixture = fixture;
        _timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 18, 0, 0, 0, TimeSpan.Zero));
        _handler = new CannedHttpMessageHandler();
        _output = output;
    }

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

        var calendarFetcher = provider.GetRequiredService<OracleCalendarFetcher>();
        var discovered = await calendarFetcher.GetAdvisoryUrisAsync(CancellationToken.None);
        _output.WriteLine("Calendar URIs: " + string.Join(", ", discovered.Select(static uri => uri.AbsoluteUri)));
        Assert.Equal(2, discovered.Count);

        // Re-seed fixtures because calendar fetch consumes canned responses.
        SeedDetails();

        var connector = provider.GetRequiredService<OracleConnector>();
        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);
        _output.WriteLine("Advisories fetched: " + string.Join(", ", advisories.Select(static a => a.AdvisoryKey)));
        _output.WriteLine($"Advisory count: {advisories.Count}");
        Assert.Equal(2, advisories.Count);

        var first = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-01-html");
        var second = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
        Assert.Equal(new DateTimeOffset(2024, 4, 18, 12, 30, 0, TimeSpan.Zero), first.Published);
        Assert.Equal(new DateTimeOffset(2024, 4, 19, 8, 15, 0, TimeSpan.Zero), second.Published);
        Assert.All(advisories, advisory =>
        {
            Assert.True(advisory.Aliases.Any(alias => alias.StartsWith("CVE-", StringComparison.Ordinal)), $"Expected CVE alias for {advisory.AdvisoryKey}");
            Assert.NotEmpty(advisory.AffectedPackages);
        });

        var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
        var expected = ReadFixture("oracle-advisories.snapshot.json");
        var normalizedSnapshot = Normalize(snapshot);
        var normalizedExpected = Normalize(expected);
        if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
        {
            var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", "oracle-advisories.actual.json");
            var actualDirectory = Path.GetDirectoryName(actualPath);
            if (!string.IsNullOrEmpty(actualDirectory))
            {
                Directory.CreateDirectory(actualDirectory);
            }
            File.WriteAllText(actualPath, snapshot);
        }

        Assert.Equal(normalizedExpected, normalizedSnapshot);

        var psirtCollection = _fixture.Database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.PsirtFlags);
        var flags = await psirtCollection.Find(Builders<BsonDocument>.Filter.Empty).ToListAsync();
        _output.WriteLine("Psirt flags: " + string.Join(", ", flags.Select(doc => doc.GetValue("_id", BsonValue.Create("<missing>")).ToString())));
        Assert.Equal(2, flags.Count);
        Assert.All(flags, doc => Assert.Equal("Oracle", doc["vendor"].AsString));
    }

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

        var connector = provider.GetRequiredService<OracleConnector>();
        await connector.FetchAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.MapAsync(provider, CancellationToken.None);

        // Second run with unchanged documents should rely on fetch cache.
        SeedDetails();
        await connector.FetchAsync(provider, CancellationToken.None);
        await connector.ParseAsync(provider, CancellationToken.None);
        await connector.MapAsync(provider, CancellationToken.None);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        var cursor = OracleCursor.FromBson(state!.Cursor);
        Assert.Empty(cursor.PendingDocuments);
        Assert.Empty(cursor.PendingMappings);
        Assert.Equal(2, cursor.FetchCache.Count);
        Assert.All(cursor.FetchCache.Values, entry => Assert.False(string.IsNullOrWhiteSpace(entry.Sha256)));

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

        var second = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
        Assert.NotNull(second);
        Assert.Equal(DocumentStatuses.Mapped, second!.Status);

        var dtoCollection = _fixture.Database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.Dto);
        var dtoCount = await dtoCollection.CountDocumentsAsync(Builders<BsonDocument>.Filter.Empty);
        Assert.Equal(2, dtoCount);
    }

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

        AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024-single.html");
        AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");

        var connector = provider.GetRequiredService<OracleConnector>();
        await connector.FetchAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.MapAsync(provider, CancellationToken.None);

        var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
        var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
        Assert.Single(advisories);
        Assert.Equal("oracle/cpuapr2024-01-html", advisories[0].AdvisoryKey);

        _handler.Clear();
        AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
        AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
        AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");

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

        advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
        Assert.Equal(2, advisories.Count);
        Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
    }

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

        AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
        AddDetailResponse(AdvisoryOne, "oracle-detail-invalid.html", "\"oracle-001\"");
        AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");

        var connector = provider.GetRequiredService<OracleConnector>();
        await connector.FetchAsync(provider, CancellationToken.None);
        _timeProvider.Advance(TimeSpan.FromMinutes(1));
        await connector.ParseAsync(provider, CancellationToken.None);

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var invalidDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryOne.ToString(), CancellationToken.None);
        Assert.NotNull(invalidDocument);
        _output.WriteLine($"Invalid document status: {invalidDocument!.Status}");

        var rawDoc = await _fixture.Database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.Document)
            .Find(Builders<BsonDocument>.Filter.Eq("uri", AdvisoryOne.ToString()))
            .FirstOrDefaultAsync();
        if (rawDoc is not null)
        {
            _output.WriteLine("Raw document: " + rawDoc.ToJson());
        }

        var dtoStore = provider.GetRequiredService<IDtoStore>();
        var invalidDto = await dtoStore.FindByDocumentIdAsync(invalidDocument.Id, CancellationToken.None);
        if (invalidDto is not null)
        {
            _output.WriteLine("Validation unexpectedly succeeded. DTO: " + invalidDto.Payload.ToJson());
        }
        Assert.Equal(DocumentStatuses.Failed, invalidDocument.Status);
        Assert.Null(invalidDto);

        var validDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
        Assert.NotNull(validDocument);
        Assert.Equal(DocumentStatuses.PendingMap, validDocument!.Status);

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

        var advisories = await provider.GetRequiredService<IAdvisoryStore>().GetRecentAsync(10, CancellationToken.None);
        Assert.Single(advisories);
        Assert.Equal("oracle/cpuapr2024-02-html", advisories[0].AdvisoryKey);

        var psirtCollection = _fixture.Database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.PsirtFlags);
        var flagCount = await psirtCollection.CountDocumentsAsync(Builders<BsonDocument>.Filter.Empty);
        Assert.Equal(1, flagCount);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        var cursor = OracleCursor.FromBson(state!.Cursor);
        Assert.Empty(cursor.PendingDocuments);
        Assert.Empty(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.AddSingleton(_handler);

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

        services.AddSourceCommon();
        services.AddOracleConnector(opts =>
        {
            opts.CalendarUris = new List<Uri> { CalendarUri };
            opts.RequestDelay = TimeSpan.Zero;
        });

        services.Configure<HttpClientFactoryOptions>(OracleOptions.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 SeedDetails()
    {
        AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
        AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
        AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
    }

    private void AddCalendarResponse(Uri uri, string fixture)
    {
        _handler.AddResponse(uri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
            };

            return response;
        });
    }

    private void AddDetailResponse(Uri uri, string fixture, string? etag)
    {
        _handler.AddResponse(uri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
            };

            if (!string.IsNullOrEmpty(etag))
            {
                response.Headers.ETag = new EntityTagHeaderValue(etag);
            }

            return response;
        });
    }

    private static string ReadFixture(string filename)
    {
        var primary = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", filename);
        if (File.Exists(primary))
        {
            return File.ReadAllText(primary);
        }

        var fallback = Path.Combine(AppContext.BaseDirectory, "Oracle", "Fixtures", filename);
        if (File.Exists(fallback))
        {
            return File.ReadAllText(fallback);
        }

        throw new FileNotFoundException($"Fixture '{filename}' not found in test output.", filename);
    }

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

    public Task InitializeAsync() => Task.CompletedTask;

    public Task DisposeAsync() => Task.CompletedTask;
}
