using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;

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

/// <summary>
///     One <c>(edgeNode, device)</c> scope's metric catalog, as declared by its most recent
///     NBIRTH/DBIRTH: the alias→metric cache <b>and</b> the stable name→metric index, rebuilt wholesale
///     on every birth. Design doc §3.6 invariants #1 and #2.
/// </summary>
/// <remarks>
///     <para>
///         <b>The alias is a per-birth cache; the NAME is the binding key.</b> Sparkplug's alias
///         mechanism exists to shrink DATA payloads — a BIRTH declares <c>name ↔ alias</c> and every
///         subsequent DATA metric carries only the alias — and the alias is scoped to <i>that one
///         birth</i>. After a rebirth an edge node is entirely free to point alias 5 at a different
///         metric. So an authored tag binds by <see cref="SparkplugMetricBinding.Name"/>; the alias is
///         only ever the lookup that turns an incoming DATA metric back into a name. Binding a tag to
///         an alias instead means a rebirth silently starts routing Pressure into a Temperature tag —
///         <b>good quality, plausible values, completely wrong</b>, and invisible unless something
///         specifically reuses an alias across a rebirth.
///     </para>
///     <para>
///         <b><see cref="RebuildFromBirth"/> REPLACES; it never merges or upserts.</b> The whole
///         snapshot — both indexes — is built fresh and published in one assignment. An alias the new
///         birth did not declare is gone, including when the new birth declares no metrics at all. This
///         is the single behaviour the type exists to guarantee: a merge (or an "optimisation" that
///         skips an empty birth) reintroduces exactly the stale-alias mis-route above.
///     </para>
///     <para>
///         <b>Both indexes are replaced together, from the same birth.</b> They are deliberately held
///         in one type rather than split across an "alias table" and a separate "birth cache": they are
///         two views of one birth, and any seam between them is somewhere the two can be replaced
///         independently and skew — an alias resolving through the new birth into a name catalog still
///         holding the old one.
///     </para>
///     <para>
///         <b>Thread-safety: an immutable snapshot swapped atomically</b>, the same discipline
///         <c>MqttSubscriptionManager.AuthoredTable</c> uses and for the same reason — this is read on
///         MQTTnet's shared dispatcher thread as messages arrive, and a reader must never see a
///         half-rebuilt map. No lock is taken on any path.
///     </para>
///     <para>
///         <b>Last values do not live here.</b> The running last-observed value per tag is
///         <see cref="LastValueCache"/>'s, keyed by RawPath — the identity every publish in this driver
///         is made under. A second copy keyed by metric name would be a duplicate source of truth with
///         a different key, and it would grow with every metric the plant births whether or not any tag
///         was ever authored for it. A birth's own values flow straight through the ingest path to that
///         cache; nothing is retained here.
///     </para>
/// </remarks>
public sealed class AliasTable
{
    private volatile Snapshot _snapshot = Snapshot.Empty;

    /// <summary>How many distinct metrics the most recent birth declared.</summary>
    public int Count => _snapshot.Metrics.Length;

    /// <summary>
    ///     Every metric the most recent birth declared. The consumer's STALE fan-out on a death, and
    ///     the discovery path's "which metrics does this scope have" question, both read this.
    /// </summary>
    public IReadOnlyList<SparkplugMetricBinding> Metrics => _snapshot.Metrics;

    /// <summary>
    ///     Replaces this scope's whole catalog with <paramref name="birthMetrics"/> — the metrics of one
    ///     NBIRTH/DBIRTH payload, exactly as <see cref="SparkplugCodec"/> projected them.
    /// </summary>
    /// <param name="birthMetrics">The birth payload's metrics.</param>
    /// <returns>
    ///     What was installed, and what was refused — the caller's only window onto a malformed birth,
    ///     since this type takes no logger.
    /// </returns>
    /// <remarks>
    ///     <para>
    ///         <b>Only an unusable NAME is a rejection.</b> A metric with a blank/absent name cannot be
    ///         bound to an authored tag by any route, and storing it alias-only would create a binding
    ///         that can never resolve (or, worse, one that matches a tag whose authored metric name
    ///         happens to be blank), so it is dropped and counted.
    ///     </para>
    ///     <para>
    ///         <b>A missing datatype is NOT a rejection</b> — it becomes
    ///         <see cref="TahuDataType.Unknown"/>. Dropping such a metric would turn every DATA message
    ///         for its alias into an unknown-alias event, which the ingest state machine answers with a
    ///         rebirth request; the edge node would answer with the same malformed birth, and the pair
    ///         would loop. Kept as Unknown, the typed layer refuses the value once
    ///         (<c>ToDriverDataType()</c> returns null for it) and nothing storms.
    ///     </para>
    ///     <para>
    ///         <b>An alias claimed by two metrics in one birth resolves to neither.</b> Any choice
    ///         between them is a coin flip between two different signals, which is the mis-route this
    ///         type exists to prevent; the alias is dropped (the consumer sees an unknown alias and can
    ///         request a rebirth) while both metrics stay bindable by name. A duplicate <i>name</i>, by
    ///         contrast, is last-wins: the name is the binding key, so evicting it would take the
    ///         authored tag dark entirely.
    ///     </para>
    /// </remarks>
    public BirthApplyResult RebuildFromBirth(IEnumerable<SparkplugMetric> birthMetrics)
    {
        ArgumentNullException.ThrowIfNull(birthMetrics);

        var byName = new Dictionary<string, SparkplugMetricBinding>(StringComparer.Ordinal);
        var byAlias = new Dictionary<ulong, SparkplugMetricBinding>();
        HashSet<ulong>? collidedAliases = null;
        var rejectedUnnamed = 0;
        var duplicateNames = 0;
        var aliasCollisions = 0;

        foreach (var metric in birthMetrics)
        {
            if (string.IsNullOrWhiteSpace(metric.Name))
            {
                rejectedUnnamed++;
                continue;
            }

            var binding = new SparkplugMetricBinding(metric.Name, metric.Alias, metric.DataType ?? TahuDataType.Unknown);

            if (!byName.TryAdd(binding.Name, binding))
            {
                duplicateNames++;
                byName[binding.Name] = binding;
            }

            if (binding.Alias is { } alias && !byAlias.TryAdd(alias, binding))
            {
                aliasCollisions++;
                (collidedAliases ??= []).Add(alias);
            }
        }

        if (collidedAliases is not null)
        {
            foreach (var alias in collidedAliases)
            {
                byAlias.Remove(alias);
            }
        }

        // A duplicate name is last-wins in `byName`, so the alias index can still hold the losing
        // binding — drop those too rather than let an alias resolve to a name that resolves elsewhere.
        if (duplicateNames > 0)
        {
            foreach (var alias in byAlias.Where(kv => !ReferenceEquals(byName[kv.Value.Name], kv.Value))
                         .Select(kv => kv.Key).ToList())
            {
                byAlias.Remove(alias);
            }
        }

        // THE replace. One assignment of a fully-built snapshot — no merge, no upsert, and no
        // short-circuit for an empty birth (an empty birth legitimately clears the scope).
        _snapshot = new Snapshot(
            byAlias.ToFrozenDictionary(),
            byName.ToFrozenDictionary(StringComparer.Ordinal),
            [.. byName.Values]);

        return new BirthApplyResult(byName.Count, rejectedUnnamed, aliasCollisions, duplicateNames);
    }

    /// <summary>
    ///     Resolves a DATA metric's alias to the metric the <b>most recent</b> birth bound it to, or
    ///     <see langword="null"/> when this birth declared no such alias.
    /// </summary>
    /// <param name="alias">The alias carried by an inbound DATA metric.</param>
    /// <returns>The binding, or <see langword="null"/> — which the consumer treats as an unknown alias.</returns>
    public SparkplugMetricBinding? Resolve(ulong alias) =>
        _snapshot.ByAlias.TryGetValue(alias, out var binding) ? binding : null;

    /// <summary>The <see cref="Resolve(ulong)"/> try-shape.</summary>
    /// <param name="alias">The alias carried by an inbound DATA metric.</param>
    /// <param name="binding">The binding when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when the most recent birth declared this alias.</returns>
    public bool TryResolve(ulong alias, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
    {
        binding = Resolve(alias);
        return binding is not null;
    }

    /// <summary>
    ///     Resolves by the stable metric name — the binding key. Used for a DATA metric that carried its
    ///     name rather than an alias (aliases are optional in Sparkplug), and to answer "does this scope
    ///     publish the metric this tag was authored against".
    /// </summary>
    /// <param name="name">The stable metric name. Matched ordinally; Sparkplug names are case-sensitive.</param>
    /// <returns>The binding, or <see langword="null"/>.</returns>
    public SparkplugMetricBinding? ResolveByName(string name) =>
        !string.IsNullOrEmpty(name) && _snapshot.ByName.TryGetValue(name, out var binding) ? binding : null;

    /// <summary>The <see cref="ResolveByName"/> try-shape.</summary>
    /// <param name="name">The stable metric name.</param>
    /// <param name="binding">The binding when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when the most recent birth declared this metric.</returns>
    public bool TryResolveByName(string name, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
    {
        binding = ResolveByName(name);
        return binding is not null;
    }

    /// <summary>
    ///     One birth's catalog, immutable and published in a single assignment so the dispatcher thread
    ///     always reads one whole birth's worth of state.
    /// </summary>
    /// <param name="ByAlias">Alias → binding, for the birth that declared it. Collided aliases are absent.</param>
    /// <param name="ByName">Stable metric name → binding — the index authored tags bind through.</param>
    /// <param name="Metrics">Every declared metric, for the STALE fan-out and discovery.</param>
    private sealed record Snapshot(
        FrozenDictionary<ulong, SparkplugMetricBinding> ByAlias,
        FrozenDictionary<string, SparkplugMetricBinding> ByName,
        SparkplugMetricBinding[] Metrics)
    {
        public static readonly Snapshot Empty = new(
            FrozenDictionary<ulong, SparkplugMetricBinding>.Empty,
            FrozenDictionary<string, SparkplugMetricBinding>.Empty,
            []);
    }
}

/// <summary>One metric as its birth declared it: the stable name, the per-birth alias, the datatype.</summary>
/// <param name="Name">
///     The stable metric name — <b>the binding key</b>. An authored tag's <c>metricName</c> matches
///     against this, never against <paramref name="Alias"/>.
/// </param>
/// <param name="Alias">
///     The alias this birth assigned, or <see langword="null"/> when the birth declared none (legal —
///     such a publisher sends names in DATA too). <b>Valid only until the next birth for this scope.</b>
/// </param>
/// <param name="DataType">
///     The datatype the birth declared, or <see cref="TahuDataType.Unknown"/> when it declared none.
///     This is the <i>only</i> place a DATA metric's datatype can come from — DATA carries none.
/// </param>
public sealed record SparkplugMetricBinding(string Name, ulong? Alias, TahuDataType DataType)
{
    /// <summary>
    ///     Applies <see cref="SparkplugCodec.ReinterpretSigned"/> to a raw DATA value using
    ///     <b>this birth's</b> datatype.
    /// </summary>
    /// <param name="rawValue">The raw <see cref="SparkplugMetric.Value"/> from a DATA metric.</param>
    /// <returns>The signed value for a signed-integer datatype; <paramref name="rawValue"/> otherwise.</returns>
    /// <remarks>
    ///     Exposed here because this is the one object that holds the datatype at the moment a DATA
    ///     value is being resolved, and forgetting the call is silent: Sparkplug carries Int8/16/32/64
    ///     as two's complement in an <i>unsigned</i> proto field, so a metric whose value is -42
    ///     publishes as 4294967254 — plausible, Good quality, and invisible until someone reads a gauge.
    /// </remarks>
    public object? Reinterpret(object? rawValue) => SparkplugCodec.ReinterpretSigned(rawValue, DataType);
}

/// <summary>What one <see cref="AliasTable.RebuildFromBirth"/> installed, and what it refused.</summary>
/// <param name="Accepted">Distinct named metrics installed.</param>
/// <param name="RejectedUnnamed">Metrics dropped for carrying no usable name.</param>
/// <param name="AliasCollisions">Aliases claimed by more than one metric, and therefore left unresolvable.</param>
/// <param name="DuplicateNames">Metrics whose name a later metric in the same birth overwrote.</param>
public readonly record struct BirthApplyResult(
    int Accepted,
    int RejectedUnnamed,
    int AliasCollisions,
    int DuplicateNames)
{
    /// <summary>Whether the birth was malformed in any way worth logging.</summary>
    public bool HasAnomaly => RejectedUnnamed > 0 || AliasCollisions > 0 || DuplicateNames > 0;
}
