using System.Text;

namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;

/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
public enum SqlBrowseNodeKind
{
    /// <summary>A schema — the browse root level.</summary>
    Schema,

    /// <summary>A table or view inside a schema.</summary>
    Table,

    /// <summary>A column of a table or view — the terminal (leaf) level.</summary>
    Column,
}

/// <summary>A decoded browse <c>NodeId</c>.</summary>
/// <param name="Kind">Which catalog level this reference addresses.</param>
/// <param name="Schema">The schema name. Always present.</param>
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
public readonly record struct SqlBrowseNodeRef(
    SqlBrowseNodeKind Kind,
    string Schema,
    string? Table,
    string? Column);

/// <summary>
///     Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
///     on expand / attribute fetch / column commit.
///     <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
///     <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
///     inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
///     both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
///     <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
///     + table <c>b</c>, and a table named <c>x|y</c> loses its second half entirely. That is not a cosmetic
///     bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
///     polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
///     </para>
///     <para><b>The encoding.</b> <c>&lt;kind&gt;:&lt;part&gt;[|&lt;part&gt;…]</c>, where <c>kind</c> is one
///     of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
///     can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
///     <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
///     readable (<c>column:dbo|TagValues|num_value</c>). The kind prefix carries the arity, so decoding never
///     has to guess how many parts a name "should" have; a part count that disagrees with the kind is
///     rejected rather than truncated or padded.</para>
///     <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
///     column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. Encode and decode
///     must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
///     exists to prevent.</para>
/// </summary>
public static class SqlBrowseNodeId
{
    private const char Separator = '|';
    private const char Escape = '\\';
    private const char KindTerminator = ':';

    private const string SchemaKind = "schema";
    private const string TableKind = "table";
    private const string ColumnKind = "column";

    /// <summary>Encodes a schema-level NodeId.</summary>
    /// <param name="schema">The schema name, exactly as the catalog reports it.</param>
    /// <returns>The encoded NodeId.</returns>
    /// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
    public static string ForSchema(string schema) => Encode(SchemaKind, schema);

    /// <summary>Encodes a table-level NodeId.</summary>
    /// <param name="schema">The schema name, exactly as the catalog reports it.</param>
    /// <param name="table">The table or view name, exactly as the catalog reports it.</param>
    /// <returns>The encoded NodeId.</returns>
    /// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
    public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);

    /// <summary>Encodes a column-level (leaf) NodeId.</summary>
    /// <param name="schema">The schema name, exactly as the catalog reports it.</param>
    /// <param name="table">The table or view name, exactly as the catalog reports it.</param>
    /// <param name="column">The column name, exactly as the catalog reports it.</param>
    /// <returns>The encoded NodeId.</returns>
    /// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
    public static string ForColumn(string schema, string table, string column) =>
        Encode(ColumnKind, schema, table, column);

    /// <summary>Attempts to decode a NodeId.</summary>
    /// <param name="nodeId">The NodeId to decode.</param>
    /// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
    /// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
    public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
    {
        reference = default;
        if (string.IsNullOrWhiteSpace(nodeId)) return false;

        var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
        if (terminator <= 0) return false;

        var kind = nodeId[..terminator];
        if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
        if (parts.Exists(string.IsNullOrWhiteSpace)) return false;

        switch (kind)
        {
            case SchemaKind when parts.Count == 1:
                reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
                return true;
            case TableKind when parts.Count == 2:
                reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
                return true;
            case ColumnKind when parts.Count == 3:
                reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
                return true;
            default:
                return false;
        }
    }

    /// <summary>
    ///     Decodes a NodeId, throwing when it is malformed.
    ///     <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
    ///     arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
    /// </summary>
    /// <param name="nodeId">The NodeId to decode.</param>
    /// <returns>The decoded reference.</returns>
    /// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
    public static SqlBrowseNodeRef Parse(string? nodeId)
    {
        if (TryParse(nodeId, out var reference)) return reference;
        throw new ArgumentException(
            "Not a SQL schema-browse node. Expected 'schema:<schema>', 'table:<schema>|<table>' or " +
            "'column:<schema>|<table>|<column>'. Re-open the browser and expand from the root.",
            nameof(nodeId));
    }

    private static string Encode(string kind, params string[] parts)
    {
        var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
        builder.Append(kind).Append(KindTerminator);
        for (var i = 0; i < parts.Length; i++)
        {
            if (string.IsNullOrWhiteSpace(parts[i]))
            {
                throw new ArgumentException(
                    "A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
            }

            if (i > 0) builder.Append(Separator);
            AppendEscaped(builder, parts[i]);
        }

        return builder.ToString();
    }

    private static void AppendEscaped(StringBuilder builder, string part)
    {
        foreach (var ch in part)
        {
            if (ch is Separator or Escape) builder.Append(Escape);
            builder.Append(ch);
        }
    }

    /// <summary>
    ///     Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
    ///     <c>false</c> on a dangling trailing escape, which cannot be produced by
    ///     <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
    /// </summary>
    private static bool TrySplit(string payload, out List<string> parts)
    {
        parts = [];
        var current = new StringBuilder(payload.Length);
        var escaped = false;

        foreach (var ch in payload)
        {
            if (escaped)
            {
                current.Append(ch);
                escaped = false;
                continue;
            }

            switch (ch)
            {
                case Escape:
                    escaped = true;
                    break;
                case Separator:
                    parts.Add(current.ToString());
                    current.Clear();
                    break;
                default:
                    current.Append(ch);
                    break;
            }
        }

        if (escaped) return false;
        parts.Add(current.ToString());
        return true;
    }
}
