using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;

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

/// <summary>
///     Covers <see cref="SqlDriverBrowser"/>'s open side: which of the two ways to name a database wins, what
///     an unresolvable reference tells the operator, which providers this build carries — and, above all,
///     that a connection string never escapes into a log line or an exception.
///     <para>The successful-open cases run through the linked <see cref="SqliteDialect"/> against the real
///     seeded <see cref="SqliteBrowseFixture"/>, injected via the browser's <c>dialectSelector</c>
///     constructor parameter. That parameter is the production extension point for a P2 provider, not a test
///     hatch — the same conclusion the driver author reached for <c>SqlDriver</c>'s <c>factory</c>
///     parameter.</para>
/// </summary>
public sealed class SqlDriverBrowserTests
{
    /// <summary>
    ///     The credential every hygiene assertion hunts for. Distinctive enough that a substring match
    ///     anywhere in an exception or a log line is proof of a leak, not a coincidence.
    /// </summary>
    private const string Password = "Sup3rSecret-OtOpcUa-Probe";

    /// <summary>
    ///     A host that cannot resolve, so the connect fails in DNS rather than on a timer. <c>.invalid</c> is
    ///     reserved by RFC 2606 and can never be registered.
    /// </summary>
    private const string DeadHost = "otopcua-no-such-host.invalid";

    private static Func<SqlProvider, ISqlDialect?> SqliteSelector => static _ => new SqliteDialect();

    // ---- identity ----

    [Fact]
    public void DriverType_isTheSqlDriversOwnInterimConstant()
    {
        // Deliberately asserted against SqlDriver.DriverTypeName rather than a literal: DriverTypeNames.Sql
        // does not exist yet (adding it reddens DriverTypeNamesGuardTests until a factory is registered), so
        // the driver's local constant is the single definition both sides must agree on.
        new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
    }

    [Fact]
    public void DefaultDialectSelector_carriesSqlServerOnly()
    {
        SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();

        foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
            SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
    }

    // ---- connection-string reference resolution ----

    /// <summary>The plan's headline case: an unresolvable ref must name the exact variable to set.</summary>
    [Fact]
    public async Task Open_unresolvableRef_failsNamingExactEnvVar()
    {
        var browser = new SqlDriverBrowser();
        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
        ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
    }

    /// <summary>
    ///     The default (production) path: no injected reader, a real process environment variable. Uses a
    ///     run-unique name and restores it in a <c>finally</c>, so a failure cannot leave it set for the rest
    ///     of the assembly — environment variables are process-global and every other test shares them.
    /// </summary>
    [Fact]
    public async Task Open_refResolvedFromTheRealEnvironment_opensSession()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var reference = "OtOpcUaBrowseTest" + Guid.NewGuid().ToString("N");
        var variable = SqlDriverBrowser.EnvironmentVariableFor(reference);

        Environment.SetEnvironmentVariable(variable, db.ConnectionString);
        try
        {
            var browser = new SqlDriverBrowser(SqliteSelector);

            await using var session = await browser.OpenAsync(
                ConfigJson(connectionStringRef: reference), CancellationToken.None);

            var roots = await session.RootAsync(CancellationToken.None);
            roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);
        }
        finally
        {
            Environment.SetEnvironmentVariable(variable, null);
        }
    }

    [Fact]
    public async Task Open_neitherRefNorLiteral_failsNamingBothWays()
    {
        var browser = new SqlDriverBrowser(SqliteSelector);

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(ConfigJson(), CancellationToken.None));

        ex.Message.ShouldContain("connectionStringRef");
        ex.Message.ShouldContain("connectionString");
    }

    [Fact]
    public async Task Open_blankRef_failsRatherThanResolvingAnEmptyVariableName()
    {
        var browser = new SqlDriverBrowser(SqliteSelector, static _ => "should never be read");

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(ConfigJson(connectionStringRef: "   "), CancellationToken.None));

        ex.Message.ShouldContain("connectionStringRef");
    }

    // ---- pasted literal, and its precedence ----

    /// <summary>
    ///     A pasted literal opens a working session, and <b>the session owns the connection</b> — it is still
    ///     live after <c>OpenAsync</c> returns (the browser did not close it) and dead after the session is
    ///     disposed (nothing else will).
    /// </summary>
    [Fact]
    public async Task Open_pastedLiteral_opensWorkingSessionThatOwnsTheConnection()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var browser = new SqlDriverBrowser(SqliteSelector);

        var session = await browser.OpenAsync(
            ConfigJson(connectionString: db.ConnectionString), CancellationToken.None);

        var children = await session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None);
        children.ShouldContain(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);

        await session.DisposeAsync();

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

    /// <summary>
    ///     Precedence: the pasted literal wins, and the reference is <b>not even read</b>. Proven by a reader
    ///     that records every call — an assertion on the resulting session alone would still pass if the ref
    ///     were consulted first and merely lost a tie-break.
    /// </summary>
    [Fact]
    public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var reads = new List<string>();
        var logger = new CapturingLogger<SqlDriverBrowser>();
        var browser = new SqlDriverBrowser(
            SqliteSelector,
            name =>
            {
                reads.Add(name);
                return "Data Source=/otopcua-no-such-directory/never.db";
            },
            logger);

        await using var session = await browser.OpenAsync(
            ConfigJson(connectionStringRef: "MesStaging", connectionString: db.ConnectionString),
            CancellationToken.None);

        reads.ShouldBeEmpty();
        var roots = await session.RootAsync(CancellationToken.None);
        roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);

        // The override is announced, naming the shadowed ref — never any connection text.
        logger.Entries.ShouldContain(e => e.Contains("MesStaging", StringComparison.Ordinal));
        logger.Entries.ShouldNotContain(e => e.Contains(db.ConnectionString, StringComparison.OrdinalIgnoreCase));
    }

    // ---- provider availability ----

    [Fact]
    public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
    {
        var browser = new SqlDriverBrowser();

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(
                ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));

        ex.Message.ShouldContain("Postgres");
        ex.Message.ShouldContain("not available in this build");
    }

    /// <summary>
    ///     Provider availability is answered before the environment is touched: "this build cannot browse
    ///     Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
    /// </summary>
    [Fact]
    public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
    {
        var reads = new List<string>();
        var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(
                ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));

        ex.Message.ShouldContain("not available in this build");
        reads.ShouldBeEmpty();
    }

    // ---- malformed configuration ----

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    public async Task Open_emptyConfig_failsAskingForOne(string configJson)
    {
        var browser = new SqlDriverBrowser(SqliteSelector);

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(configJson, CancellationToken.None));

        ex.Message.ShouldContain("configuration");
    }

    /// <summary>
    ///     Malformed JSON must fail as malformed JSON — and must not echo the offending text back, because
    ///     the text a JSON parser chokes on may itself be a half-pasted connection string.
    /// </summary>
    [Fact]
    public async Task Open_malformedJson_failsWithoutEchoingTheConfig()
    {
        var browser = new SqlDriverBrowser(SqliteSelector);
        var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """;

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(configJson, CancellationToken.None));

        ex.Message.ShouldContain("not valid JSON");
        ex.ToString().ShouldNotContain(Password, Case.Insensitive);
    }

    [Fact]
    public async Task Open_jsonThatIsNotAnObject_fails()
    {
        var browser = new SqlDriverBrowser(SqliteSelector);

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync("[1,2,3]", CancellationToken.None));

        ex.Message.ShouldContain("JSON object");
    }

    /// <summary>An unknown <c>provider</c> name is a bind failure, and must not echo the config either.</summary>
    [Fact]
    public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig()
    {
        var browser = new SqlDriverBrowser(SqliteSelector);

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(
                ConfigJson("NotARealProvider", connectionString: $"Server=x;Password={Password}"),
                CancellationToken.None));

        ex.Message.ShouldContain("could not be bound");
        ex.ToString().ShouldNotContain(Password, Case.Insensitive);
    }

    // ---- credential hygiene ----

    /// <summary>
    ///     The natural case the task names: a well-formed connection string carrying a password, pointed at a
    ///     host that does not exist. Nothing about the string may reach the exception.
    /// </summary>
    [Fact]
    public async Task Open_unreachableHost_neverLeaksThePastedConnectionString()
    {
        var connectionString =
            $"Server={DeadHost};Database=Mes;User ID=sa;Password={Password};Connect Timeout=1;"
            + "TrustServerCertificate=true";
        var browser = new SqlDriverBrowser();

        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));

        ex.ToString().ShouldNotContain(Password, Case.Insensitive);
        ex.ToString().ShouldNotContain(connectionString, Case.Insensitive);
        // Still says something useful about *why*.
        ex.Message.ShouldContain("could not open a connection");
    }

    /// <summary>
    ///     The leak that is real, and is the reason the parser's message is suppressed rather than trusted.
    ///     <para>ADO.NET expects a value containing <c>;</c> to be quoted. An unquoted one splits, and the
    ///     tail of the password is then parsed as a keyword — which
    ///     <c>Microsoft.Data.SqlClient</c> echoes verbatim (lower-cased) in
    ///     <c>Keyword not supported: '…'</c>. The first half of this test is the control: it proves the raw
    ///     provider really does leak, so the second half asserting the browser does not is not vacuous.</para>
    /// </summary>
    [Fact]
    public async Task Open_semicolonBearingPassword_neverLeaksTheProvidersEchoedKeyword()
    {
        const string tail = "OtOpcUaTailSecret";
        var connectionString = $"Server={DeadHost};Password=Sup3r;{tail};Connect Timeout=1";

        // Control: the bare provider leaks the tail of the password into its own exception.
        var raw = Should.Throw<ArgumentException>(() =>
        {
            using var connection = new SqlServerDialect().Factory.CreateConnection()!;
            connection.ConnectionString = connectionString;
        });
        raw.ToString().ShouldContain(tail, Case.Insensitive);

        // The browser does not.
        var browser = new SqlDriverBrowser();
        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
            browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));

        ex.ToString().ShouldNotContain(tail, Case.Insensitive);
        ex.Message.ShouldContain("malformed");
    }

    /// <summary>
    ///     Nothing the browser logs — on the success path or the failure path — carries connection text. The
    ///     ref name is fair game; the string never is.
    /// </summary>
    [Fact]
    public async Task Open_neverLogsTheConnectionString()
    {
        await using var db = await SqliteBrowseFixture.CreateAsync();
        var logger = new CapturingLogger<SqlDriverBrowser>();
        var browser = new SqlDriverBrowser(SqliteSelector, static _ => null, logger);

        await using (await browser.OpenAsync(
            ConfigJson(connectionString: db.ConnectionString), CancellationToken.None))
        {
            // Opened purely for its log output.
        }

        var dead = $"Data Source=/otopcua-no-such-directory/never.db;Password={Password}";
        await Should.ThrowAsync<Exception>(() =>
            browser.OpenAsync(ConfigJson(connectionString: dead), CancellationToken.None));

        logger.Entries.ShouldNotBeEmpty();
        foreach (var entry in logger.Entries)
        {
            entry.ShouldNotContain(db.ConnectionString, Case.Insensitive);
            entry.ShouldNotContain(Password, Case.Insensitive);
            entry.ShouldNotContain("Data Source", Case.Insensitive);
        }
    }

    // ---- helpers ----

    /// <summary>
    ///     Builds a driver-config blob. Serialized rather than interpolated so a connection string containing
    ///     JSON metacharacters (backslashes in a <c>Data Source</c> path, quotes in a password) escapes
    ///     correctly instead of producing malformed JSON the test would then misattribute.
    /// </summary>
    private static string ConfigJson(
        string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null)
    {
        var map = new Dictionary<string, object?> { ["provider"] = provider };
        if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef;
        if (connectionString is not null) map["connectionString"] = connectionString;
        return JsonSerializer.Serialize(map);
    }

    /// <summary>Records every formatted log message so the hygiene assertions can read them back.</summary>
    private sealed class CapturingLogger<T> : ILogger<T>
    {
        public List<string> Entries { get; } = [];

        IDisposable? ILogger.BeginScope<TState>(TState state) => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId, TState state, Exception? exception,
            Func<TState, Exception?, string> formatter) =>
            Entries.Add(formatter(state, exception));
    }
}
