using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb.Contracts;
using ZB.MOM.WW.LocalDb.Internal;
using ZB.MOM.WW.LocalDb.Registration;

namespace ZB.MOM.WW.LocalDb.Replication.Internal;

/// <summary>
/// Consumes an inbound full-snapshot the receive loop routes to it: SnapshotBegin opens a receive,
/// each SnapshotBatch merges through the SAME last-writer-wins path as deltas (never truncating —
/// the receiver may hold newer local writes), and SnapshotComplete advances the durable watermark
/// so incremental deltas resume above it.
/// </summary>
internal interface ISnapshotApplier
{
    Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct);
    Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct);
    Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct);
}

/// <summary>
/// Streams a full snapshot of every registered table to a peer that has fallen behind the pruned
/// oplog horizon. Reads the authoritative per-row version from <c>__localdb_row_version</c> joined
/// to each live table, pages it into <see cref="SnapshotBatch"/> messages through the gated data
/// lane, and brackets them with SnapshotBegin/SnapshotComplete carrying the max oplog seq as-of.
/// </summary>
internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, ReplicationOptions options, ILogger logger)
{
    /// <summary>Streams the full snapshot; returns the as-of oplog seq it covers, so the session can skip already-covered tail deltas.</summary>
    public async Task<long> SendAsync(Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
    {
        var asOfSeq = await store.GetMaxSeqAsync(ct);
        await send(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = asOfSeq } }, ct);

        var totalRows = 0L;
        foreach (var table in db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
        {
            var sql = BuildPageSql(table);
            // Keyset cursor (pk_json > last seen), not LIMIT/OFFSET: concurrent inserts/prunes
            // between pages cannot shift the window, so no ledger row is skipped or re-sent.
            var lastPk = "";
            while (true)
            {
                var rows = await db.QueryAsync(
                    sql,
                    // Column 4 is NULL for tombstones AND for live rows whose joined table row is
                    // missing; a proto optional string cannot be assigned null, so carry it as a
                    // nullable and only set row_json when present.
                    static r => (
                        PkJson: r.GetString(0),
                        Hlc: r.GetInt64(1),
                        NodeId: r.GetString(2),
                        IsTombstone: r.GetInt64(3) != 0,
                        RowJson: r.IsDBNull(4) ? null : r.GetString(4)),
                    new { t = table.Name, page = options.MaxBatchSize, lastPk }, ct);
                if (rows.Count == 0)
                    break;
                lastPk = rows[^1].PkJson;

                var batch = new SnapshotBatch { TableName = table.Name };
                foreach (var row in rows)
                {
                    if (!row.IsTombstone && row.RowJson is null)
                    {
                        // Ledger says live but the LEFT JOIN found no table row: ledger/table
                        // divergence (e.g. an unreplicated manual delete). Emitting {"col":null,...}
                        // would fabricate a phantom row on the peer, so skip it defensively.
                        logger.LogWarning(
                            "Snapshot skipping live-ledger row {Table} {PkJson}: no matching table row (ledger/table divergence).",
                            batch.TableName, row.PkJson);
                        continue;
                    }
                    var snapshotRow = new SnapshotRow
                    {
                        PkJson = row.PkJson,
                        Hlc = row.Hlc,
                        NodeId = row.NodeId,
                        IsTombstone = row.IsTombstone,
                    };
                    if (row.RowJson is not null)
                        snapshotRow.RowJson = row.RowJson;
                    batch.Rows.Add(snapshotRow);
                }
                if (batch.Rows.Count > 0)
                    await send(new SyncMessage { SnapshotBatch = batch }, ct);
                totalRows += batch.Rows.Count;

                if (rows.Count < options.MaxBatchSize)
                    break;
            }
        }

        await send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = asOfSeq } }, ct);
        // The sender owed this snapshot (its own needs_snapshot); clearing it here — after the whole
        // snapshot is on the wire — stops it re-streaming on the next reconnect. Narrow window: the
        // messages are enqueued on the data lane, not yet transport-delivered, so a crash/drop after
        // this clear could lose the snapshot with the flag already down. The reconnect handshake's
        // gap check (peer watermark below the pruned horizon) re-detects that in every case where the
        // oplog is non-empty; the sole undetectable corner is an EMPTY oplog (nothing to gap-check),
        // where the peer stays behind until new local writes recreate a horizon. Accepted: closing it
        // would need transport-level delivery acks for snapshot frames.
        await store.SetNeedsSnapshotAsync(false, ct);
        logger.LogInformation("Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows).", asOfSeq, totalRows);
        return asOfSeq;
    }

    // Pages one table's rows by joining the row-version ledger to the live table on the PK columns
    // extracted from pk_json. Rows present in the live table but absent from __localdb_row_version
    // (data written BEFORE the table was registered for replication) are intentionally NOT snapshotted:
    // they were never captured, consistent with CDC semantics — a full re-registration is required to
    // baseline them.
    private static string BuildPageSql(ReplicatedTable table)
    {
        var name = Ident(table.Name);
        var join = string.Join(" AND ", table.PkColumns.Select(p => $"t.{Ident(p)} = json_extract(rv.pk_json, {JsonPath(p)})"));
        var jsonObject = "json_object(" +
            string.Join(", ", table.Columns.Select(c => $"{StringLiteral(c.Name)}, t.{Ident(c.Name)}")) + ")";
        // A live ledger row whose join found no table row (first PK column NULL — PK columns are
        // never NULL on a real join hit) also yields NULL row json, so the caller can detect the
        // divergence instead of shipping a fabricated all-NULL row.
        var joinMiss = $"t.{Ident(table.PkColumns[0])} IS NULL";
        return
            "SELECT rv.pk_json, rv.hlc, rv.node_id, rv.is_tombstone, " +
            $"CASE WHEN rv.is_tombstone = 1 OR {joinMiss} THEN NULL ELSE {jsonObject} END " +
            $"FROM __localdb_row_version rv LEFT JOIN {name} t ON {join} " +
            "WHERE rv.table_name = @t AND rv.pk_json > @lastPk ORDER BY rv.pk_json LIMIT @page";
    }

    private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";

    private static string StringLiteral(string value) => "'" + value.Replace("'", "''") + "'";

    // A single-quoted JSON path with a quoted key member so a column name with reserved characters
    // still resolves: $."col".
    private static string JsonPath(string column) => "'$.\"" + column.Replace("\"", "\\\"") + "\"'";
}

/// <summary>
/// The inbound-snapshot consumer wired into <see cref="SyncSession"/>. Each batch merges through the
/// shared <see cref="LwwApplier"/> (seq 0 — snapshot rows carry no oplog seq — so the applier's
/// MAX-guarded watermark update is a no-op, and per-row LWW protects any newer local write). On
/// complete, the remote watermark is set to the snapshot's as-of seq so deltas resume above it.
/// </summary>
internal sealed class SnapshotApplier : ISnapshotApplier
{
    private readonly LwwApplier _applier;
    private readonly OplogStore _store;
    private readonly ILogger _logger;
    private long _asOfSeq;
    private long _rows;
    private long _maxHlc;

    // db is part of the collaborator's canonical shape (mirrors the streamer) even though the merge
    // reaches the database through the applier and store; kept for construction symmetry in the factory.
    public SnapshotApplier(SqliteLocalDb db, LwwApplier applier, OplogStore store, ILogger logger)
    {
        _ = db;
        _applier = applier;
        _store = store;
        _logger = logger;
    }

    public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct)
    {
        _asOfSeq = begin.AsOfSeq;
        _rows = 0;
        _maxHlc = 0;
        _logger.LogInformation("Snapshot receive begun (as_of_seq {AsOfSeq}).", _asOfSeq);
        return Task.CompletedTask;
    }

    public async Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct)
    {
        if (batch.Rows.Count == 0)
            return;

        var entries = new List<OplogEntryRecord>(batch.Rows.Count);
        foreach (var row in batch.Rows)
        {
            if (row.Hlc > _maxHlc) _maxHlc = row.Hlc;
            entries.Add(new OplogEntryRecord(
                0, batch.TableName, row.PkJson, row.HasRowJson ? row.RowJson : null, row.Hlc, row.NodeId, row.IsTombstone));
        }
        await _applier.ApplyBatchAsync(entries, ct);
        _rows += batch.Rows.Count;
    }

    public async Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct)
    {
        // SetLastAppliedRemoteSeqAsync is MAX-guarded, so a delta applied mid-snapshot with a seq
        // above the snapshot's as-of (or an HLC above what the snapshot carried) is never regressed.
        await _store.SetLastAppliedRemoteSeqAsync(complete.AsOfSeq, _maxHlc, ct);
        _logger.LogInformation("Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows).", complete.AsOfSeq, _rows);
    }
}
