using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

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

/// <summary>
///     Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
///     column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
///     two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
///     the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
///     <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
///     <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
///     knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
///     read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
///     is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
///     <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
///     never needs an identifier in text.</para>
///     <para><b>Connection ownership: this session owns the connection it is handed and closes it on
///     <see cref="DisposeAsync"/>.</b> It does not open one, and it never re-opens a closed one. The handoff
///     is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
///     construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
///     if the session did not close it, nothing would, and every reaped picker would leak a pooled
///     connection.</para>
///     <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
///     20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); this session's job is simply to
///     honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
///     independent deadline here would only produce two competing, differently-worded failures.</para>
/// </summary>
internal sealed class SqlBrowseSession : IBrowseSession
{
    /// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
    private const string SchemaColumn = "TABLE_SCHEMA";

    /// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
    private const string TableNameColumn = "TABLE_NAME";

    /// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
    private const string TableTypeColumn = "TABLE_TYPE";

    /// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
    private const string ColumnNameColumn = "COLUMN_NAME";

    /// <summary>Column alias carrying the SQL type family name.</summary>
    private const string DataTypeColumn = "DATA_TYPE";

    /// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
    private const string ViewTableType = "VIEW";

    /// <summary>
    ///     The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
    ///     (design §4.3), so there is nothing else a picked column could be.
    /// </summary>
    private const string ReadOnlySecurityClass = "ViewOnly";

    private readonly DbConnection _connection;
    private readonly ISqlDialect _dialect;

    /// <summary>
    ///     Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
    ///     the AdminUI tree happily fires several expands at once when an operator clicks quickly.
    /// </summary>
    private readonly SemaphoreSlim _gate = new(1, 1);

    private volatile bool _disposed;

    /// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
    /// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
    /// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
    internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
    {
        _connection = connection ?? throw new ArgumentNullException(nameof(connection));
        _dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
    }

    /// <inheritdoc />
    public Guid Token { get; } = Guid.NewGuid();

    /// <inheritdoc />
    public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;

    /// <inheritdoc />
    public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
    {
        var schemas = await QueryAsync(
            _dialect.ListSchemasSql,
            static _ => { },
            static reader => ReadString(reader, SchemaColumn),
            cancellationToken).ConfigureAwait(false);

        var nodes = new List<BrowseNode>(schemas.Count);
        foreach (var schema in schemas)
        {
            if (string.IsNullOrWhiteSpace(schema)) continue;
            nodes.Add(new BrowseNode(
                NodeId: SqlBrowseNodeId.ForSchema(schema),
                DisplayName: schema,
                Kind: BrowseNodeKind.Folder,
                HasChildrenHint: true));
        }

        return nodes;
    }

    /// <inheritdoc />
    /// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
    public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        var reference = SqlBrowseNodeId.Parse(nodeId);
        switch (reference.Kind)
        {
            case SqlBrowseNodeKind.Schema:
                return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
            case SqlBrowseNodeKind.Table:
                return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
                    .ConfigureAwait(false);
            default:
                // A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
                // it has re-read the node's Kind.
                LastUsedUtc = DateTime.UtcNow;
                return Array.Empty<BrowseNode>();
        }
    }

    /// <inheritdoc />
    /// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
    public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(
        string nodeId, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        var reference = SqlBrowseNodeId.Parse(nodeId);
        if (reference.Kind != SqlBrowseNodeKind.Column)
        {
            // Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
            // uniform and returns empty for every node.
            LastUsedUtc = DateTime.UtcNow;
            return Array.Empty<AttributeInfo>();
        }

        var columns = await ReadColumnsAsync(
            reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);

        // Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
        // catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
        // that legitimately carries both "Value" and "value".
        foreach (var column in columns)
        {
            if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
            return
            [
                new AttributeInfo(
                    Name: column.Name,
                    DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
                    IsArray: false,
                    SecurityClass: ReadOnlySecurityClass,
                    IsAlarm: false,
                    // A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
                    // browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
                    // generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
                    // schema is carried separately so the commit mapper can qualify the table itself rather
                    // than parsing a joined string back apart.
                    AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
                    {
                        ["schema"] = reference.Schema,
                        ["table"] = reference.Table!,
                        ["columnName"] = column.Name,
                    }),
            ];
        }

        // The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
        // the honest answer; the picker simply has nothing to prefill.
        return Array.Empty<AttributeInfo>();
    }

    /// <summary>
    ///     Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
    ///     may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        try { await _connection.DisposeAsync().ConfigureAwait(false); }
        catch { /* best-effort: the connection may already be broken or closed. */ }

        try { _gate.Dispose(); }
        catch { /* best-effort: a concurrent second dispose already tore it down. */ }
    }

    private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
    {
        var rows = await QueryAsync(
            _dialect.ListTablesSql,
            command => Bind(command, "@schema", schema),
            static reader => (
                Name: ReadString(reader, TableNameColumn),
                Type: ReadOptionalString(reader, TableTypeColumn)),
            ct).ConfigureAwait(false);

        var nodes = new List<BrowseNode>(rows.Count);
        foreach (var (name, type) in rows)
        {
            if (string.IsNullOrWhiteSpace(name)) continue;
            var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
            nodes.Add(new BrowseNode(
                NodeId: SqlBrowseNodeId.ForTable(schema, name),
                // The label is decorated, never the NodeId — a view and a table of the same name in the same
                // schema cannot exist, so the suffix is presentation only.
                DisplayName: isView ? $"{name} (view)" : name,
                Kind: BrowseNodeKind.Folder,
                HasChildrenHint: true));
        }

        return nodes;
    }

    private async Task<IReadOnlyList<BrowseNode>> ExpandTableAsync(
        string schema, string table, CancellationToken ct)
    {
        var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);

        var nodes = new List<BrowseNode>(columns.Count);
        foreach (var column in columns)
        {
            if (string.IsNullOrWhiteSpace(column.Name)) continue;
            nodes.Add(new BrowseNode(
                NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
                DisplayName: column.Name,
                Kind: BrowseNodeKind.Leaf,
                HasChildrenHint: false));
        }

        return nodes;
    }

    /// <summary>
    ///     Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
    ///     than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
    ///     relation, and neither is a reason to fail an operator's click.
    /// </summary>
    private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
        string schema, string table, CancellationToken ct) =>
        QueryAsync(
            _dialect.ListColumnsSql,
            command =>
            {
                Bind(command, "@schema", schema);
                Bind(command, "@table", table);
            },
            static reader => (
                Name: ReadString(reader, ColumnNameColumn),
                DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
            ct);

    /// <summary>
    ///     Runs one catalog query under the gate and projects every row. The disposed check happens
    ///     <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
    ///     <see cref="LastUsedUtc"/> only advances on a call that actually completed.
    /// </summary>
    private async Task<List<T>> QueryAsync<T>(
        string sql,
        Action<DbCommand> bind,
        Func<DbDataReader, T> project,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);

            var results = new List<T>();
            await using var command = _connection.CreateCommand();
            command.CommandText = sql;
            bind(command);

            await using var reader = await command
                .ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
                results.Add(project(reader));

            LastUsedUtc = DateTime.UtcNow;
            return results;
        }
        finally
        {
            // A dispose that raced this call has already disposed the gate; releasing it then is harmless to
            // ignore and must not mask the real result.
            try { _gate.Release(); }
            catch (ObjectDisposedException) { }
        }
    }

    /// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
    private static void Bind(DbCommand command, string name, string value)
    {
        var parameter = command.CreateParameter();
        parameter.ParameterName = name;
        parameter.DbType = DbType.String;
        parameter.Value = value;
        command.Parameters.Add(parameter);
    }

    private static string ReadString(DbDataReader reader, string columnName) =>
        ReadOptionalString(reader, columnName) ?? string.Empty;

    private static string? ReadOptionalString(DbDataReader reader, string columnName)
    {
        var ordinal = reader.GetOrdinal(columnName);
        return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
    }
}
