using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.LocalDb.Contracts;
using ZB.MOM.WW.LocalDb.Internal;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.LocalDb.Replication.Internal;

namespace ZB.MOM.WW.LocalDb.Tests;

public sealed class MetricsTests : IAsyncLifetime
{
    private const string OrdersSql = "CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)";
    private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(30);

    private readonly List<string> _paths = [];
    private readonly List<IDisposable> _disposables = [];
    private readonly List<IHost> _hosts = [];
    private readonly List<ServiceProvider> _providers = [];

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        foreach (var provider in _providers)
            await provider.DisposeAsync();
        foreach (var host in _hosts)
        {
            try { await host.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ }
            host.Dispose();
        }
        foreach (var d in _disposables)
            d.Dispose();
        Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
        foreach (var p in _paths)
        {
            if (File.Exists(p)) File.Delete(p);
            if (File.Exists(p + "-wal")) File.Delete(p + "-wal");
            if (File.Exists(p + "-shm")) File.Delete(p + "-shm");
        }
    }

    // ---- in-memory duplex tests -----------------------------------------------------------

    [Fact]
    public async Task Counters_IncrementOnApply()
    {
        using var metricsB = new LocalDbMetrics();
        using var collector = new Collector(metricsB.Meter);

        var a = await NewSideAsync();
        var b = await NewSideAsync(metrics: metricsB);
        await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'FROM_A', 10)");

        var (da, db) = DuplexPair();
        using var cts = new CancellationTokenSource(RunTimeout);
        var runA = a.Session.RunAsync(da, cts.Token);
        var runB = b.Session.RunAsync(db, cts.Token);

        await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);

        Assert.True(collector.Sum("localdb.sync.applied") > 0);

        cts.Cancel();
        await SwallowAsync(runA);
        await SwallowAsync(runB);
    }

    [Fact]
    public async Task DeadLetter_Counter_Increments()
    {
        using var metrics = new LocalDbMetrics();
        using var collector = new Collector(metrics.Meter);

        var a = await NewSideAsync(metrics: metrics);
        var (duplex, toSession, fromSession) = ScriptedPeer();

        using var cts = new CancellationTokenSource(RunTimeout);
        var run = a.Session.RunAsync(duplex, cts.Token);

        await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);

        // A non-tombstone entry with null row_json is poison: the applier dead-letters it.
        await toSession.WriteAsync(new SyncMessage
        {
            DeltaBatch = new DeltaBatch
            {
                Entries = { new OplogEntry
                {
                    Seq = 1, TableName = "orders", PkJson = "{\"id\":1}",
                    Hlc = 5_000_000, NodeId = "peer", IsTombstone = false,
                } },
            },
        }, cts.Token);
        await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token);

        Assert.Equal(1, collector.Sum("localdb.sync.dead_lettered"));

        cts.Cancel();
        await SwallowAsync(run);
    }

    [Fact]
    public async Task OplogDepth_Gauge_ReportsBacklog()
    {
        using var metrics = new LocalDbMetrics();
        using var collector = new Collector(metrics.Meter);

        var a = await NewSideAsync();
        var b = await NewSideAsync();
        metrics.OplogDepthProvider = a.Store.GetOplogDepthSync;

        const int rows = 4;
        for (var i = 1; i <= rows; i++)
            await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i });

        // No session running yet: the gauge reflects the unacked oplog backlog.
        collector.Clear();
        collector.Observe();
        Assert.Equal(rows, collector.LastLong("localdb.oplog.depth"));

        var (da, db) = DuplexPair();
        using var cts = new CancellationTokenSource(RunTimeout);
        var runA = a.Session.RunAsync(da, cts.Token);
        var runB = b.Session.RunAsync(db, cts.Token);

        await WaitForAsync(async () => await OplogCount(a.Db) == 0, RunTimeout);

        collector.Clear();
        collector.Observe();
        Assert.Equal(0, collector.LastLong("localdb.oplog.depth"));

        cts.Cancel();
        await SwallowAsync(runA);
        await SwallowAsync(runB);
    }

    [Fact]
    public async Task SyncLag_Gauge_EmptyUntilFirstSync_ThenReports()
    {
        using var metrics = new LocalDbMetrics();
        using var collector = new Collector(metrics.Meter);

        DateTimeOffset? lastSync = null;
        metrics.LastSyncUtcProvider = () => lastSync;

        // Before any sync: the gauge yields no measurement (last_sync unknown).
        collector.Observe();
        Assert.Empty(collector.Doubles("localdb.sync.lag.seconds"));

        lastSync = DateTimeOffset.UtcNow;
        collector.Observe();
        var lags = collector.Doubles("localdb.sync.lag.seconds");
        Assert.NotEmpty(lags);
        Assert.True(lags[^1] >= 0, $"lag {lags[^1]} should be non-negative");
        Assert.True(lags[^1] < 60, $"lag {lags[^1]} should be small");
    }

    [Fact]
    public async Task SnapshotOnlySync_UpdatesLastSyncAndLag()
    {
        var statusA = new SyncStatus();
        var statusB = new SyncStatus();
        using var metricsB = new LocalDbMetrics { LastSyncUtcProvider = () => statusB.LastSyncUtc };
        using var collector = new Collector(metricsB.Meter);

        var a = await NewSideAsync(status: statusA);
        var b = await NewSideAsync(metrics: metricsB, status: statusB);
        await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'SNAP', 10)");
        // A owes B a full snapshot; the pump then skips the tail deltas the snapshot covers, so
        // B converges via the snapshot path alone (no delta batch ever reaches it).
        await a.Store.SetNeedsSnapshotAsync(true, default);

        // Before any sync the lag gauge yields no measurement.
        collector.Observe();
        Assert.Empty(collector.Doubles("localdb.sync.lag.seconds"));

        var (da, db) = DuplexPair();
        using var cts = new CancellationTokenSource(RunTimeout);
        var runA = a.Session.RunAsync(da, cts.Token);
        var runB = b.Session.RunAsync(db, cts.Token);

        await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);
        await WaitForAsync(() => Task.FromResult(statusB.LastSyncUtc is not null), RunTimeout);

        Assert.NotNull(statusB.LastSyncUtc); // receive path: SnapshotComplete
        Assert.NotNull(statusA.LastSyncUtc); // send path: SnapshotSender returned

        collector.Observe();
        var lags = collector.Doubles("localdb.sync.lag.seconds");
        Assert.NotEmpty(lags);
        Assert.True(lags[^1] >= 0, $"lag {lags[^1]} should be non-negative");

        cts.Cancel();
        await SwallowAsync(runA);
        await SwallowAsync(runB);
    }

    [Fact]
    public void BothRoles_ActiveSessions_ConnectedUntilLastEnds()
    {
        // A node running both roles holds two concurrent sessions; one ending must not clobber
        // the other's Connected signal.
        var status = new SyncStatus();
        Assert.False(status.Connected);

        status.SessionStarted();
        Assert.True(status.Connected);
        status.SessionStarted();
        Assert.True(status.Connected);

        status.SessionEnded();
        Assert.True(status.Connected);
        status.SessionEnded();
        Assert.False(status.Connected);
    }

    [Fact]
    public async Task Metrics_NullSafe_NoMetricsRegistered()
    {
        // A session constructed with metrics=null and status=null must sync without an NRE.
        var a = await NewSideAsync();
        var b = await NewSideAsync();
        await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 10)");

        var (da, db) = DuplexPair();
        using var cts = new CancellationTokenSource(RunTimeout);
        var runA = a.Session.RunAsync(da, cts.Token);
        var runB = b.Session.RunAsync(db, cts.Token);

        await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);

        cts.Cancel();
        await SwallowAsync(runA);
        await SwallowAsync(runB);
    }

    // ---- gRPC host tests ------------------------------------------------------------------

    [Fact]
    public async Task Reconnects_Counter_Increments()
    {
        var serverHost = await BuildServerAsync(NewDbPath());
        var handler = serverHost.GetTestServer().CreateHandler();

        var calls = 0;
        GrpcChannel Factory()
        {
            if (Interlocked.Increment(ref calls) == 1)
                throw new InvalidOperationException("transient dial failure");
            return ChannelOver(handler);
        }

        var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", Factory);
        var clientDb = clientProvider.GetRequiredService<ILocalDb>();
        await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
        var metrics = clientProvider.GetRequiredService<LocalDbMetrics>();
        using var collector = new Collector(metrics.Meter);

        using var cts = new CancellationTokenSource(RunTimeout);
        await bg.StartAsync(cts.Token);

        var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
        await WaitForAsync(async () => (await ReadOrders(serverDb)).Count == 1, RunTimeout);

        Assert.True(bg.ConnectionAttempts >= 2, $"attempts {bg.ConnectionAttempts}");
        Assert.True(collector.Sum("localdb.sync.reconnects") >= 1, $"reconnects {collector.Sum("localdb.sync.reconnects")}");

        await bg.StopAsync(CancellationToken.None);
    }

    [Fact]
    public async Task SyncStatus_ReflectsSessionLifecycle()
    {
        var serverHost = await BuildServerAsync(NewDbPath());
        var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
        await serverDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'S', 20)");
        var handler = serverHost.GetTestServer().CreateHandler();

        var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", () => ChannelOver(handler));
        var clientDb = clientProvider.GetRequiredService<ILocalDb>();
        await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
        var status = clientProvider.GetRequiredService<ISyncStatus>();
        var serverStatus = serverHost.Services.GetRequiredService<ISyncStatus>();

        Assert.False(status.Connected);
        Assert.False(serverStatus.Connected);

        using var cts = new CancellationTokenSource(RunTimeout);
        await bg.StartAsync(cts.Token);

        await WaitForAsync(() => Task.FromResult(status.Connected), RunTimeout);
        await WaitForAsync(() => Task.FromResult(serverStatus.Connected), RunTimeout);
        await WaitForAsync(
            async () => (await ReadOrders(clientDb)).Count == 2 && (await ReadOrders(serverDb)).Count == 2,
            RunTimeout);

        Assert.NotNull(status.PeerNodeId);
        Assert.NotNull(status.LastSyncUtc);
        Assert.NotNull(serverStatus.PeerNodeId);

        await bg.StopAsync(CancellationToken.None);
        await WaitForAsync(() => Task.FromResult(!status.Connected), RunTimeout);
        Assert.False(status.Connected);
        await WaitForAsync(() => Task.FromResult(!serverStatus.Connected), RunTimeout);
        Assert.False(serverStatus.Connected);
    }

    // ---- in-memory harness ----------------------------------------------------------------

    private sealed record Side(SqliteLocalDb Db, OplogStore Store, LwwApplier Applier, SyncSession Session);

    private async Task<Side> NewSideAsync(LocalDbMetrics? metrics = null, SyncStatus? status = null)
    {
        var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
        _paths.Add(path);
        var db = new SqliteLocalDb(new LocalDbOptions { Path = path });
        _disposables.Add(db);
        await db.ExecuteAsync(OrdersSql);
        db.RegisterReplicated("orders");

        var options = new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) };
        var store = new OplogStore(db, options);
        var applier = new LwwApplier(db);
        var session = new SyncSession(db, store, applier, options, NullLogger.Instance, null, metrics, status);
        var streamer = new SnapshotStreamer(db, store, options, NullLogger.Instance);
        session.SnapshotSender = streamer.SendAsync;
        session.SnapshotApplier = new SnapshotApplier(db, applier, store, NullLogger.Instance);
        return new Side(db, store, applier, session);
    }

    private static (SyncDuplex A, SyncDuplex B) DuplexPair()
    {
        var aToB = Channel.CreateUnbounded<SyncMessage>();
        var bToA = Channel.CreateUnbounded<SyncMessage>();
        var a = new SyncDuplex { Send = (m, ct) => aToB.Writer.WriteAsync(m, ct).AsTask(), Inbox = bToA.Reader };
        var b = new SyncDuplex { Send = (m, ct) => bToA.Writer.WriteAsync(m, ct).AsTask(), Inbox = aToB.Reader };
        return (a, b);
    }

    private static (SyncDuplex Duplex, ChannelWriter<SyncMessage> ToSession, ChannelReader<SyncMessage> FromSession) ScriptedPeer()
    {
        var toSession = Channel.CreateUnbounded<SyncMessage>();
        var fromSession = Channel.CreateUnbounded<SyncMessage>();
        var duplex = new SyncDuplex { Send = (m, ct) => fromSession.Writer.WriteAsync(m, ct).AsTask(), Inbox = toSession.Reader };
        return (duplex, toSession.Writer, fromSession.Reader);
    }

    private static Handshake MatchingHandshake(SqliteLocalDb sessionDb, string peerNodeId)
    {
        var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = 1, LastAppliedRemoteSeq = 0 };
        foreach (var t in sessionDb.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
            h.Tables.Add(new TableDigest { TableName = t.Name, Digest = t.Digest });
        return h;
    }

    private static async Task DriveHandshakeAsync(
        SqliteLocalDb sessionDb, ChannelWriter<SyncMessage> toSession, ChannelReader<SyncMessage> fromSession, CancellationToken ct)
    {
        await NextAsync(fromSession, ct);
        await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(sessionDb, "peer") }, ct);
        await NextAsync(fromSession, ct);
        await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct);
    }

    private static async Task<SyncMessage> NextAsync(ChannelReader<SyncMessage> reader, CancellationToken ct)
    {
        await reader.WaitToReadAsync(ct);
        reader.TryRead(out var msg);
        return msg!;
    }

    private static async Task<SyncMessage> NextOfCaseAsync(
        ChannelReader<SyncMessage> reader, SyncMessage.MsgOneofCase msgCase, CancellationToken ct)
    {
        SyncMessage msg;
        do { msg = await NextAsync(reader, ct); } while (msg.MsgCase != msgCase);
        return msg;
    }

    private static async Task SwallowAsync(Task task)
    {
        try { await task; }
        catch (OperationCanceledException) { }
    }

    // ---- gRPC host harness ----------------------------------------------------------------

    private string NewDbPath()
    {
        var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
        _paths.Add(path);
        return path;
    }

    private static void OnReady(ILocalDb db)
    {
        using var conn = db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = OrdersSql;
        cmd.ExecuteNonQuery();
        db.RegisterReplicated("orders");
    }

    private static IConfiguration ConfigFor(string path, string? peerAddress)
    {
        var dict = new Dictionary<string, string?>
        {
            ["LocalDb:Path"] = path,
            ["LocalDb:Replication:FlushInterval"] = "00:00:00.020",
        };
        if (peerAddress is not null) dict["LocalDb:Replication:PeerAddress"] = peerAddress;
        return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
    }

    private static GrpcChannel ChannelOver(HttpMessageHandler handler) =>
        GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { HttpHandler = handler });

    private async Task<IHost> BuildServerAsync(string path)
    {
        var config = ConfigFor(path, peerAddress: null);
        var host = await new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddRouting();
                    services.AddGrpc();
                    services.AddZbLocalDb(config, OnReady);
                    services.AddZbLocalDbReplication(config);
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(e => e.MapZbLocalDbSync());
                });
            })
            .StartAsync();
        _hosts.Add(host);
        return host;
    }

    private (ServiceProvider Provider, SyncBackgroundService Bg) BuildClient(
        string path, string peerAddress, Func<GrpcChannel> channelFactory)
    {
        var config = ConfigFor(path, peerAddress);
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddZbLocalDb(config, OnReady);
        services.AddZbLocalDbReplication(config);
        var provider = services.BuildServiceProvider();
        _providers.Add(provider);

        var bg = provider.GetServices<IHostedService>().OfType<SyncBackgroundService>().Single();
        bg.ChannelFactory = channelFactory;
        return (provider, bg);
    }

    private static async Task<List<(long Id, string? Sku, long? Qty)>> ReadOrders(ILocalDb db)
    {
        var rows = await db.QueryAsync(
            "SELECT id, sku, qty FROM orders ORDER BY id",
            x => (x.GetInt64(0), x.IsDBNull(1) ? null : x.GetString(1), (long?)(x.IsDBNull(2) ? null : x.GetInt64(2))));
        return rows.ToList();
    }

    private static async Task<long> OplogCount(ILocalDb db)
    {
        var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0));
        return r[0];
    }

    private static async Task WaitForAsync(Func<Task<bool>> predicate, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            if (await predicate()) return;
            await Task.Delay(20);
        }
        throw new TimeoutException("Condition not reached within " + timeout);
    }

    // ---- metric collector -----------------------------------------------------------------

    private sealed class Collector : IDisposable
    {
        private readonly MeterListener _listener;
        private readonly object _lock = new();
        private readonly Dictionary<string, List<long>> _longs = new(StringComparer.Ordinal);
        private readonly Dictionary<string, List<double>> _doubles = new(StringComparer.Ordinal);

        public Collector(Meter meter)
        {
            _listener = new MeterListener
            {
                InstrumentPublished = (inst, l) =>
                {
                    if (ReferenceEquals(inst.Meter, meter))
                        l.EnableMeasurementEvents(inst);
                },
            };
            _listener.SetMeasurementEventCallback<long>((inst, val, _, _) =>
            {
                lock (_lock) Append(_longs, inst.Name, val);
            });
            _listener.SetMeasurementEventCallback<double>((inst, val, _, _) =>
            {
                lock (_lock) Append(_doubles, inst.Name, val);
            });
            _listener.Start();
        }

        private static void Append<T>(Dictionary<string, List<T>> map, string name, T value)
        {
            if (!map.TryGetValue(name, out var list))
            {
                list = [];
                map[name] = list;
            }
            list.Add(value);
        }

        public void Observe() => _listener.RecordObservableInstruments();

        public long Sum(string name)
        {
            lock (_lock) return _longs.TryGetValue(name, out var l) ? l.Sum() : 0;
        }

        public long? LastLong(string name)
        {
            lock (_lock) return _longs.TryGetValue(name, out var l) && l.Count > 0 ? l[^1] : null;
        }

        public IReadOnlyList<double> Doubles(string name)
        {
            lock (_lock) return _doubles.TryGetValue(name, out var l) ? l.ToList() : [];
        }

        public void Clear()
        {
            lock (_lock)
            {
                _longs.Clear();
                _doubles.Clear();
            }
        }

        public void Dispose() => _listener.Dispose();
    }
}
