using ZB.MOM.WW.Secrets.Abstractions;

namespace ZB.MOM.WW.Secrets.Replication;

/// <summary>
/// Transport-agnostic anti-entropy: compares a peer's manifest against the local store, pulls only
/// the rows the peer holds a strictly newer copy of, and applies them last-writer-wins.
/// </summary>
/// <remarks>
/// <para>
/// This is the half of replication that is pure logic, deliberately separated from <em>how</em> the
/// peer is reached. The SQL-Server package points it at a shared hub database; the Akka package
/// points it at a cluster peer over pub/sub. Both get identical convergence behaviour, and the
/// interesting edge cases (ties, tombstones, a peer that is behind) are testable offline without a
/// database or a cluster.
/// </para>
/// <para>
/// Reconciliation is a pull, never a push: it only ever writes rows the peer says are newer, so
/// running it against a peer that is behind is a no-op rather than a regression. It is also safe to
/// run concurrently with live replication — <see cref="ISecretStore.ApplyReplicatedAsync"/> re-checks
/// the same predicate under its own transaction, so a row that a broadcast delivered first is simply
/// ignored the second time.
/// </para>
/// </remarks>
/// <param name="local">The local store to bring up to date.</param>
/// <param name="cacheInvalidator">
/// Optional resolver-cache seam. When supplied, every applied name is evicted so a node picks up a
/// peer's write within the reconcile interval instead of serving a stale plaintext for the rest of
/// the cache TTL.
/// </param>
/// <param name="onRowFailed">
/// Optional callback invoked when a single replicated row could not be applied. The row is skipped
/// and the rest of the batch proceeds; without a callback the failure is silent, so transports
/// should supply one that logs.
/// </param>
public sealed class SecretReplicationReconciler(
    ISecretStore local,
    ISecretCacheInvalidator? cacheInvalidator = null,
    Action<SecretName, Exception>? onRowFailed = null)
{
    /// <summary>
    /// Returns the names for which <paramref name="remote"/> holds a strictly newer row than
    /// <paramref name="localManifest"/> — including names absent locally, and including tombstones
    /// (a delete propagates as a newer row, not as an absence).
    /// </summary>
    /// <param name="localManifest">The local store's manifest.</param>
    /// <param name="remote">The peer's manifest.</param>
    /// <returns>The names to pull from the peer, in manifest order.</returns>
    public static IReadOnlyList<SecretName> ComputePullSet(
        IReadOnlyList<SecretManifestEntry> localManifest,
        IReadOnlyList<SecretManifestEntry> remote)
    {
        ArgumentNullException.ThrowIfNull(localManifest);
        ArgumentNullException.ThrowIfNull(remote);

        Dictionary<string, SecretManifestEntry> localByName =
            localManifest.ToDictionary(entry => entry.Name.Value, StringComparer.Ordinal);

        var pull = new List<SecretName>();

        foreach (SecretManifestEntry remoteEntry in remote)
        {
            // Absent locally, or the peer's copy wins LWW. A name we hold and the peer does not is
            // deliberately NOT handled here: this side pulls, the peer's own reconcile pushes.
            if (!localByName.TryGetValue(remoteEntry.Name.Value, out SecretManifestEntry? localEntry) ||
                SecretLastWriterWins.IsNewer(remoteEntry, localEntry))
            {
                pull.Add(remoteEntry.Name);
            }
        }

        return pull;
    }

    /// <summary>
    /// Returns the names the local store holds a strictly newer row for than <paramref name="remote"/>
    /// — the mirror of <see cref="ComputePullSet"/>, used to push local writes a peer is missing.
    /// </summary>
    /// <remarks>
    /// Every transport here reconciles in <b>both</b> directions, and this is why: publishing a write
    /// is best-effort (see <c>ReplicatingSecretStore</c>), so without a push sweep a write made while
    /// a peer was unreachable would sit on one node forever — the peer never learns the name exists,
    /// so its own pull can never ask for it.
    /// </remarks>
    /// <param name="localManifest">The local store's manifest.</param>
    /// <param name="remote">The peer's manifest.</param>
    /// <returns>The names to push to the peer.</returns>
    public static IReadOnlyList<SecretName> ComputePushSet(
        IReadOnlyList<SecretManifestEntry> localManifest,
        IReadOnlyList<SecretManifestEntry> remote) =>
        // Exactly the pull computation with the two sides swapped: "what does the peer need from me"
        // is "what would I need, if I were the peer".
        ComputePullSet(remote, localManifest);

    /// <summary>
    /// Reconciles the local store against <paramref name="remoteManifest"/>, fetching and applying
    /// every row the peer holds a newer copy of.
    /// </summary>
    /// <param name="remoteManifest">The peer's manifest.</param>
    /// <param name="fetchAsync">
    /// Fetches the encrypted rows for the requested names from the peer. May return fewer rows than
    /// requested (a row deleted between manifest and fetch); extra rows are applied normally.
    /// </param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>The number of rows applied locally.</returns>
    public async Task<int> ReconcileAsync(
        IReadOnlyList<SecretManifestEntry> remoteManifest,
        Func<IReadOnlyList<SecretName>, CancellationToken, Task<IReadOnlyList<StoredSecret>>> fetchAsync,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(remoteManifest);
        ArgumentNullException.ThrowIfNull(fetchAsync);

        IReadOnlyList<SecretManifestEntry> localManifest =
            await local.GetManifestAsync(ct).ConfigureAwait(false);

        IReadOnlyList<SecretName> pull = ComputePullSet(localManifest, remoteManifest);

        if (pull.Count == 0)
        {
            return 0;
        }

        int applied = 0;

        // Chunked, not fetched in one shot. A cold-starting node's pull set is the peer's ENTIRE
        // inventory, and an unbounded fetch breaks on real backends — SQL Server caps a command at
        // 2100 parameters, so a hub with more than that many secrets would fail the same way every
        // interval and the node would never receive a single row. Chunking also bounds the size of
        // one replication message on the wire.
        foreach (IReadOnlyList<SecretName> batch in Chunk(pull, FetchBatchSize))
        {
            IReadOnlyList<StoredSecret> rows = await fetchAsync(batch, ct).ConfigureAwait(false);
            applied += await ApplyAsync(rows, ct).ConfigureAwait(false);
        }

        return applied;
    }

    /// <summary>
    /// Names fetched per round trip. Well under SQL Server's 2100-parameter command limit, and small
    /// enough that one replication message stays a sane size.
    /// </summary>
    internal const int FetchBatchSize = 500;

    private static IEnumerable<IReadOnlyList<SecretName>> Chunk(
        IReadOnlyList<SecretName> names, int size)
    {
        for (int offset = 0; offset < names.Count; offset += size)
        {
            yield return [.. names.Skip(offset).Take(size)];
        }
    }

    /// <summary>
    /// Reads the local rows for <paramref name="names"/>, skipping any that vanished between the
    /// manifest and the read. Used by transports to assemble a push batch.
    /// </summary>
    /// <param name="names">The names to read.</param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>The encrypted rows that still exist locally.</returns>
    public async Task<IReadOnlyList<StoredSecret>> ReadLocalAsync(
        IReadOnlyList<SecretName> names, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(names);

        var rows = new List<StoredSecret>(names.Count);

        foreach (SecretName name in names)
        {
            StoredSecret? row = await local.GetAsync(name, ct).ConfigureAwait(false);

            if (row is not null)
            {
                rows.Add(row);
            }
        }

        return rows;
    }

    /// <summary>
    /// Applies encrypted rows received from a peer, last-writer-wins, evicting the resolver cache for
    /// each. Used both by <see cref="ReconcileAsync"/> and by transports that receive live broadcasts.
    /// </summary>
    /// <param name="rows">The encrypted rows to apply.</param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>The number of rows passed to the store.</returns>
    public async Task<int> ApplyAsync(IReadOnlyList<StoredSecret> rows, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(rows);

        int applied = 0;

        foreach (StoredSecret row in rows)
        {
            try
            {
                await local.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
                applied++;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception ex)
            {
                // Isolated per row, because a batch is not a transaction and must not be all-or-
                // nothing. One row a peer cannot supply cleanly (a hostile or buggy peer, a
                // constraint violation) would otherwise abandon every row after it — and since the
                // pull set is recomputed identically each sweep, in the same manifest order, the
                // SAME row would poison the SAME batch forever and every name behind it would never
                // converge. Drop the row, keep the batch.
                onRowFailed?.Invoke(row.Name, ex);
                continue;
            }
            finally
            {
                // Evicted unconditionally: the store may have ignored the row as stale, but an
                // eviction only costs the next resolve a re-read, whereas skipping one when the row
                // DID land serves a stale plaintext until the TTL expires.
                cacheInvalidator?.Invalidate(row.Name);
            }
        }

        return applied;
    }
}
