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

/// <summary>
///     An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
///     allow-list design §8.1 requires identifiers to come from.
///     <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
///     <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
///     <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
///     lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
///     identifier text in an emitted query is a string the database gave us.</para>
///     <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
///     driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
///     snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
///     asks.</para>
/// </summary>
public sealed class SqlCatalog
{
    private readonly IReadOnlyList<string> _schemas;

    /// <summary>Canonical schema → canonical table names in it.</summary>
    private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;

    /// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
    private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;

    /// <summary>Constructs a catalog snapshot.</summary>
    /// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
    /// <param name="schemas">Every schema the connection can see.</param>
    /// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
    /// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
    internal SqlCatalog(
        string defaultSchema,
        IReadOnlyList<string> schemas,
        IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
        IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
    {
        DefaultSchema = defaultSchema;
        _schemas = schemas;
        _tablesBySchema = tablesBySchema;
        _columnsByTable = columnsByTable;
    }

    /// <summary>The schema an unqualified authored object name is resolved in.</summary>
    public string DefaultSchema { get; }

    /// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
    /// <param name="authored">The authored schema, or null/blank for the default schema.</param>
    /// <param name="canonical">The catalog's spelling, when resolved.</param>
    /// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
    public bool TryResolveSchema(string? authored, out string canonical)
    {
        if (string.IsNullOrWhiteSpace(authored))
        {
            canonical = DefaultSchema;
            return true;
        }

        return TryMatch(_schemas, authored, out canonical);
    }

    /// <summary>Resolves an authored table/view name within a canonical schema.</summary>
    /// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
    /// <param name="authored">The authored table or view name.</param>
    /// <param name="canonical">The catalog's spelling, when resolved.</param>
    /// <returns><see langword="true"/> when the relation exists in that schema.</returns>
    public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
    {
        canonical = string.Empty;
        return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
               && TryMatch(tables, authored, out canonical);
    }

    /// <summary>Resolves an authored column name within a canonical relation.</summary>
    /// <param name="canonicalSchema">The resolved schema.</param>
    /// <param name="canonicalTable">The resolved table or view.</param>
    /// <param name="authored">The authored column name.</param>
    /// <param name="canonical">The catalog's spelling, when resolved.</param>
    /// <returns><see langword="true"/> when the column exists on that relation.</returns>
    public bool TryResolveColumn(
        string canonicalSchema, string canonicalTable, string authored, out string canonical)
    {
        canonical = string.Empty;
        return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
               && TryMatch(columns, authored, out canonical);
    }

    /// <summary>The dictionary key for a canonical relation.</summary>
    /// <param name="schema">The canonical schema.</param>
    /// <param name="table">The canonical table.</param>
    /// <returns>The composite key.</returns>
    internal static string QualifiedKey(string schema, string table) => schema + "." + table;

    /// <summary>
    ///     Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
    ///     <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
    /// </summary>
    /// <remarks>
    ///     <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
    ///     <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
    ///     ordinally would reject configurations that have always been valid — a gate that breaks working
    ///     deployments is not defence in depth.</para>
    ///     <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
    ///     relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
    ///     such a config resolving to what the operator wrote, and refusing to choose between two
    ///     case-insensitive candidates is the only safe answer — silently picking one would publish a different
    ///     column's data under the operator's node, which is worse than rejecting the tag.</para>
    /// </remarks>
    /// <param name="candidates">The catalog spellings to match against.</param>
    /// <param name="authored">The authored name.</param>
    /// <param name="canonical">The catalog's spelling, when matched.</param>
    /// <returns><see langword="true"/> on an unambiguous match.</returns>
    internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
    {
        foreach (var candidate in candidates)
        {
            if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
            canonical = candidate;
            return true;
        }

        canonical = string.Empty;
        var matches = 0;
        foreach (var candidate in candidates)
        {
            if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
            if (++matches > 1)
            {
                canonical = string.Empty;
                return false;
            }

            canonical = candidate;
        }

        return matches == 1;
    }
}
