using System.Data;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;

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

/// <summary>
///     Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
///     <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
///     that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
///     with a quoting helper bolted on.
/// </summary>
public sealed class SqlBrowseSessionTests
{
    private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId;

    [Fact]
    public async Task RootAsync_yieldsSchemaFolders()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var roots = await session.RootAsync(CancellationToken.None);

        roots.Count.ShouldBe(1);
        roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema);
        roots[0].NodeId.ShouldBe(SchemaNodeId);
        roots[0].Kind.ShouldBe(BrowseNodeKind.Folder);
        roots[0].HasChildrenHint.ShouldBeTrue();
    }

    [Fact]
    public async Task ExpandSchema_yieldsTablesAndViewsAsFolders()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);

        children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint);
        children.ShouldContain(n =>
            n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable));
        // The view is listed beside the tables, and is labelled so the operator can tell them apart.
        var view = children
            .Where(n => n.NodeId ==
                SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName))
            .ShouldHaveSingleItem();
        view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName);
        view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName);
    }

    /// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
    [Fact]
    public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --",
        // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too.
        var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
            .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);

        var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);

        columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn
                                   && c.Kind == BrowseNodeKind.Leaf
                                   && !c.HasChildrenHint);

        var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
        var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None);

        var attribute = attributes.ShouldHaveSingleItem();
        attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
        attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
        attribute.IsArray.ShouldBeFalse();
        attribute.SecurityClass.ShouldBe("ViewOnly");
        attribute.IsAlarm.ShouldBeFalse();
    }

    [Fact]
    public async Task AttributesAsync_mapsTextColumnToString()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var nodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
        var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);

        attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
    }

    /// <summary>
    ///     An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
    ///     <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
    ///     still render.
    /// </summary>
    [Fact]
    public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable);
        var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None);
        columns.Count.ShouldBe(2);

        var nodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn);
        var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);

        attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
    }

    /// <summary>
    ///     The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
    ///     <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
    ///     this is the case that proves the encoding, not just the codec unit test.
    /// </summary>
    [Fact]
    public async Task AwkwardIdentifiers_surviveTheFullWalk()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
        var awkward = tables
            .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable)
            .ShouldHaveSingleItem();

        var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None);
        columns.Select(c => c.DisplayName)
            .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 },
                ignoreOrder: true);

        var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn);
        var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId);
        parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable);
        parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn);

        var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None);
        attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
    }

    /// <summary>
    ///     A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
    ///     instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
    /// </summary>
    [Fact]
    public async Task HostileTableName_isBound_notConcatenated()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable);
        var columns = await session.ExpandAsync(nodeId, CancellationToken.None);

        // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it
        // before anything about the columns, or a splice that returns no rows reds on the wrong line and the
        // test stops proving the payload was inert.
        db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
        columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn);

        var columnNodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn);
        var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None);
        db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
        attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32));
    }

    /// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
    [Fact]
    public async Task HostileSchemaName_isBound_notConcatenated()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --");
        var tables = await session.ExpandAsync(nodeId, CancellationToken.None);

        // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing.
        tables.ShouldBeEmpty();
        db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
    }

    /// <summary>
    ///     A table node whose table is gone (dropped between browse and expand) yields an empty column list.
    ///     This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less
    ///     table, and an empty catalog answer is what both shapes look like from the browser's side.
    /// </summary>
    [Fact]
    public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable);

        (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();

        var columnNodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever");
        (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty();
    }

    /// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
    [Fact]
    public async Task ExpandColumn_yieldsEmpty()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var nodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);

        (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();
    }

    /// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
    [Fact]
    public async Task AttributesAsync_onNonLeafNode_yieldsEmpty()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty();

        var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
        (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty();
    }

    /// <summary>
    ///     A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
    ///     message — the same contract the Galaxy and OPC UA client sessions have, and what
    ///     <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
    ///     <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
    /// </summary>
    [Theory]
    [InlineData("")]
    [InlineData("not-a-node-id")]
    [InlineData("main.TagValues|num_value")]  // the plan's literal sketch — not this codec's format
    [InlineData("column:a|b|c|d")]
    public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId)
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var expand = await Should.ThrowAsync<ArgumentException>(
            () => session.ExpandAsync(nodeId, CancellationToken.None));
        expand.Message.ShouldNotBeNullOrWhiteSpace();

        await Should.ThrowAsync<ArgumentException>(
            () => session.AttributesAsync(nodeId, CancellationToken.None));
    }

    /// <summary>
    ///     One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
    ///     Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
    ///     assertion would pass with the gate deleted.
    /// </summary>
    [Fact]
    public async Task ConcurrentCalls_areSerializedByTheGate()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var probe = new ConcurrencyProbe();
        var probing = new ConcurrencyProbingDbConnection(db.Connection, probe);
        await using var session = new SqlBrowseSession(probing, new SqliteDialect());

        var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
        var calls = new[]
        {
            session.RootAsync(CancellationToken.None),
            session.ExpandAsync(SchemaNodeId, CancellationToken.None),
            session.ExpandAsync(tableNodeId, CancellationToken.None),
            session.ExpandAsync(tableNodeId, CancellationToken.None),
        };

        var results = await Task.WhenAll(calls);

        probe.Peak.ShouldBe(1);
        // TagValues has three columns — proves the serialized calls still returned real catalog rows.
        results[2].Count.ShouldBe(3);
    }

    /// <summary>
    ///     Ownership: the session takes over the connection it is handed and closes it on dispose. The
    ///     AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
    ///     not close the connection, nothing would — every reaped picker would leak one.
    /// </summary>
    [Fact]
    public async Task DisposeAsync_closesTheConnectionItWasGiven()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var connection = db.OpenNewConnection();
        var session = new SqlBrowseSession(connection, new SqliteDialect());

        connection.State.ShouldBe(ConnectionState.Open);
        await session.DisposeAsync();

        connection.State.ShouldBe(ConnectionState.Closed);
    }

    [Fact]
    public async Task DisposeAsync_isIdempotent()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());

        await session.DisposeAsync();
        await Should.NotThrowAsync(async () => await session.DisposeAsync());
    }

    [Fact]
    public async Task CallsAfterDispose_throwObjectDisposedException()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());
        await session.DisposeAsync();

        await Should.ThrowAsync<ObjectDisposedException>(
            () => session.RootAsync(CancellationToken.None));
        await Should.ThrowAsync<ObjectDisposedException>(
            () => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
    }

    /// <summary>
    ///     The AdminUI bounds every call with a 20 s linked CTS
    ///     (<c>BrowserSessionService.PerCallTimeout</c>) — the session invents no timeout of its own, it just
    ///     has to honour the token it is handed, all the way into the ADO.NET call.
    /// </summary>
    [Fact]
    public async Task CancelledToken_isHonoured()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(() => session.RootAsync(cts.Token));
        await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
    }

    /// <summary>
    ///     <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
    ///     refreshed it would be evicted out from under an actively browsing operator.
    /// </summary>
    [Fact]
    public async Task LastUsedUtc_advancesOnEverySuccessfulCall()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());

        var atStart = session.LastUsedUtc;
        await Task.Delay(15, TestContext.Current.CancellationToken);
        await session.RootAsync(CancellationToken.None);
        var afterRoot = session.LastUsedUtc;
        afterRoot.ShouldBeGreaterThan(atStart);

        await Task.Delay(15, TestContext.Current.CancellationToken);
        await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
        var afterExpand = session.LastUsedUtc;
        afterExpand.ShouldBeGreaterThan(afterRoot);

        await Task.Delay(15, TestContext.Current.CancellationToken);
        var columnNodeId = SqlBrowseNodeId.ForColumn(
            SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
        await session.AttributesAsync(columnNodeId, CancellationToken.None);
        session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
    }
}
