using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
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.Driver;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Common.Http;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Jvn;
using StellaOps.Feedser.Source.Jvn.Configuration;
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.Storage.Mongo.JpFlags;
using Xunit.Abstractions;
using StellaOps.Feedser.Testing;

namespace StellaOps.Feedser.Source.Jvn.Tests;

[Collection("mongo-fixture")]
public sealed class JvnConnectorTests : IAsyncLifetime
{
    private const string VulnId = "JVNDB-2024-123456";

    private readonly MongoIntegrationFixture _fixture;
    private readonly ITestOutputHelper _output;
    private readonly FakeTimeProvider _timeProvider;
    private readonly CannedHttpMessageHandler _handler;
    private ServiceProvider? _serviceProvider;

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

    [Fact]
    public async Task FetchParseMap_ProducesDeterministicSnapshot()
    {
        var options = new JvnOptions
        {
            WindowSize = TimeSpan.FromDays(1),
            WindowOverlap = TimeSpan.FromHours(6),
            PageSize = 10,
        };

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

        var now = _timeProvider.GetUtcNow();
        var windowStart = now - options.WindowSize;
        var windowEnd = now;

        var overviewUri = BuildOverviewUri(options, windowStart, windowEnd, startItem: 1);
        _handler.AddTextResponse(overviewUri, ReadFixture("jvnrss-window1.xml"), "application/xml");

        var detailUri = BuildDetailUri(options, VulnId);
        _handler.AddTextResponse(detailUri, ReadFixture("vuldef-JVNDB-2024-123456.xml"), "application/xml");

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

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

        var stateAfterFetch = await provider.GetRequiredService<ISourceStateRepository>()
            .TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
        if (stateAfterFetch?.Cursor is not null)
        {
            _output.WriteLine($"Fetch state cursor: {stateAfterFetch.Cursor.ToJson()}");
        }

        var rawDocuments = await _fixture.Database
            .GetCollection<BsonDocument>("document")
            .Find(Builders<BsonDocument>.Filter.Empty)
            .ToListAsync(CancellationToken.None);
        _output.WriteLine($"Fixture document count: {rawDocuments.Count}");

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

        var stateAfterParse = await provider.GetRequiredService<ISourceStateRepository>()
            .TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
        _output.WriteLine($"Parse state failure reason: {stateAfterParse?.LastFailureReason ?? "<none>"}");
        if (stateAfterParse?.Cursor is not null)
        {
            _output.WriteLine($"Parse state cursor: {stateAfterParse.Cursor.ToJson()}");
        }

        var dtoCollection = provider.GetRequiredService<IMongoDatabase>()
            .GetCollection<BsonDocument>("dto");
        var dtoDocs = await dtoCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync(CancellationToken.None);
        _output.WriteLine($"DTO document count: {dtoDocs.Count}");

        var documentsAfterParse = await _fixture.Database
            .GetCollection<BsonDocument>("document")
            .Find(Builders<BsonDocument>.Filter.Empty)
            .ToListAsync(CancellationToken.None);
        _output.WriteLine($"Document statuses after parse: {string.Join(",", documentsAfterParse.Select(d => d.GetValue("status", BsonValue.Create("<missing>")).AsString))}");

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

        var rawAdvisories = await _fixture.Database
            .GetCollection<BsonDocument>("advisory")
            .Find(Builders<BsonDocument>.Filter.Empty)
            .ToListAsync(CancellationToken.None);
        _output.WriteLine($"Fixture advisory count: {rawAdvisories.Count}");
        Assert.NotEmpty(rawAdvisories);

        var providerDatabase = provider.GetRequiredService<IMongoDatabase>();
        var providerCount = await providerDatabase
            .GetCollection<BsonDocument>("advisory")
            .CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty, cancellationToken: CancellationToken.None);
        _output.WriteLine($"Provider advisory count: {providerCount}");
        Assert.True(providerCount > 0, $"Provider DB advisory count was {providerCount}");

        var typedDocs = await providerDatabase
            .GetCollection<AdvisoryDocument>("advisory")
            .Find(FilterDefinition<AdvisoryDocument>.Empty)
            .ToListAsync(CancellationToken.None);
        _output.WriteLine($"Typed advisory docs: {typedDocs.Count}");
        Assert.NotEmpty(typedDocs);

        var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
        var singleAdvisory = await advisoryStore.FindAsync(VulnId, CancellationToken.None);
        Assert.NotNull(singleAdvisory);
        _output.WriteLine($"singleAdvisory null? {singleAdvisory is null}");

        var canonical = SnapshotSerializer.ToSnapshot(singleAdvisory!).Replace("\r\n", "\n");
        var expected = ReadFixture("expected-advisory.json").Replace("\r\n", "\n");
        if (!string.Equals(expected, canonical, StringComparison.Ordinal))
        {
            var actualPath = Path.Combine(AppContext.BaseDirectory, "Jvn", "Fixtures", "expected-advisory.actual.json");
            Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
            File.WriteAllText(actualPath, canonical);
        }
        Assert.Equal(expected, canonical);

        var jpFlagStore = provider.GetRequiredService<IJpFlagStore>();
        var jpFlag = await jpFlagStore.FindAsync(VulnId, CancellationToken.None);
        Assert.NotNull(jpFlag);
        Assert.Equal("product", jpFlag!.Category);
        Assert.Equal("vulnerable", jpFlag.VendorStatus);

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

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

    private async Task EnsureServiceProviderAsync(JvnOptions 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.AddJvnConnector(opts =>
        {
            opts.BaseEndpoint = template.BaseEndpoint;
            opts.WindowSize = template.WindowSize;
            opts.WindowOverlap = template.WindowOverlap;
            opts.PageSize = template.PageSize;
            opts.RequestDelay = TimeSpan.Zero;
        });

        services.Configure<HttpClientFactoryOptions>(JvnOptions.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 Uri BuildOverviewUri(JvnOptions options, DateTimeOffset windowStart, DateTimeOffset windowEnd, int startItem)
    {
        var (startYear, startMonth, startDay) = ToTokyoDateParts(windowStart);
        var (endYear, endMonth, endDay) = ToTokyoDateParts(windowEnd);

        var parameters = new List<KeyValuePair<string, string>>
        {
            new("method", "getVulnOverviewList"),
            new("feed", "hnd"),
            new("lang", "en"),
            new("rangeDatePublished", "n"),
            new("rangeDatePublic", "n"),
            new("rangeDateFirstPublished", "n"),
            new("dateFirstPublishedStartY", startYear),
            new("dateFirstPublishedStartM", startMonth),
            new("dateFirstPublishedStartD", startDay),
            new("dateFirstPublishedEndY", endYear),
            new("dateFirstPublishedEndM", endMonth),
            new("dateFirstPublishedEndD", endDay),
            new("startItem", startItem.ToString(CultureInfo.InvariantCulture)),
            new("maxCountItem", options.PageSize.ToString(CultureInfo.InvariantCulture)),
        };

        return BuildUri(options.BaseEndpoint, parameters);
    }

    private static Uri BuildDetailUri(JvnOptions options, string vulnId)
    {
        var parameters = new List<KeyValuePair<string, string>>
        {
            new("method", "getVulnDetailInfo"),
            new("feed", "hnd"),
            new("lang", "en"),
            new("vulnId", vulnId),
        };

        return BuildUri(options.BaseEndpoint, parameters);
    }

    private static Uri BuildUri(Uri baseEndpoint, IEnumerable<KeyValuePair<string, string>> parameters)
    {
        var query = string.Join(
            "&",
            parameters.Select(parameter =>
                $"{WebUtility.UrlEncode(parameter.Key)}={WebUtility.UrlEncode(parameter.Value)}"));

        var builder = new UriBuilder(baseEndpoint)
        {
            Query = query,
        };

        return builder.Uri;
    }

    private static (string Year, string Month, string Day) ToTokyoDateParts(DateTimeOffset timestamp)
    {
        var local = timestamp.ToOffset(TimeSpan.FromHours(9)).Date;
        return (
            local.Year.ToString("D4", CultureInfo.InvariantCulture),
            local.Month.ToString("D2", CultureInfo.InvariantCulture),
            local.Day.ToString("D2", CultureInfo.InvariantCulture));
    }

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

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

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

    public Task InitializeAsync() => Task.CompletedTask;

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