using System.Linq;
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.Acsc;
using StellaOps.Feedser.Source.Acsc.Configuration;
using StellaOps.Feedser.Source.Common;
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;
using Xunit;

namespace StellaOps.Feedser.Source.Acsc.Tests.Acsc;

[Collection("mongo-fixture")]
public sealed class AcscConnectorParseTests : IAsyncLifetime
{
    private static readonly Uri BaseEndpoint = new("https://origin.example/");

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

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

    [Fact]
    public async Task ParseAsync_PersistsDtoAndAdvancesCursor()
    {
        await using var provider = await BuildProviderAsync();
        var connector = provider.GetRequiredService<AcscConnector>();

        var feedUri = new Uri(BaseEndpoint, "/feeds/alerts/rss");
        SeedRssResponse(feedUri);

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

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);

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

        var refreshed = await documentStore.FindAsync(document!.Id, CancellationToken.None);
        Assert.NotNull(refreshed);
        Assert.Equal(DocumentStatuses.PendingMap, refreshed!.Status);

        var dtoStore = provider.GetRequiredService<IDtoStore>();
        var dtoRecord = await dtoStore.FindByDocumentIdAsync(document.Id, CancellationToken.None);
        Assert.NotNull(dtoRecord);
        Assert.Equal("acsc.feed.v1", dtoRecord!.SchemaVersion);

        var payload = dtoRecord.Payload;
        Assert.NotNull(payload);
        Assert.Equal("alerts", payload.GetValue("feedSlug").AsString);
        Assert.Single(payload.GetValue("entries").AsBsonArray);

        var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
        var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.DoesNotContain(document.Id.ToString(), state!.Cursor.GetValue("pendingDocuments").AsBsonArray.Select(v => v.AsString));
        Assert.Contains(document.Id.ToString(), state.Cursor.GetValue("pendingMappings").AsBsonArray.Select(v => v.AsString));

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

        var advisoriesStore = provider.GetRequiredService<IAdvisoryStore>();
        var advisories = await advisoriesStore.GetRecentAsync(10, CancellationToken.None);
        Assert.Single(advisories);

        var ordered = advisories
            .OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
            .ToArray();

        WriteOrAssertSnapshot(
            SnapshotSerializer.ToSnapshot(ordered),
            "acsc-advisories.snapshot.json");

        var mappedDocument = await documentStore.FindAsync(document.Id, CancellationToken.None);
        Assert.NotNull(mappedDocument);
        Assert.Equal(DocumentStatuses.Mapped, mappedDocument!.Status);

        state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
        Assert.NotNull(state);
        Assert.True(state!.Cursor.GetValue("pendingMappings").AsBsonArray.Count == 0);
    }

    [Fact]
    public async Task MapAsync_MultiEntryFeedProducesExpectedSnapshot()
    {
        await using var provider = await BuildProviderAsync(options =>
        {
            options.Feeds.Clear();
            options.Feeds.Add(new AcscFeedOptions
            {
                Slug = "multi",
                RelativePath = "/feeds/multi/rss",
                Enabled = true,
            });
        });
        var connector = provider.GetRequiredService<AcscConnector>();

        var feedUri = new Uri(BaseEndpoint, "/feeds/multi/rss");
        SeedMultiEntryResponse(feedUri);

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

        var documentStore = provider.GetRequiredService<IDocumentStore>();
        var dtoStore = provider.GetRequiredService<IDtoStore>();
        var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
        Assert.NotNull(document);
        var dtoRecord = await dtoStore.FindByDocumentIdAsync(document!.Id, CancellationToken.None);
        Assert.NotNull(dtoRecord);
        var payload = dtoRecord!.Payload;
        Assert.NotNull(payload);
        var entries = payload.GetValue("entries").AsBsonArray;
        Assert.Equal(2, entries.Count);
        var fields = entries[0].AsBsonDocument.GetValue("fields").AsBsonDocument;
        Assert.Equal("Critical", fields.GetValue("severity").AsString);
        Assert.Equal("ExampleCo Router X, ExampleCo Router Y", fields.GetValue("systemsAffected").AsString);

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

        var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
        var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
        Assert.Equal(2, advisories.Count);

        var ordered = advisories
            .OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
            .ToArray();

        WriteOrAssertSnapshot(
            SnapshotSerializer.ToSnapshot(ordered),
            "acsc-advisories-multi.snapshot.json");

        var affected = ordered.First(advisory => advisory.AffectedPackages.Any());
        Assert.Contains("ExampleCo Router X", affected.AffectedPackages[0].Identifier);
        Assert.Equal("critical", ordered.First(a => a.Severity is not null).Severity, StringComparer.OrdinalIgnoreCase);
    }

    public Task InitializeAsync() => Task.CompletedTask;

    public Task DisposeAsync() => Task.CompletedTask;

    private async Task<ServiceProvider> BuildProviderAsync(Action<AcscOptions>? configure = null)
    {
        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.AddAcscConnector(options =>
        {
            options.BaseEndpoint = BaseEndpoint;
            options.RelayEndpoint = null;
            options.PreferRelayByDefault = false;
            options.ForceRelay = false;
            options.EnableRelayFallback = false;
            options.RequestTimeout = TimeSpan.FromSeconds(10);
            options.Feeds.Clear();
            options.Feeds.Add(new AcscFeedOptions
            {
                Slug = "alerts",
                RelativePath = "/feeds/alerts/rss",
                Enabled = true,
            });
            configure?.Invoke(options);
        });

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

        services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
        {
            options.MaxAttempts = 1;
            options.BaseDelay = TimeSpan.Zero;
        });

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

    private void SeedRssResponse(Uri uri)
    {
        const string payload = """
            <?xml version="1.0" encoding="UTF-8"?>
            <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
              <channel>
                <title>ACSC Alerts</title>
                <link>https://origin.example/feeds/alerts</link>
                <lastBuildDate>Sun, 12 Oct 2025 04:20:00 GMT</lastBuildDate>
                <item>
                  <title>ACSC-2025-001 Example Advisory</title>
                  <link>https://origin.example/advisories/example</link>
                  <guid>https://origin.example/advisories/example</guid>
                  <pubDate>Sun, 12 Oct 2025 03:00:00 GMT</pubDate>
                  <content:encoded><![CDATA[
                    <p><strong>Serial number:</strong> ACSC-2025-001</p>
                    <p><strong>Advisory type:</strong> Alert</p>
                    <p>First paragraph describing issue.</p>
                    <p>Second paragraph with <a href="https://vendor.example/patch">Vendor patch</a>.</p>
                  ]]></content:encoded>
                </item>
              </channel>
            </rss>
            """;

        _handler.AddResponse(HttpMethod.Get, uri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
            };
            response.Headers.ETag = new EntityTagHeaderValue("\"parse-etag\"");
            response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 4, 20, 0, TimeSpan.Zero);
            return response;
        });
    }

    private void SeedMultiEntryResponse(Uri uri)
    {
        const string payload = """
            <?xml version="1.0" encoding="UTF-8"?>
            <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
              <channel>
                <title>ACSC Advisories</title>
                <link>https://origin.example/feeds/advisories</link>
                <lastBuildDate>Sun, 12 Oct 2025 05:00:00 GMT</lastBuildDate>
                <item>
                  <title>Critical router vulnerability</title>
                  <link>https://origin.example/advisories/router-critical</link>
                  <guid>https://origin.example/advisories/router-critical</guid>
                  <pubDate>Sun, 12 Oct 2025 04:45:00 GMT</pubDate>
                  <content:encoded><![CDATA[
                    <p><strong>Serial number:</strong> ACSC-2025-010</p>
                    <p><strong>Severity:</strong> Critical</p>
                    <p><strong>Systems affected:</strong> ExampleCo Router X, ExampleCo Router Y</p>
                    <p>Remote code execution on ExampleCo routers. See <a href="https://vendor.example/router/patch">vendor patch</a>.</p>
                    <p>CVE references: CVE-2025-0001</p>
                  ]]></content:encoded>
                </item>
                <item>
                  <title>Information bulletin</title>
                  <link>https://origin.example/advisories/info-bulletin</link>
                  <guid>https://origin.example/advisories/info-bulletin</guid>
                  <pubDate>Sun, 12 Oct 2025 02:30:00 GMT</pubDate>
                  <content:encoded><![CDATA[
                    <p><strong>Serial number:</strong> ACSC-2025-011</p>
                    <p><strong>Advisory type:</strong> Bulletin</p>
                    <p>General guidance bulletin.</p>
                  ]]></content:encoded>
                </item>
              </channel>
            </rss>
            """;

        _handler.AddResponse(HttpMethod.Get, uri, () =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
            };
            response.Headers.ETag = new EntityTagHeaderValue("\"multi-etag\"");
            response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 5, 0, 0, TimeSpan.Zero);
            return response;
        });
    }

    private static void WriteOrAssertSnapshot(string snapshot, string filename)
    {
        if (ShouldUpdateFixtures() || !FixtureExists(filename))
        {
            var writable = GetWritableFixturePath(filename);
            Directory.CreateDirectory(Path.GetDirectoryName(writable)!);
            File.WriteAllText(writable, Normalize(snapshot));
            return;
        }

        var expected = Normalize(File.ReadAllText(GetExistingFixturePath(filename)));
        var actual = Normalize(snapshot);

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

        Assert.Equal(expected, actual);
    }

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

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

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

        var projectRelative = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
        if (File.Exists(projectRelative))
        {
            return Path.GetFullPath(projectRelative);
        }

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

    private static string GetWritableFixturePath(string filename)
        => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);

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

    private static bool FixtureExists(string filename)
    {
        try
        {
            _ = GetExistingFixturePath(filename);
            return true;
        }
        catch (FileNotFoundException)
        {
            return false;
        }
    }
}
