using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;

/// <summary>
///     One authored raw tag's resolution target: the upstream OPC UA node id string the driver dials
///     (authored as the <c>nodeId</c> key of the tag's <c>TagConfig</c> blob) plus the platform
///     <see cref="RawTagEntry.WriteIdempotent"/> flag threaded onto the table (not read from the blob).
/// </summary>
/// <param name="NodeId">The upstream OPC UA node id string (a stable <c>nsu=…</c> or plain <c>ns=N;…</c> reference).</param>
/// <param name="WriteIdempotent">Whether a write to this tag is idempotent (from the tag's <see cref="RawTagEntry"/>).</param>
public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent);

/// <summary>
///     Bidirectional namespace map for the OPC UA Client (gateway) driver, built at connect
///     time from <c>session.NamespaceUris</c> per <c>docs/v2/driver-specs.md</c> §8
///     "Namespace Remapping".
/// </summary>
/// <remarks>
///     <para>
///         The session-relative namespace index embedded in an <c>ns=N;…</c> NodeId string is
///         <b>not</b> stable: the OPC UA spec permits a server to reorder its namespace table
///         across a restart. A driver that stores raw <c>ns=N</c> references and re-parses
///         them verbatim for reads/writes will, after such a reorder, silently address the
///         wrong namespace.
///     </para>
///     <para>
///         This map captures the upstream namespace table as it was at connect time and lets
///         the driver persist NodeIds in a <b>server-stable</b> form — the namespace
///         <i>URI</i> plus the identifier — via <see cref="ToStableReference"/>. At
///         read/write time the static <see cref="TryResolve(ISession?, string, out NodeId)"/>
///         re-binds that stable form against the <i>current</i> session's namespace table, so a
///         table reorder is transparently corrected instead of silently misaddressing nodes.
///     </para>
///     <para>
///         Stable references use the form <c>nsu=&lt;uri&gt;;&lt;idType&gt;=&lt;identifier&gt;</c>,
///         which is the standard OPC UA namespace-URI NodeId encoding the SDK already
///         understands. References that are already in plain <c>ns=N;…</c> form (e.g. a
///         hand-entered config tag) still resolve — the static
///         <see cref="TryResolve(ISession?, string, out NodeId)"/> falls back to a direct parse
///         against the current session.
///     </para>
///     <para>
///         <b>v3 RawPath resolution.</b> The map additionally carries the driver's authored
///         <c>RawPath → upstream node id</c> table, built from the deployed <see cref="RawTagEntry"/>
///         list at connect time. Under v3 the read/write/subscribe/history reference handed to the
///         driver is the tag's <b>RawPath</b> identity, not the upstream node id; the instance
///         <see cref="TryResolve(string, out string)"/> maps that RawPath to the upstream node id
///         string (authored as the <c>nodeId</c> key of each tag's <c>TagConfig</c> blob), which the
///         driver then re-binds against the live session via the static
///         <see cref="TryResolve(ISession?, string, out NodeId)"/>.
///     </para>
/// </remarks>
public sealed class NamespaceMap
{
    private static readonly IReadOnlyDictionary<string, RawTagBinding> EmptyRawTable =
        new Dictionary<string, RawTagBinding>(0, StringComparer.Ordinal);

    // index -> URI and URI -> index, as the upstream server published them at connect time.
    private readonly string[] _uris;
    private readonly Dictionary<string, ushort> _uriToIndex;

    // v3 authored resolution table: RawPath (tag identity) -> upstream node id + WriteIdempotent.
    // Case-sensitive ordinal — a RawPath is an exact cluster-scoped slash path.
    private readonly IReadOnlyDictionary<string, RawTagBinding> _rawPathToNode;

    private NamespaceMap(string[] uris, IReadOnlyDictionary<string, RawTagBinding> rawPathToNode)
    {
        _uris = uris;
        _uriToIndex = new Dictionary<string, ushort>(uris.Length, StringComparer.Ordinal);
        for (var i = 0; i < uris.Length; i++)
            _uriToIndex[uris[i]] = (ushort)i;
        _rawPathToNode = rawPathToNode;
    }

    /// <summary>
    ///     Snapshot the namespace table from a live session and (v3) ingest the driver's authored
    ///     <paramref name="rawTags"/> into the <c>RawPath → upstream node id</c> resolution table.
    /// </summary>
    /// <param name="session">The OPC UA session to snapshot.</param>
    /// <param name="rawTags">The deployed raw tags to build the RawPath resolution table from. Null/empty
    ///     (e.g. the AdminUI address-picker browse path, which never resolves a RawPath) yields an empty table.</param>
    /// <returns>A <see cref="NamespaceMap"/> capturing the session's namespace table + RawPath table.</returns>
    public static NamespaceMap FromSession(ISession session, IReadOnlyList<RawTagEntry>? rawTags = null)
    {
        ArgumentNullException.ThrowIfNull(session);
        return FromTable(session.NamespaceUris, rawTags);
    }

    /// <summary>
    ///     Snapshot a <see cref="NamespaceTable"/> directly and (v3) ingest the driver's authored
    ///     <paramref name="rawTags"/> into the <c>RawPath → upstream node id</c> resolution table.
    ///     Separated from <see cref="FromSession"/> so the encoding + RawPath resolution can be
    ///     exercised without standing up a live <see cref="ISession"/>.
    /// </summary>
    /// <param name="namespaceUris">The namespace table to snapshot.</param>
    /// <param name="rawTags">The deployed raw tags to build the RawPath resolution table from; null/empty yields an empty table.</param>
    /// <returns>A <see cref="NamespaceMap"/> capturing the given namespace table + RawPath table.</returns>
    public static NamespaceMap FromTable(NamespaceTable namespaceUris, IReadOnlyList<RawTagEntry>? rawTags = null)
    {
        ArgumentNullException.ThrowIfNull(namespaceUris);
        return new NamespaceMap(namespaceUris.ToArray(), BuildRawPathTable(rawTags));
    }

    /// <summary>
    ///     Build the <c>RawPath → upstream node id</c> table from the authored raw tags. Each entry's
    ///     <c>TagConfig</c> blob is parsed for its <c>nodeId</c> string key (the upstream OPC UA node the
    ///     driver dials); an entry with a blank RawPath or a TagConfig missing a usable <c>nodeId</c> is
    ///     skipped (the driver surfaces such a reference as <c>BadNodeIdInvalid</c>). The entry's
    ///     <see cref="RawTagEntry.WriteIdempotent"/> is threaded onto the table, not read from the blob.
    /// </summary>
    private static IReadOnlyDictionary<string, RawTagBinding> BuildRawPathTable(IReadOnlyList<RawTagEntry>? rawTags)
    {
        if (rawTags is null || rawTags.Count == 0) return EmptyRawTable;
        var table = new Dictionary<string, RawTagBinding>(rawTags.Count, StringComparer.Ordinal);
        foreach (var entry in rawTags)
        {
            if (string.IsNullOrWhiteSpace(entry.RawPath)) continue;
            if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
            table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
        }
        return table;
    }

    /// <summary>
    ///     Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
    ///     (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
    ///     is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
    /// </summary>
    private static bool TryReadNodeId(string tagConfig, out string nodeId)
    {
        nodeId = string.Empty;
        if (string.IsNullOrWhiteSpace(tagConfig)) return false;
        try
        {
            using var doc = JsonDocument.Parse(tagConfig);
            var root = doc.RootElement;
            if (root.ValueKind != JsonValueKind.Object) return false;
            if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
                && n.ValueKind == JsonValueKind.String)
            {
                var s = n.GetString();
                if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
            }
        }
        catch (JsonException) { /* malformed blob — treat as unresolvable */ }
        return false;
    }

    /// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
    public int Count => _uris.Length;

    /// <summary>The namespace URI at the given index, or null if out of range.</summary>
    /// <param name="index">The zero-based index in the namespace table.</param>
    /// <returns>The namespace URI at <paramref name="index"/>, or null if out of range.</returns>
    public string? UriForIndex(int index) =>
        index >= 0 && index < _uris.Length ? _uris[index] : null;

    /// <summary>The index for a namespace URI, or null if the URI is not in the table.</summary>
    /// <param name="uri">The namespace URI to look up.</param>
    /// <returns>The index for <paramref name="uri"/>, or null if the URI is not in the table.</returns>
    public ushort? IndexForUri(string uri) =>
        _uriToIndex.TryGetValue(uri, out var idx) ? idx : null;

    /// <summary>Number of authored raw tags in the RawPath resolution table.</summary>
    public int RawTagCount => _rawPathToNode.Count;

    /// <summary>
    ///     (v3) Resolve a tag's <b>RawPath</b> identity to its upstream OPC UA node id string via the
    ///     authored resolution table. The returned node id is still a <i>reference string</i>
    ///     (<c>nsu=…</c> or <c>ns=N;…</c>) — the caller re-binds it against the live session with the
    ///     static <see cref="TryResolve(ISession?, string, out NodeId)"/>. Returns <see langword="false"/>
    ///     for a blank RawPath or an unknown/un-authored RawPath.
    /// </summary>
    /// <param name="rawPath">The tag's RawPath (the wire reference the driver is handed under v3).</param>
    /// <param name="nodeId">On success, the upstream node id string; otherwise <see cref="string.Empty"/>.</param>
    /// <returns><see langword="true"/> when <paramref name="rawPath"/> maps to an authored upstream node id.</returns>
    public bool TryResolve(string rawPath, out string nodeId)
    {
        nodeId = string.Empty;
        if (string.IsNullOrWhiteSpace(rawPath)) return false;
        if (!_rawPathToNode.TryGetValue(rawPath, out var binding)) return false;
        nodeId = binding.NodeId;
        return true;
    }

    /// <summary>
    ///     (v3) Look up the full <see cref="RawTagBinding"/> (upstream node id + <c>WriteIdempotent</c>)
    ///     for a tag's RawPath. Companion to <see cref="TryResolve(string, out string)"/> for callers
    ///     that need the write-idempotency flag threaded onto the table.
    /// </summary>
    /// <param name="rawPath">The tag's RawPath.</param>
    /// <param name="binding">On success, the resolution binding for the tag.</param>
    /// <returns><see langword="true"/> when <paramref name="rawPath"/> is an authored raw tag.</returns>
    public bool TryGetBinding(string rawPath, out RawTagBinding binding) =>
        _rawPathToNode.TryGetValue(rawPath, out binding);

    /// <summary>
    ///     Render a NodeId resolved against this map's session as a <b>server-stable</b>
    ///     reference string — namespace URI plus identifier, in the SDK's <c>nsu=…</c>
    ///     encoding. The OPC UA core namespace (index 0) keeps the compact <c>i=…</c>/<c>ns=0</c>
    ///     form since URI 0 never moves. Used to persist a discovered NodeId into the local
    ///     address space so it survives a remote namespace-table reorder.
    /// </summary>
    /// <param name="nodeId">The NodeId to render as a stable reference.</param>
    /// <returns>The server-stable reference string for <paramref name="nodeId"/>.</returns>
    public string ToStableReference(NodeId nodeId)
    {
        ArgumentNullException.ThrowIfNull(nodeId);
        // Namespace 0 is fixed by the spec — the compact form is already stable.
        if (nodeId.NamespaceIndex == 0)
            return nodeId.ToString() ?? string.Empty;

        var uri = UriForIndex(nodeId.NamespaceIndex);
        if (uri is null)
            // Namespace index not in the captured table — fall back to the raw form rather
            // than throwing; the read/write path will surface BadNodeIdInvalid if it truly
            // can't resolve.
            return nodeId.ToString() ?? string.Empty;

        // nsu=<uri>;<idType>=<identifier> is the standard namespace-URI NodeId encoding.
        return $"nsu={uri};{IdentifierPart(nodeId)}";
    }

    /// <summary>
    ///     Resolve a reference string — either a stable <c>nsu=…</c> reference produced by
    ///     <see cref="ToStableReference"/> or a plain <c>ns=N;…</c> NodeId — against the
    ///     <paramref name="currentSession"/>'s namespace table. A <c>nsu=…</c> reference is
    ///     re-bound through the current session's URI table so a remote reorder since connect
    ///     time is corrected. Returns false for empty/malformed input, an unknown URI, or a
    ///     null session (defensive — callers on the reconnect path may pass a session that has
    ///     been torn down concurrently).
    /// </summary>
    /// <param name="currentSession">The live OPC UA session to resolve against. Passing null
    ///     returns false rather than throwing, matching the TryXxx contract.</param>
    /// <param name="reference">The reference string to resolve (either <c>nsu=…</c> or <c>ns=N;…</c> format).</param>
    /// <param name="nodeId">On success, the resolved NodeId; on failure, <c>NodeId.Null</c>.
    ///     Use <see cref="NodeId.IsNull(NodeId)"/> — not <c>== null</c> — to test this value.</param>
    /// <returns><c>true</c> if <paramref name="reference"/> resolved to a non-null NodeId; otherwise <c>false</c>.</returns>
    public static bool TryResolve(ISession? currentSession, string reference, out NodeId nodeId)
    {
        nodeId = NodeId.Null;
        if (currentSession is null || string.IsNullOrWhiteSpace(reference)) return false;
        try
        {
            // NodeId.Parse with a NamespaceUriString form (nsu=…) re-maps the URI against
            // the supplied context's NamespaceUris — i.e. the *current* session table — so a
            // reorder since the reference was captured resolves correctly. Plain ns=N forms
            // resolve directly.
            nodeId = NodeId.Parse(currentSession.MessageContext, reference);
            return !NodeId.IsNull(nodeId);
        }
        catch
        {
            return false;
        }
    }

    /// <summary>Render just the identifier portion (<c>s=…</c>, <c>i=…</c>, <c>g=…</c>, <c>b=…</c>) of a NodeId.</summary>
    private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch
    {
        IdType.Numeric => $"i={nodeId.Identifier}",
        IdType.String => $"s={nodeId.Identifier}",
        IdType.Guid => $"g={nodeId.Identifier}",
        IdType.Opaque => $"b={Convert.ToBase64String((byte[])nodeId.Identifier)}",
        _ => $"s={nodeId.Identifier}",
    };
}
