using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
///     shape and the caller's tests can assert on the reason.
/// </summary>
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);

/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
/// <param name="Accepted">
///     The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
///     <see cref="SqlGroupPlanner"/>.
/// </param>
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
public sealed record SqlCatalogGateResult(
    IReadOnlyList<SqlTagDefinition> Accepted,
    IReadOnlyList<SqlCatalogRejection> Rejected);

/// <summary>
///     Design §8.1's identifier gate: validates every authored table/column against the live catalog
///     <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
/// </summary>
/// <remarks>
///     <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
///     sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
///     bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
///     the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
///     and the design promised a filter. With the gate in place the identifier text in an emitted query is a
///     string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
///     than the whole story.</para>
///     <para><b>Charset first, catalog second.</b> Each identifier is passed through
///     <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
///     (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
///     fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
///     override or a NUL can reach a log line through this type's rejection messages. A name that passes has
///     been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
///     must, or an operator has no way to find the typo.</para>
///     <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
///     driver's table, so its RawPath resolves to nothing and it publishes
///     <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
///     on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
///     down" rule the tag-table build already follows.</para>
/// </remarks>
public static class SqlCatalogGate
{
    /// <summary>
    ///     The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
    ///     four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
    ///     cannot see, so it cannot be allow-listed.
    /// </summary>
    private const int MaxNameParts = 2;

    /// <summary>
    ///     Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
    ///     with catalog-spelled identifiers and the rejected ones with reasons.
    /// </summary>
    /// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
    /// <param name="catalog">The catalog snapshot to validate against.</param>
    /// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
    /// <returns>The accepted and rejected sets.</returns>
    /// <exception cref="ArgumentNullException">A required argument is null.</exception>
    public static SqlCatalogGateResult Apply(
        IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
    {
        ArgumentNullException.ThrowIfNull(definitions);
        ArgumentNullException.ThrowIfNull(catalog);
        ArgumentNullException.ThrowIfNull(dialect);

        var accepted = new List<SqlTagDefinition>();
        var rejected = new List<SqlCatalogRejection>();

        foreach (var definition in definitions)
        {
            ArgumentNullException.ThrowIfNull(definition);
            if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
                accepted.Add(canonical!);
            else
                rejected.Add(rejection!);
        }

        return new SqlCatalogGateResult(accepted, rejected);
    }

    /// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
    private static bool TryCanonicalize(
        SqlTagDefinition definition,
        SqlCatalog catalog,
        ISqlDialect dialect,
        out SqlTagDefinition? canonical,
        out SqlCatalogRejection? rejection)
    {
        canonical = null;
        rejection = null;

        if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
            return false;

        // Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
        // model does not use is null and simply skipped — the planner enforces which are required.
        var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
        foreach (var (field, authored) in new (string Field, string? Authored)[]
                 {
                     (nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
                     (nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
                     (nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
                     (nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
                     (nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
                     (nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
                 })
        {
            if (string.IsNullOrWhiteSpace(authored))
            {
                resolved[field] = authored;
                continue;
            }

            if (!IsRenderableIdentifier(authored, dialect))
            {
                rejection = new SqlCatalogRejection(definition.Name, field,
                    $"the authored {field} is not a usable identifier (it is over-long, or contains control " +
                    "or Unicode format characters); the value is withheld from this message because it " +
                    "cannot be safely rendered");
                return false;
            }

            if (!catalog.TryResolveColumn(schema, table, authored, out var column))
            {
                rejection = new SqlCatalogRejection(definition.Name, field,
                    $"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
                    "column under a case-sensitive collation)");
                return false;
            }

            resolved[field] = column;
        }

        canonical = definition with
        {
            Table = SqlCatalog.QualifiedKey(schema, table),
            KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
            ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
            TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
            ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
            RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
            RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
        };
        return true;
    }

    /// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
    private static bool TryResolveRelation(
        SqlTagDefinition definition,
        SqlCatalog catalog,
        ISqlDialect dialect,
        out string schema,
        out string table,
        out SqlCatalogRejection? rejection)
    {
        schema = string.Empty;
        table = string.Empty;
        rejection = null;
        const string Field = nameof(SqlTagDefinition.Table);

        if (string.IsNullOrWhiteSpace(definition.Table))
        {
            rejection = new SqlCatalogRejection(definition.Name, Field,
                "the tag has no 'table'; author the table or view to read");
            return false;
        }

        var parts = definition.Table.Split('.');
        if (parts.Length > MaxNameParts)
        {
            rejection = new SqlCatalogRejection(definition.Name, Field,
                $"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
                "validated against this connection's catalog, so a cross-database or linked-server name " +
                "cannot be allow-listed; expose the data through a view in this database instead");
            return false;
        }

        var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
        var authoredTable = parts[^1];

        foreach (var part in parts)
        {
            if (IsRenderableIdentifier(part, dialect)) continue;
            rejection = new SqlCatalogRejection(definition.Name, Field,
                "the authored table name has a part that is not a usable identifier (empty, over-long, or " +
                "containing control or Unicode format characters); the value is withheld from this message " +
                "because it cannot be safely rendered");
            return false;
        }

        if (!catalog.TryResolveSchema(authoredSchema, out schema))
        {
            rejection = new SqlCatalogRejection(definition.Name, Field,
                $"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
                "case-sensitive collation)");
            return false;
        }

        if (!catalog.TryResolveTable(schema, authoredTable, out table))
        {
            rejection = new SqlCatalogRejection(definition.Name, Field,
                $"table or view '{authoredTable}' does not exist in schema '{schema}'" +
                (authoredSchema is null
                    ? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
                    : string.Empty));
            return false;
        }

        return true;
    }

    /// <summary>
    ///     True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
    ///     embed in SQL and to render into a log line or an AdminUI label.
    /// </summary>
    /// <remarks>
    ///     Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
    ///     rules, so the two can never disagree about what a legal identifier is. The quoted result is
    ///     discarded: only whether it threw is interesting here.
    /// </remarks>
    private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
    {
        try
        {
            dialect.QuoteIdentifier(identifier);
            return true;
        }
        catch (ArgumentException)
        {
            return false;
        }
    }
}
