using System.Globalization;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.LocalDb.Internal;
using ZB.MOM.WW.LocalDb.Registration;

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

/// <summary>Outcome of applying one inbound batch. <see cref="AppliedThruSeq"/> is the batch's highest seq regardless of per-entry disposition, so the watermark advances even over discarded/dead-lettered entries.</summary>
internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discarded, int DeadLettered);

/// <summary>
/// Applies a peer's oplog batch under last-writer-wins in one transaction: the applying guard is
/// raised so capture triggers skip these writes, per-entry LWW resolves against the local
/// row-version (hlc, then node_id ordinal tie-break), poison rows are dead-lettered while the
/// batch continues, and the remote watermark advances in the SAME transaction as the data so a
/// crash-replay of the batch is a no-op (LWW's strict-greater compare loses on equal versions).
/// This never writes <c>__localdb_oplog</c>: the 2-node topology does not re-forward applied deltas.
/// </summary>
internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow = null)
{
    private readonly Func<DateTimeOffset> _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);

    public async Task<ApplyResult> ApplyBatchAsync(IReadOnlyList<OplogEntryRecord> entries, CancellationToken ct)
    {
        if (entries.Count == 0)
            return new ApplyResult(0, 0, 0, 0);

        // Validated before opening the transaction: an unknown table is a schema disagreement
        // between the peers (a protocol violation), not a per-row poison — reject the whole batch
        // so nothing is half-applied.
        foreach (var entry in entries)
            if (!db.ReplicatedTables.ContainsKey(entry.TableName))
                throw new LocalDbSchemaMismatchException(
                    $"Received an oplog entry for table '{entry.TableName}', which is not registered for replication on this node.");

        var utcNow = _utcNow().UtcDateTime.ToString(OplogStore.TombstoneUtcFormat, CultureInfo.InvariantCulture);
        var maxSeq = 0L;
        var maxHlc = 0L;
        foreach (var entry in entries)
        {
            if (entry.Seq > maxSeq) maxSeq = entry.Seq;
            if (entry.Hlc > maxHlc) maxHlc = entry.Hlc;
        }

        var sqlByTable = new Dictionary<string, (string Delete, string Upsert)>(StringComparer.Ordinal);
        var applied = 0;
        var discarded = 0;
        var deadLettered = 0;

        await using var tx = await db.BeginTransactionAsync(ct);
        await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 1 WHERE id = 1", null, ct);

        foreach (var entry in entries)
        {
            // Without row data the upsert would bind NULL to every column — on an INTEGER PRIMARY
            // KEY table that silently inserts a phantom auto-rowid row — so it is poison, not a win.
            if (!entry.IsTombstone && entry.RowJson is null)
            {
                await DeadLetterAsync(tx, entry, "non-tombstone entry with null row_json", utcNow, ct);
                deadLettered++;
                continue;
            }

            var local = await tx.QueryAsync(
                "SELECT hlc, node_id FROM __localdb_row_version WHERE table_name = @t AND pk_json = @pk",
                static r => (Hlc: r.GetInt64(0), NodeId: r.GetString(1)),
                new { t = entry.TableName, pk = entry.PkJson }, ct);

            var remoteWins = local.Count == 0
                || entry.Hlc > local[0].Hlc
                || (entry.Hlc == local[0].Hlc && string.CompareOrdinal(entry.NodeId, local[0].NodeId) > 0);

            if (!remoteWins)
            {
                discarded++;
                continue;
            }

            if (!sqlByTable.TryGetValue(entry.TableName, out var sql))
            {
                sql = BuildTableSql(db.ReplicatedTables[entry.TableName]);
                sqlByTable[entry.TableName] = sql;
            }

            try
            {
                if (entry.IsTombstone)
                    await tx.ExecuteAsync(sql.Delete, new { pk = entry.PkJson }, ct);
                else
                    await tx.ExecuteAsync(sql.Upsert, new { row = entry.RowJson }, ct);

                await tx.ExecuteAsync(
                    "INSERT OR REPLACE INTO __localdb_row_version " +
                    "(table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc) " +
                    "VALUES (@t, @pk, @hlc, @node, @tomb, @tut)",
                    new
                    {
                        t = entry.TableName,
                        pk = entry.PkJson,
                        hlc = entry.Hlc,
                        node = entry.NodeId,
                        tomb = entry.IsTombstone ? 1 : 0,
                        tut = entry.IsTombstone ? utcNow : null,
                    }, ct);
                applied++;
            }
            catch (SqliteException ex)
            {
                // Deliberately narrow: only statement-level SQLite errors (e.g. json_extract on
                // malformed row_json) are treated as per-entry poison — SQLite aborts just the
                // statement, not the transaction, so the batch continues after dead-lettering.
                // Any other exception propagates and rolls back the whole batch (fail-closed).
                await DeadLetterAsync(tx, entry, ex.Message, utcNow, ct);
                deadLettered++;
            }
        }

        await tx.ExecuteAsync(
            "UPDATE __localdb_peer_state SET " +
            "last_applied_remote_seq = MAX(last_applied_remote_seq, @maxSeq), " +
            "last_seen_hlc = MAX(last_seen_hlc, @maxHlc), last_sync_utc = @utc WHERE id = 1",
            new { maxSeq, maxHlc, utc = utcNow }, ct);

        await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 0 WHERE id = 1", null, ct);
        await tx.CommitAsync(ct);

        db.Clock.Observe(maxHlc);
        return new ApplyResult(maxSeq, applied, discarded, deadLettered);
    }

    private static Task DeadLetterAsync(
        ILocalDbTransaction tx, OplogEntryRecord entry, string error, string utcNow, CancellationToken ct) =>
        tx.ExecuteAsync(
            "INSERT INTO __localdb_dead_letter " +
            "(received_utc, table_name, pk_json, row_json, hlc, node_id, error) " +
            "VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)",
            new
            {
                utc = utcNow,
                t = entry.TableName,
                pk = entry.PkJson,
                row = entry.RowJson,
                hlc = entry.Hlc,
                node = entry.NodeId,
                err = error,
            }, ct);

    private static (string Delete, string Upsert) BuildTableSql(ReplicatedTable table)
    {
        var name = Ident(table.Name);

        var pkMatch = string.Join(" AND ", table.PkColumns.Select(p => $"{Ident(p)} = json_extract(@pk, {JsonPath(p)})"));
        var delete = $"DELETE FROM {name} WHERE {pkMatch}";

        var columns = string.Join(", ", table.Columns.Select(c => Ident(c.Name)));
        var values = string.Join(", ", table.Columns.Select(c => $"json_extract(@row, {JsonPath(c.Name)})"));
        var upsert = $"INSERT OR REPLACE INTO {name} ({columns}) VALUES ({values})";

        return (delete, upsert);
    }

    // Local copy of TriggerSqlGenerator's convention (that helper lives in the core assembly).
    private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";

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