using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;

namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;

/// <summary>
/// Integration coverage for the "notify-and-fetch" deploy seam: central stages a flattened
/// config in a <see cref="PendingDeployment"/> row and serves it from
/// <c>GET /api/internal/deployments/{deploymentId}/config</c> (token in the
/// <c>X-Deployment-Token</c> header); the SITE fetches it over HTTP via
/// <see cref="HttpDeploymentConfigFetcher"/>. This decouples config size from Akka's 128 KB
/// remote-frame limit.
///
/// <para>
/// <b>Harness.</b> A minimal in-memory ASP.NET Core <see cref="TestServer"/> (NOT the full
/// <c>WebApplicationFactory&lt;Program&gt;</c>) maps ONLY the real
/// <see cref="DeploymentConfigEndpoints.MapDeploymentConfigAPI"/> endpoint over a real
/// <see cref="DeploymentManagerRepository"/> backed by a real (SQLite in-memory)
/// <see cref="ScadaBridgeDbContext"/>. The light TestServer is preferred over the full Host
/// because the central Host startup (migrations, Akka remoting, cluster bootstrap) cannot run
/// in this sandbox — and the real seam under test is the production serve-endpoint plus the
/// production fetcher over an in-memory HTTP transport, both of which run hermetically.
/// </para>
///
/// <para>
/// <b>Sandbox note.</b> A full two-cluster end-to-end over Akka.Remote dot-netty sockets is
/// NOT exercised here (sockets cannot bind in this sandbox); the actor-level apply/replication
/// is covered by unit tests and the real cluster by a separate live-smoke task. These tests
/// prove the HTTP seam — the part the size-limit fix hinges on.
/// </para>
/// </summary>
public sealed class DeployNotifyFetchTests
{
    /// <summary>
    /// SQLite-adapted <see cref="ScadaBridgeDbContext"/>: maps <see cref="DateTimeOffset"/> columns
    /// to sortable ISO 8601 strings (SQLite has no native DateTimeOffset), drops the SQL Server
    /// rowversion concurrency token on <see cref="DeploymentRecord"/>, and is constructed with an
    /// ephemeral Data Protection provider so secret-bearing columns are write-capable.
    /// </summary>
    private sealed class SqliteFetchDbContext : ScadaBridgeDbContext
    {
        public SqliteFetchDbContext(DbContextOptions<ScadaBridgeDbContext> options)
            : base(options, new EphemeralDataProtectionProvider())
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            // SQLite cannot auto-generate SQL Server rowversion values.
            modelBuilder.Entity<DeploymentRecord>(builder =>
            {
                builder.Property(d => d.RowVersion)
                    .IsRequired(false)
                    .IsConcurrencyToken(false)
                    .ValueGeneratedNever();
            });

            var converter = new ValueConverter<DateTimeOffset, string>(
                v => v.UtcDateTime.ToString("o"),
                v => DateTimeOffset.Parse(v));
            var nullableConverter = new ValueConverter<DateTimeOffset?, string?>(
                v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
                v => v != null ? DateTimeOffset.Parse(v) : null);

            foreach (var entityType in modelBuilder.Model.GetEntityTypes())
            {
                foreach (var property in entityType.GetProperties())
                {
                    if (property.ClrType == typeof(DateTimeOffset))
                    {
                        property.SetValueConverter(converter);
                        property.SetColumnType("TEXT");
                    }
                    else if (property.ClrType == typeof(DateTimeOffset?))
                    {
                        property.SetValueConverter(nullableConverter);
                        property.SetColumnType("TEXT");
                    }
                }
            }
        }
    }

    /// <summary>
    /// One self-contained test fixture: a kept-open SQLite in-memory connection (an in-memory
    /// database is destroyed when its last connection closes), an in-memory <see cref="TestServer"/>
    /// hosting ONLY the real config-fetch endpoint over a real repository, and the in-memory
    /// <see cref="HttpClient"/> the production fetcher drives.
    /// </summary>
    private sealed class FetchHarness : IDisposable
    {
        private readonly SqliteConnection _connection;
        private readonly IHost _host;
        private readonly DbContextOptions<ScadaBridgeDbContext> _dbOptions;

        public HttpClient Client { get; }

        /// <summary>Base URL the site presents to the fetcher (the test server's address).</summary>
        public string BaseUrl { get; }

        private FetchHarness(
            SqliteConnection connection,
            IHost host,
            DbContextOptions<ScadaBridgeDbContext> dbOptions,
            HttpClient client)
        {
            _connection = connection;
            _host = host;
            _dbOptions = dbOptions;
            Client = client;
            BaseUrl = client.BaseAddress!.ToString();
        }

        public static async Task<FetchHarness> CreateAsync()
        {
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();

            var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
                .UseSqlite(connection)
                .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
                .Options;

            // Create the schema once on a throwaway context over the shared connection.
            using (var schemaContext = new SqliteFetchDbContext(dbOptions))
            {
                await schemaContext.Database.EnsureCreatedAsync();
            }

            // Minimal host: routing + the REAL serve-endpoint over the REAL repository.
            var host = await new HostBuilder()
                .ConfigureWebHost(webBuilder =>
                {
                    webBuilder
                        .UseTestServer()
                        .ConfigureServices(services =>
                        {
                            services.AddRouting();
                            services.AddScoped<ScadaBridgeDbContext>(_ => new SqliteFetchDbContext(dbOptions));
                            services.AddScoped<IDeploymentManagerRepository>(sp =>
                                new DeploymentManagerRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
                        })
                        .Configure(app =>
                        {
                            app.UseRouting();
                            app.UseEndpoints(endpoints => endpoints.MapDeploymentConfigAPI());
                        });
                })
                .StartAsync();

            var client = host.GetTestClient();
            return new FetchHarness(connection, host, dbOptions, client);
        }

        /// <summary>
        /// Seeds an <see cref="Instance"/> (with its required <see cref="Site"/> + <see cref="Template"/>)
        /// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a
        /// real row is required.
        /// </summary>
        public async Task<int> SeedInstanceAsync(string uniqueName)
        {
            await using var ctx = new SqliteFetchDbContext(_dbOptions);
            var site = new Site($"Site-{uniqueName}", $"S-{uniqueName}");
            var template = new Template($"T-{uniqueName}");
            ctx.Sites.Add(site);
            ctx.Templates.Add(template);
            await ctx.SaveChangesAsync();

            var instance = new Instance(uniqueName) { SiteId = site.Id, TemplateId = template.Id };
            ctx.Instances.Add(instance);
            await ctx.SaveChangesAsync();
            return instance.Id;
        }

        /// <summary>
        /// Stages a pending deployment through the REAL repository path
        /// (<see cref="DeploymentManagerRepository.AddPendingDeploymentAsync"/> + SaveChanges), so
        /// supersession of any prior row for the same instance is exercised exactly as in production.
        /// </summary>
        public async Task StageAsync(PendingDeployment pending)
        {
            await using var ctx = new SqliteFetchDbContext(_dbOptions);
            var repo = new DeploymentManagerRepository(ctx);
            await repo.AddPendingDeploymentAsync(pending);
            await repo.SaveChangesAsync();
        }

        public void Dispose()
        {
            Client.Dispose();
            _host.Dispose();
            _connection.Dispose();
        }
    }

    private static HttpDeploymentConfigFetcher NewFetcher(FetchHarness harness)
        => new(harness.Client, NullLogger<HttpDeploymentConfigFetcher>.Instance);

    private static PendingDeployment NewPending(
        string deploymentId, int instanceId, string config, string token, DateTimeOffset expiresAtUtc)
        => new(
            deploymentId, instanceId, revisionHash: "rev-" + deploymentId,
            configurationJson: config, token: token,
            createdAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1), expiresAtUtc: expiresAtUtc);

    /// <summary>
    /// Deterministic JSON string comfortably larger than Akka's 128 KB remote-frame limit
    /// (~204 KB here). It need not be a real FlattenedConfiguration — the endpoint serves the
    /// stored string verbatim.
    /// </summary>
    private static string BuildLargeConfigJson()
    {
        // 200 KiB of payload + JSON wrapper. ASCII only, so byte-for-byte == char-for-char.
        var payload = new string('A', 200 * 1024);
        return "{\"payload\":\"" + payload + "\"}";
    }

    // ----------------------------------------------------------------------------------------
    // 1. Large config round-trips: the core proof that the 128 KB size limit is gone.
    // ----------------------------------------------------------------------------------------

    [Fact]
    public async Task FetchAsync_LargeConfigOver128Kb_RoundTripsExactBytes()
    {
        using var harness = await FetchHarness.CreateAsync();
        var fetcher = NewFetcher(harness);
        var instanceId = await harness.SeedInstanceAsync("LargeInst");

        var largeJson = BuildLargeConfigJson();
        Assert.True(
            largeJson.Length > 128 * 1024,
            $"staged config must exceed the old 128 KB Akka frame limit; was {largeJson.Length} bytes");

        await harness.StageAsync(NewPending(
            "dep-large", instanceId, largeJson, token: "tok-large",
            expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));

        var fetched = await fetcher.FetchAsync(harness.BaseUrl, "dep-large", "tok-large", CancellationToken.None);

        // Byte-for-byte (ordinal) round-trip of a >128 KB body over the HTTP seam.
        Assert.Equal(largeJson, fetched, StringComparer.Ordinal);
        Assert.True(
            fetched.Length > 128 * 1024,
            $"fetched config must exceed the old 128 KB Akka frame limit; was {fetched.Length} bytes");
    }

    // ----------------------------------------------------------------------------------------
    // 2. Supersession: staging B for an instance deletes its prior pending row A.
    //    Fetching A -> 404 (IsSuperseded); fetching B -> B's config.
    // ----------------------------------------------------------------------------------------

    [Fact]
    public async Task FetchAsync_SupersededDeployment_Throws404SupersededAndServesNewest()
    {
        using var harness = await FetchHarness.CreateAsync();
        var fetcher = NewFetcher(harness);
        var instanceId = await harness.SeedInstanceAsync("SupersedeInst");
        var future = DateTimeOffset.UtcNow.AddMinutes(10);

        await harness.StageAsync(NewPending("dep-A", instanceId, "{\"v\":1}", "tok-A", future));
        // Staging B for the SAME instance supersedes (deletes) A via the real repository path.
        await harness.StageAsync(NewPending("dep-B", instanceId, "{\"v\":2}", "tok-B", future));

        // A is gone -> 404 -> IsSuperseded.
        var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
            () => fetcher.FetchAsync(harness.BaseUrl, "dep-A", "tok-A", CancellationToken.None));
        Assert.True(ex.IsSuperseded);

        // B is current and still served.
        var fetchedB = await fetcher.FetchAsync(harness.BaseUrl, "dep-B", "tok-B", CancellationToken.None);
        Assert.Equal("{\"v\":2}", fetchedB, StringComparer.Ordinal);
    }

    // ----------------------------------------------------------------------------------------
    // 3. Wrong token: a live, non-expired row with a bad token -> 401 (NOT 404).
    // ----------------------------------------------------------------------------------------

    [Fact]
    public async Task FetchAsync_WrongToken_Throws401NotSuperseded()
    {
        using var harness = await FetchHarness.CreateAsync();
        var fetcher = NewFetcher(harness);
        var instanceId = await harness.SeedInstanceAsync("TokenInst");

        await harness.StageAsync(NewPending(
            "dep-tok", instanceId, "{\"v\":1}", token: "right-token",
            expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));

        var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
            () => fetcher.FetchAsync(harness.BaseUrl, "dep-tok", "wrong-token", CancellationToken.None));

        // 401 (not 404): a valid, live id with a bad token is unauthorized, not "gone".
        Assert.False(ex.IsSuperseded);
    }

    // ----------------------------------------------------------------------------------------
    // 4. Expired: a row past its TTL is indistinguishable from gone -> 404 (IsSuperseded),
    //    even with the correct token.
    // ----------------------------------------------------------------------------------------

    [Fact]
    public async Task FetchAsync_ExpiredRow_Throws404SupersededEvenWithCorrectToken()
    {
        using var harness = await FetchHarness.CreateAsync();
        var fetcher = NewFetcher(harness);
        var instanceId = await harness.SeedInstanceAsync("ExpiredInst");

        await harness.StageAsync(NewPending(
            "dep-exp", instanceId, "{\"v\":1}", token: "tok-exp",
            expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1))); // TTL already elapsed

        var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
            () => fetcher.FetchAsync(harness.BaseUrl, "dep-exp", "tok-exp", CancellationToken.None));

        // Expired is hidden as 404 (existence-hiding), so IsSuperseded is set.
        Assert.True(ex.IsSuperseded);
    }
}
