using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     Folds a set of <see cref="SqlTagDefinition"/>s into the minimum number of parameterized queries
///     (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
///     <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> when every
///     field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
///     too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
///     <c>SqlGroupPlannerTests</c>.</para>
///     <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
///     <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
///     <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
///     interpolation of tag content into SQL anywhere in this type.</para>
/// </summary>
public static class SqlGroupPlanner
{
    /// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
    private const string KeyMarkerPrefix = "@k";

    /// <summary>The single parameter marker for a wide-row model's row selector.</summary>
    private const string RowSelectorMarker = "@w";

    /// <summary>
    ///     Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
    ///     <para>Deterministic: groups appear in the input order of their first member, members keep their input
    ///     order within a group, and parameters keep the first-appearance order of their distinct values — so a
    ///     given tag list always yields byte-identical SQL.</para>
    /// </summary>
    /// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
    /// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
    /// <returns>One plan per distinct group key.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
    /// <exception cref="ArgumentException">
    ///     A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
    ///     identifier cannot be quoted (an empty table-name part, a control character — see
    ///     <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
    ///     up front, so reaching one here means a caller built a definition by hand with a broken invariant.
    /// </exception>
    /// <exception cref="NotSupportedException">
    ///     A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
    ///     <b>Deliberately loud rather than skipped:</b> silently dropping a tag would leave an authored node
    ///     permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
    /// </exception>
    public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
    {
        ArgumentNullException.ThrowIfNull(tags);
        ArgumentNullException.ThrowIfNull(dialect);

        // Ordered grouping: the dictionary decides membership, the list decides emission order.
        var order = new List<object>();
        var groups = new Dictionary<object, List<SqlTagDefinition>>();

        foreach (var tag in tags)
        {
            ArgumentNullException.ThrowIfNull(tag);
            var key = BuildGroupKey(tag);
            if (!groups.TryGetValue(key, out var members))
            {
                members = [];
                groups.Add(key, members);
                order.Add(key);
            }

            members.Add(tag);
        }

        var plans = new List<SqlQueryPlan>(order.Count);
        foreach (var key in order)
        {
            var members = groups[key];
            plans.Add(members[0].Model switch
            {
                SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
                SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
                _ => throw UnsupportedModel(members[0]),
            });
        }

        return plans;
    }

    /// <summary>
    ///     Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose
    ///     first element is the model, so a key-value key can never compare equal to a wide-row key.
    ///     <list type="bullet">
    ///         <item>
    ///             <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
    ///             Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
    ///             would make one tag publish the other's column.
    ///         </item>
    ///         <item>
    ///             <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
    ///             — i.e. the table plus the whole row selector, exactly design §5.3's
    ///             <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
    ///             deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
    ///             one row), and they are added to the SELECT list instead.
    ///         </item>
    ///     </list>
    ///     <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
    ///     two tags authored <c>dbo.T</c> and <c>DBO.T</c> plan as two groups. That costs one extra round-trip
    ///     and is never wrong; guessing the server's collation here could fold two genuinely different sources.</para>
    /// </summary>
    private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch
    {
        SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn),
        SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue,
            tag.RowSelectorTopByTimestamp),
        _ => throw UnsupportedModel(tag),
    };

    /// <summary>
    ///     Emits <c>SELECT &lt;key&gt;, &lt;value&gt;[, &lt;ts&gt;] FROM &lt;table&gt; WHERE &lt;key&gt; IN (@k0..@kN)</c>.
    ///     The key column is selected because the reader indexes rows by it to slice values back per member.
    ///     <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
    ///     <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
    /// </summary>
    private static SqlQueryPlan BuildKeyValuePlan(
        object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
    {
        var first = members[0];
        var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first);
        var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first);
        var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn;

        var selected = new List<string>(3);
        AddDistinct(selected, keyColumn);
        AddDistinct(selected, valueColumn);
        if (timestampColumn is not null) AddDistinct(selected, timestampColumn);

        // A keyValue may legitimately be the empty string, so only null is a broken invariant.
        var names = new List<string>(members.Count);
        var values = new List<object?>(members.Count);
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var member in members)
        {
            var keyValue = member.KeyValue
                           ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
            if (!seen.Add(keyValue)) continue;
            names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
            values.Add(keyValue);
        }

        var sql = new StringBuilder("SELECT ")
            .Append(QuoteList(selected, dialect))
            .Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
            .Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
            .Append(" IN (").AppendJoin(", ", names).Append(')')
            .ToString();

        return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
    }

    /// <summary>
    ///     Emits <c>SELECT &lt;cols&gt; FROM &lt;table&gt; WHERE &lt;whereColumn&gt; = @w</c>, or — for a
    ///     <c>topByTimestamp</c> selector — the single-row form
    ///     <c>SELECT &lt;limit&gt; &lt;cols&gt; FROM &lt;table&gt; ORDER BY &lt;ts&gt; DESC &lt;limit&gt;</c>, whose
    ///     row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
    ///     <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
    ///     <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
    ///     followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
    ///     timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
    ///     row in the result already matches the bound value, so reading it back adds nothing.</para>
    /// </summary>
    private static SqlQueryPlan BuildWideRowPlan(
        object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
    {
        var first = members[0];

        var selected = new List<string>(members.Count + 1);
        foreach (var member in members)
            AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member));
        foreach (var member in members)
            if (!Blank(member.TimestampColumn))
                AddDistinct(selected, member.TimestampColumn!);

        var table = QuoteQualifiedName(first.Table, dialect);
        var columns = QuoteList(selected, dialect);

        // A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
        if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
        {
            var sql = string.Concat(
                "SELECT ", columns, " FROM ", table,
                " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
            return new SqlQueryPlan(
                SqlTagModel.WideRow, groupKey, sql,
                [RowSelectorMarker], [first.RowSelectorValue], members, selected);
        }

        if (!Blank(first.RowSelectorTopByTimestamp))
        {
            // The row limit is dialect syntax and sits at opposite ends of the statement per provider
            // (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted
            // unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix.
            var sql = string.Concat(
                "SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
                " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
                dialect.SingleRowLimitSuffix);
            return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
        }

        throw new ArgumentException(
            $"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" +
            "whereValue pair or a rowSelector.topByTimestamp column.", nameof(members));
    }

    /// <summary>
    ///     Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
    ///     <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
    ///     <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
    ///     form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
    ///     empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
    ///     whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
    /// </summary>
    private static string QuoteQualifiedName(string name, ISqlDialect dialect)
    {
        if (Blank(name))
            throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name));

        return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
    }

    /// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
    private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
        => string.Join(", ", columns.Select(dialect.QuoteIdentifier));

    /// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
    private static void AddDistinct(List<string> target, string value)
    {
        if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
    }

    /// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
    private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);

    /// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
    private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag)
        => Blank(value) ? throw MissingField(field, tag) : value!;

    private static ArgumentException MissingField(string field, SqlTagDefinition tag)
        => new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'.");

    private static NotSupportedException UnsupportedModel(SqlTagDefinition tag)
        => new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " +
               "v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4).");
}
