using System.Collections.Concurrent;

namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;

/// <summary>
///     The driver's live Sparkplug birth state: one <see cref="AliasTable"/> per
///     <see cref="SparkplugScope"/>, with the spec's node→device invalidation rules applied on birth
///     and on death. Design doc §3.6 invariants #2 and #4.
/// </summary>
/// <remarks>
///     <para>
///         <b>Scoping.</b> A scope is the full <c>(group, edgeNode, device?)</c> triple — never the
///         device id alone and never <c>node/device</c> without the group, or one plant's
///         <c>Filler1</c> would answer for another's. A node-scoped birth (NBIRTH) owns the metrics the
///         edge node publishes itself; each DBIRTH owns its device's. They are separate scopes because
///         Sparkplug gives them separate alias spaces: alias 5 on <c>EdgeA</c> and alias 5 on
///         <c>EdgeA/Filler1</c> are unrelated metrics.
///     </para>
///     <para>
///         <b>An NBIRTH invalidates every device under that node.</b> Per the Sparkplug spec an NBIRTH
///         means the edge node (re)started and must re-publish a DBIRTH for each of its devices, so
///         every previously published DBIRTH is void. Keeping a device's alias table across its node's
///         rebirth is the same stale-alias mis-route as
///         <see cref="AliasTable.RebuildFromBirth"/> guards against, one scope up — and it is easier to
///         miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned
///         (<see cref="NodeBirthOutcome.InvalidatedDevices"/>) rather than merely dropped, so the
///         consumer can fan STALE out over their metrics without a lookup against a table that has
///         already been evicted.
///     </para>
///     <para>
///         <b>A death evicts, it does not blank.</b> After an NDEATH/DDEATH the scope is <i>absent</i>,
///         which is what makes a DATA message arriving before the next birth a data-before-birth event
///         (⇒ request a rebirth, §3.6 invariant #3) rather than a silent unknown-alias drop against a
///         table that still looks alive.
///     </para>
///     <para>
///         <b>Thread-safety.</b> A <see cref="ConcurrentDictionary{TKey,TValue}"/> of scopes, each
///         holding an <see cref="AliasTable"/> whose own state is an atomically swapped immutable
///         snapshot. A rebirth <i>rebuilds the existing table in place</i> rather than replacing the
///         table object, so a consumer holding a reference to a scope's table always observes the newest
///         birth instead of quietly reading a detached older one. No lock is taken on any path — this is
///         read (and written) on MQTTnet's shared dispatcher thread.
///     </para>
///     <para>
///         <b>Not a value store.</b> See the remarks on <see cref="AliasTable"/>: running values belong
///         to <see cref="LastValueCache"/>, keyed by RawPath.
///     </para>
/// </remarks>
public sealed class BirthCache
{
    private readonly ConcurrentDictionary<SparkplugScope, AliasTable> _scopes = new();

    /// <summary>How many scopes currently hold a live birth.</summary>
    public int Count => _scopes.Count;

    /// <summary>A snapshot of the scopes currently holding a live birth.</summary>
    public IReadOnlyList<SparkplugScope> Scopes => [.. _scopes.Keys];

    /// <summary>
    ///     Applies an NBIRTH: rebuilds the edge node's own catalog and <b>invalidates every device</b>
    ///     under it, per the Sparkplug spec.
    /// </summary>
    /// <param name="groupId">The Sparkplug group id.</param>
    /// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
    /// <param name="birthMetrics">The NBIRTH payload's metrics.</param>
    /// <returns>The node's apply result plus the devices this birth invalidated, with their metrics.</returns>
    public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable<SparkplugMetric> birthMetrics)
    {
        ArgumentException.ThrowIfNullOrEmpty(groupId);
        ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
        ArgumentNullException.ThrowIfNull(birthMetrics);

        // Devices first: a device table must never outlive the node birth that voided it, whatever the
        // node's own rebuild does.
        var invalidated = Evict(scope => scope.DeviceId is not null && scope.IsUnder(groupId, edgeNodeId));

        var result = TableFor(new SparkplugScope(groupId, edgeNodeId, null)).RebuildFromBirth(birthMetrics);
        return new NodeBirthOutcome(result, invalidated);
    }

    /// <summary>Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale.</summary>
    /// <param name="groupId">The Sparkplug group id.</param>
    /// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
    /// <param name="deviceId">The Sparkplug device id.</param>
    /// <param name="birthMetrics">The DBIRTH payload's metrics.</param>
    /// <returns>What the birth installed, and what it refused.</returns>
    public BirthApplyResult ApplyDeviceBirth(
        string groupId,
        string edgeNodeId,
        string deviceId,
        IEnumerable<SparkplugMetric> birthMetrics)
    {
        ArgumentException.ThrowIfNullOrEmpty(groupId);
        ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
        ArgumentException.ThrowIfNullOrEmpty(deviceId);
        ArgumentNullException.ThrowIfNull(birthMetrics);

        return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics);
    }

    /// <summary>
    ///     The scope's catalog, or <see langword="null"/> when no live birth has been seen for it —
    ///     which is the consumer's data-before-birth signal.
    /// </summary>
    /// <param name="scope">The scope to look up.</param>
    /// <returns>The catalog, or <see langword="null"/>.</returns>
    public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null;

    /// <summary>
    ///     Applies an NDEATH: forgets the edge node and every device under it, returning what was
    ///     forgotten so the consumer can emit STALE for those metrics.
    /// </summary>
    /// <param name="groupId">The Sparkplug group id.</param>
    /// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
    /// <returns>The evicted scopes and their metrics; empty when nothing was live.</returns>
    public IReadOnlyList<SparkplugScopeMetrics> ForgetNode(string groupId, string edgeNodeId)
    {
        ArgumentException.ThrowIfNullOrEmpty(groupId);
        ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);

        return Evict(scope => scope.IsUnder(groupId, edgeNodeId));
    }

    /// <summary>
    ///     Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out.
    ///     The node's own catalog is untouched.
    /// </summary>
    /// <param name="groupId">The Sparkplug group id.</param>
    /// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
    /// <param name="deviceId">The Sparkplug device id.</param>
    /// <param name="removed">The evicted scope and its metrics when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when the device had a live birth to forget.</returns>
    public bool TryForgetDevice(
        string groupId,
        string edgeNodeId,
        string deviceId,
        out SparkplugScopeMetrics removed)
    {
        ArgumentException.ThrowIfNullOrEmpty(groupId);
        ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
        ArgumentException.ThrowIfNullOrEmpty(deviceId);

        var scope = new SparkplugScope(groupId, edgeNodeId, deviceId);
        if (_scopes.TryRemove(scope, out var table))
        {
            removed = new SparkplugScopeMetrics(scope, table.Metrics);
            return true;
        }

        removed = default;
        return false;
    }

    /// <summary>Forgets every scope — the disconnect/reinitialise reset.</summary>
    public void Clear() => _scopes.Clear();

    /// <summary>
    ///     The scope's catalog, created empty on first use. Rebuilds happen <b>in place</b> on the
    ///     returned instance, so a consumer holding it observes the newest birth rather than a detached
    ///     older one.
    /// </summary>
    /// <param name="scope">The scope.</param>
    /// <returns>The scope's catalog.</returns>
    private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable());

    /// <summary>Removes every scope matching <paramref name="predicate"/>, capturing their metrics first.</summary>
    /// <param name="predicate">Which scopes to evict.</param>
    /// <returns>The evicted scopes and the metrics they held.</returns>
    private List<SparkplugScopeMetrics> Evict(Func<SparkplugScope, bool> predicate)
    {
        List<SparkplugScopeMetrics>? evicted = null;

        // ConcurrentDictionary enumeration is weakly consistent, which is exactly right here: this runs
        // on the dispatcher thread and only needs to see the scopes that existed when the birth/death
        // arrived.
        foreach (var scope in _scopes.Keys)
        {
            if (!predicate(scope) || !_scopes.TryRemove(scope, out var table))
            {
                continue;
            }

            (evicted ??= []).Add(new SparkplugScopeMetrics(scope, table.Metrics));
        }

        return evicted ?? [];
    }
}

/// <summary>
///     A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every
///     birth, death and alias resolution is keyed by.
/// </summary>
/// <param name="GroupId">The Sparkplug group id.</param>
/// <param name="EdgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="DeviceId">The Sparkplug device id, or <see langword="null"/> for the node's own scope.</param>
public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId)
{
    /// <summary>Whether this is a device scope (a DBIRTH's) rather than an edge node's own.</summary>
    public bool IsDevice => DeviceId is not null;

    /// <summary>The owning edge node's scope — this instance itself when it is already node-scoped.</summary>
    public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null);

    /// <summary>Whether this scope belongs to the given edge node (the node's own scope included).</summary>
    /// <param name="groupId">The Sparkplug group id.</param>
    /// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
    /// <returns><see langword="true"/> when both segments match ordinally.</returns>
    public bool IsUnder(string groupId, string edgeNodeId) =>
        string.Equals(GroupId, groupId, StringComparison.Ordinal)
        && string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal);

    /// <inheritdoc/>
    public override string ToString() =>
        DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}";
}

/// <summary>
///     A scope's metrics, captured at the moment it was evicted — the STALE fan-out's input, taken
///     before the eviction so it cannot race a re-birth of the same scope.
/// </summary>
/// <param name="Scope">The scope that was evicted.</param>
/// <param name="Metrics">The metrics its last birth declared.</param>
public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList<SparkplugMetricBinding> Metrics);

/// <summary>The result of an NBIRTH: the node's own apply, plus the devices it invalidated.</summary>
/// <param name="Result">What the node's catalog rebuild installed and refused.</param>
/// <param name="InvalidatedDevices">
///     The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input
///     until each device re-DBIRTHs.
/// </param>
public readonly record struct NodeBirthOutcome(
    BirthApplyResult Result,
    IReadOnlyList<SparkplugScopeMetrics> InvalidatedDevices);
