using System.Data;
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     Proves the <see cref="SqlDriver"/> shell — the part <see cref="SqlPollReader"/> deliberately does not
///     own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery,
///     the poll-engine subscription overlay, and the health + host-connectivity surface.
///     <para><b>The health assertions are the point of this class.</b> The reader returns Bad-coded snapshots
///     rather than throwing for everything short of an unreachable server, so nothing below the shell degrades
///     health on its own: a frozen database yields all-<c>BadTimeout</c> snapshots and a perfectly successful
///     <see cref="PollGroupEngine"/> tick. If the shell does not classify what came back, the driver reports
///     <see cref="DriverState.Healthy"/> while every value is Bad — the failure mode these tests exist to
///     prevent, together with its inverse (a tag typo must NOT report the database down).</para>
/// </summary>
public sealed class SqlDriverTests
{
    private const string DriverInstanceId = "sql-1";

    // ---- discovery + initialize ----

    [Fact]
    public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
        driver.DriverType.ShouldBe("Sql");
        driver.DriverInstanceId.ShouldBe(DriverInstanceId);
        ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
        ((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);

        var capture = new CapturingBuilder();
        await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);

        capture.Variables.ShouldContain(v =>
            v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
        // v1 is read-only by construction, not by configuration.
        driver.ShouldNotBeAssignableTo<IWritable>();
    }

    [Fact]
    public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe()
    {
        using var fixture = new SqlitePollFixture();
        var logger = new CapturingLogger();
        await using var driver = NewDriver(
            fixture,
            logger,
            KvEntry("Speed", SqlitePollFixture.PresentKey),
            new RawTagEntry("Broken", "not json at all", WriteIdempotent: false));

        // A single malformed blob must never fail the whole driver.
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);

        var capture = new CapturingBuilder();
        await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
        capture.Variables.Count.ShouldBe(1);
        capture.Variables[0].Info.FullName.ShouldBe("Speed");

        logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken"));

        // …and the skipped tag resolves to nothing rather than to someone else's row.
        var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None);
        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
    }

    [Fact]
    public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));

        // Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in
        // Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not
        // seal as a silently-connected driver.
        var thrown = await Should.ThrowAsync<Exception>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));

        var health = driver.GetHealth();
        health.State.ShouldBe(DriverState.Faulted);
        health.LastError.ShouldNotBeNullOrWhiteSpace();
        // Actionable: it names the endpoint the operator has to go look at…
        health.LastError!.ShouldContain(NoSuchDatabaseName);
        thrown.Message.ShouldContain(NoSuchDatabaseName);
        // …and never the credential-bearing connection string.
        health.LastError.ShouldNotContain(SecretToken);
        thrown.Message.ShouldNotContain(SecretToken);
        driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
        driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
    }

    [Fact]
    public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain()
    {
        using var fixture = new SqlitePollFixture();
        // SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection
        // string is unreachable until the directory exists, and reachable the moment it does.
        var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}");
        var connectionString = new SqliteConnectionStringBuilder
        {
            DataSource = Path.Combine(directory, "late.db"),
        }.ToString();

        await using var driver = NewDriver(
            fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));

        await Should.ThrowAsync<Exception>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
        driver.GetHealth().State.ShouldBe(DriverState.Faulted);

        try
        {
            Directory.CreateDirectory(directory);

            await driver.ReinitializeAsync(ConfigJson, CancellationToken.None);

            driver.GetHealth().State.ShouldBe(DriverState.Healthy);
            driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
            // The authored table survived the round trip — a recovered driver still serves its tags.
            var capture = new CapturingBuilder();
            await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
            capture.Variables.Count.ShouldBe(1);
        }
        finally
        {
            SqliteConnection.ClearAllPools();
            try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
        }
    }

    // ---- IReadable delegation ----

    [Fact]
    public async Task ReadAsync_delegatesToTheReader_valueForValue()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture,
            KvEntry("Speed", SqlitePollFixture.PresentKey),
            KvEntry("Temp", SqlitePollFixture.NullValueKey),
            KvEntry("Missing", SqlitePollFixture.AbsentKey));
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var reference = new SqlPollReader(
            fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
            commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
            maxConcurrentGroups: 4, nullIsBad: false,
            resolve: rawPath => rawPath switch
            {
                "Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey),
                "Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey),
                "Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey),
                _ => null,
            });

        string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"];
        var throughDriver = await driver.ReadAsync(refs, CancellationToken.None);
        var throughReader = await reference.ReadAsync(refs, CancellationToken.None);

        throughDriver.Count.ShouldBe(throughReader.Count);
        for (var i = 0; i < throughDriver.Count; i++)
        {
            throughDriver[i].Value.ShouldBe(throughReader[i].Value);
            throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode);
            throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc);
        }
    }

    // ---- the sustained-timeout decision ----

    [Fact]
    public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture, fixture.ConnectionString, CapturingLogger.Null,
            // Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while
            // the server-side backstop would still be waiting — the reader's frozen-peer shape.
            operationTimeout: TimeSpan.FromMilliseconds(500),
            commandTimeout: TimeSpan.FromSeconds(30),
            rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]);
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);

        // A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests).
        using var locker = fixture.OpenNewConnection();
        using (var begin = locker.CreateCommand())
        {
            begin.CommandText = "BEGIN EXCLUSIVE";
            begin.ExecuteNonQuery();
        }

        try
        {
            var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);

            // The reader does exactly what it promises — Bad-coded snapshots, no exception…
            snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
            // …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health.
            var health = driver.GetHealth();
            health.State.ShouldBe(DriverState.Degraded);
            health.LastError.ShouldNotBeNullOrWhiteSpace();
            driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
        }
        finally
        {
            using var rollback = locker.CreateCommand();
            rollback.CommandText = "ROLLBACK";
            rollback.ExecuteNonQuery();
        }
    }

    [Fact]
    public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
    {
        // The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when
        // the Bad codes are connection-class. An authoring typo reporting "the database is down" would send
        // an operator to the wrong system entirely.
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);
        (await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);

        var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);

        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
        driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
    }

    // ---- ISubscribable (poll-engine overlay) ----

    [Fact]
    public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var first = new TaskCompletionSource<DataChangeEventArgs>(
            TaskCreationOptions.RunContinuationsAsynchronously);
        var changes = 0;
        driver.OnDataChange += (_, args) =>
        {
            Interlocked.Increment(ref changes);
            first.TrySetResult(args);
        };

        var handle = await driver.SubscribeAsync(
            ["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);

        var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
        initial.FullReference.ShouldBe("Speed");
        initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
        initial.SubscriptionHandle.ShouldBe(handle);

        await driver.UnsubscribeAsync(handle, CancellationToken.None);
        driver.ActiveSubscriptionCount.ShouldBe(0);

        // Unsubscribe awaits the loop task, so nothing may arrive after it returns.
        var afterUnsubscribe = Volatile.Read(ref changes);
        await Task.Delay(400, TestContext.Current.CancellationToken);
        Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
    }

    // ---- disposal ----

    [Fact]
    public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent()
    {
        using var fixture = new SqlitePollFixture();
        var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var changes = 0;
        driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes);
        _ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        await driver.DisposeAsync();
        driver.ActiveSubscriptionCount.ShouldBe(0);

        var afterDispose = Volatile.Read(ref changes);
        await Task.Delay(400, TestContext.Current.CancellationToken);
        Volatile.Read(ref changes).ShouldBe(afterDispose);

        // Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw.
        await driver.ShutdownAsync(CancellationToken.None);
        await Should.NotThrowAsync(async () => await driver.DisposeAsync());
    }

    // ---- host identity ----

    [Fact]
    public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials()
    {
        using var fixture = new SqlitePollFixture();
        const string connectionString =
            "Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";";
        await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null);

        var hosts = driver.GetHostStatuses();

        hosts.Count.ShouldBe(1);
        hosts[0].HostName.ShouldContain("sqlsrv01");
        hosts[0].HostName.ShouldContain("MesStaging");
        hosts[0].HostName.ShouldNotContain(SecretToken);
        hosts[0].HostName.ShouldNotContain("svc_ot");
        hosts[0].State.ShouldBe(HostState.Unknown);   // nothing has been attempted yet
    }

    [Fact]
    public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
        var transitions = new List<HostStatusChangedEventArgs>();
        driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } };

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);
        // A second successful poll must not re-raise — the event is for transitions, not for ticks.
        await driver.ReadAsync(["Speed"], CancellationToken.None);

        lock (transitions)
        {
            transitions.Count.ShouldBe(1);
            transitions[0].OldState.ShouldBe(HostState.Unknown);
            transitions[0].NewState.ShouldBe(HostState.Running);
        }
    }

    // ---- C1a: the credential guarantee, proven load-bearing ----

    [Fact]
    public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface()
    {
        // The vacuous sibling test drives SQLite's "unable to open database file" path, whose message
        // structurally never contains the connection string — so it passes with or without any defensive
        // code. This one fabricates a DbException whose OWN .Message carries a credential-like token and
        // drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED
        // against the pre-fix code that interpolated ex.Message into LastError and the thrown message.
        const string leakyMessage = "login failed for user; Password=" + SecretToken;
        var driver = new SqlDriver(
            new SqlDriverOptions
            {
                RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
                OperationTimeout = TimeSpan.FromSeconds(15),
                CommandTimeout = TimeSpan.FromSeconds(10),
            },
            DriverInstanceId,
            new SqliteDialect(),
            // A credential-free connection string: the ONLY place the token can come from is the exception.
            "Server=sqlsrv01;Initial Catalog=Mes",
            factory: new ThrowingConnectionFactory(leakyMessage),
            logger: CapturingLogger.Null);
        await using var _ = driver;

        var thrown = await Should.ThrowAsync<Exception>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));

        var health = driver.GetHealth();
        health.State.ShouldBe(DriverState.Faulted);
        health.LastError.ShouldNotBeNullOrWhiteSpace();
        // The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status.
        health.LastError!.ShouldNotContain(SecretToken);
        thrown.Message.ShouldNotContain(SecretToken);
        driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
        driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
        // …and it is still actionable: it names the endpoint the operator has to go look at.
        health.LastError.ShouldContain("sqlsrv01");
    }

    // ---- I1: no double health-classification on a subscribed-read outage ----

    [Fact]
    public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce()
    {
        // On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a
        // good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll
        // engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync
        // owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a
        // credential-leak path) and logged the outage a second time.
        using var fixture = new SqlitePollFixture();
        var logger = new CapturingLogger();
        await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);

        driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken));

        var health = driver.GetHealth();
        // Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked…
        health.State.ShouldBe(DriverState.Healthy);
        health.LastError.ShouldBeNull();
        // …and it logged nothing (ReadAsync owns the single outage warning).
        logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0);
    }

    [Fact]
    public void HandlePollError_forAnEngineContractViolation_stillDegrades()
    {
        // The falsifiability control for the guard above: a non-DbException (the poll engine's own
        // reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health
        // surface. The guard skips only the DbException connection class.
        using var fixture = new SqlitePollFixture();
        var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));

        driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots"));

        driver.GetHealth().State.ShouldBe(DriverState.Degraded);
    }

    [Fact]
    public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem()
    {
        // The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction —
        // canonically the ArgumentException a malformed connection string's keyword parser throws, echoing
        // credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is
        // not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never
        // ex.Message. Pre-fix this degraded with the raw message and leaked the token.
        using var fixture = new SqlitePollFixture();
        var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));

        driver.HandlePollError(new ArgumentException(
            "Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken));

        var health = driver.GetHealth();
        health.State.ShouldBe(DriverState.Degraded);
        health.LastError.ShouldNotBeNull();
        health.LastError.ShouldNotContain(SecretToken);                 // never the message text
        health.LastError.ShouldContain(nameof(ArgumentException));      // but still actionable (the type)
    }

    // ---- I2: a late poll cannot un-fault a Faulted driver ----

    [Fact]
    public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver()
    {
        // The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to
        // the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has
        // recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only
        // a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write.
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await Should.ThrowAsync<Exception>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
        driver.GetHealth().State.ShouldBe(DriverState.Faulted);

        // The stale in-flight poll completes with an all-Good batch.
        driver.ObservePollOutcome(
            [new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);

        driver.GetHealth().State.ShouldBe(DriverState.Faulted);   // stayed Faulted
    }

    // ---- I3: an omitted-type tag warns at discovery ----

    [Fact]
    public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne()
    {
        using var fixture = new SqlitePollFixture();
        var logger = new CapturingLogger();
        await using var driver = NewDriver(
            fixture, logger,
            KvEntry("Speed", SqlitePollFixture.PresentKey),                 // no "type" → discovered String
            TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32"));    // explicit "type"
        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var capture = new CapturingBuilder();
        await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);

        logger.Entries.ShouldContain(e =>
            e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored"));
        logger.Entries.ShouldNotContain(e =>
            e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored"));
    }

    // ---- helpers ----

    /// <summary>A distinctive password token: any assertion that finds it has found a credential leak.</summary>
    private const string SecretToken = "hunter2-do-not-log";

    /// <summary>The unreachable connection string's database file name, used as the actionable-error probe.</summary>
    private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";

    /// <summary>
    ///     The driver's <c>DriverConfig</c> JSON. The shell serves its typed options (the factory, Task 9,
    ///     owns parsing) exactly as <c>ModbusDriver</c> does, so this is deliberately inert.
    /// </summary>
    private const string ConfigJson = """{"provider":"SqlServer"}""";

    private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
        => NewDriver(fixture, CapturingLogger.Null, rawTags);

    private static SqlDriver NewDriver(
        SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
        => NewDriver(fixture, fixture.ConnectionString, logger, rawTags);

    private static SqlDriver NewDriver(
        SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags)
        => NewDriver(
            fixture, connectionString, logger,
            operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
            rawTags: rawTags);

    /// <summary>
    ///     Builds the driver through its production constructor. That constructor <b>is</b> the injection
    ///     seam — dialect, provider factory and already-resolved connection string are all parameters — so no
    ///     test-only factory is needed on the product type (Task 9 resolves the same three from config).
    /// </summary>
    private static SqlDriver NewDriver(
        SqlitePollFixture fixture,
        string connectionString,
        CapturingLogger logger,
        TimeSpan operationTimeout,
        TimeSpan commandTimeout,
        IReadOnlyList<RawTagEntry> rawTags)
        => new(
            new SqlDriverOptions
            {
                RawTags = rawTags,
                OperationTimeout = operationTimeout,
                CommandTimeout = commandTimeout,
            },
            DriverInstanceId,
            new SqliteDialect(),
            connectionString,
            factory: fixture.Factory,
            logger: logger);

    /// <summary>A connection string whose database cannot be opened — the directory does not exist.</summary>
    private static string Unreachable() => new SqliteConnectionStringBuilder
    {
        DataSource = Path.Combine(
            Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
        Password = SecretToken,
    }.ToString();

    /// <summary>One authored raw tag: a key-value <c>TagConfig</c> blob over the fixture's EAV table.</summary>
    private static RawTagEntry KvEntry(string rawPath, string keyValue)
        => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
            {
              "driver": "Sql",
              "model": "KeyValue",
              "table": "{{SqlitePollFixture.KeyValueTable}}",
              "keyColumn": "{{SqlitePollFixture.KeyColumn}}",
              "keyValue": "{{keyValue}}",
              "valueColumn": "{{SqlitePollFixture.ValueColumn}}",
              "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
            }
            """), WriteIdempotent: false);

    /// <summary>A key-value entry carrying an explicit <c>"type"</c> — so <c>DeclaredType</c> is non-null.</summary>
    private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type)
        => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
            {
              "driver": "Sql",
              "model": "KeyValue",
              "table": "{{SqlitePollFixture.KeyValueTable}}",
              "keyColumn": "{{SqlitePollFixture.KeyColumn}}",
              "keyValue": "{{keyValue}}",
              "valueColumn": "{{SqlitePollFixture.ValueColumn}}",
              "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}",
              "type": "{{type}}"
            }
            """), WriteIdempotent: false);

    /// <summary>The same tag as <see cref="KvEntry"/>, already typed — for the reference reader.</summary>
    private static SqlTagDefinition KvDefinition(string rawPath, string keyValue)
        => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
            KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
            ValueColumn: SqlitePollFixture.ValueColumn,
            TimestampColumn: SqlitePollFixture.TimestampColumn);

    /// <summary>Records everything the driver streams into the address space.</summary>
    private sealed class CapturingBuilder : IAddressSpaceBuilder
    {
        /// <summary>The folders created, in order.</summary>
        public List<(string BrowseName, string DisplayName)> Folders { get; } = [];

        /// <summary>The variables registered, in order.</summary>
        public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];

        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        {
            Folders.Add((browseName, displayName));
            return this;
        }

        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
        {
            Variables.Add((browseName, attributeInfo));
            return new Handle(attributeInfo.FullName);
        }

        public void AddProperty(string browseName, DriverDataType dataType, object? value) { }

        private sealed class Handle(string fullReference) : IVariableHandle
        {
            public string FullReference => fullReference;

            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();

            private sealed class Sink : IAlarmConditionSink
            {
                public void OnTransition(AlarmEventArgs args) { }
            }
        }
    }

    /// <summary>Captures the driver's log so "skipped and logged" can be asserted rather than assumed.</summary>
    private sealed class CapturingLogger : ILogger<SqlDriver>
    {
        /// <summary>A logger that records nothing — for the tests that do not assert on logging.</summary>
        public static CapturingLogger Null { get; } = new();

        /// <summary>Every record written, level + rendered message.</summary>
        public List<(LogLevel Level, string Message)> Entries { get; } = [];

        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

        public bool IsEnabled(LogLevel logLevel) => true;

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

        private sealed class NullScope : IDisposable
        {
            public static NullScope Instance { get; } = new();

            public void Dispose() { }
        }
    }

    /// <summary>
    ///     A <see cref="DbException"/> whose message is under the test's control — stands in for the
    ///     credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene
    ///     guarantee can be exercised without a live database.
    /// </summary>
    private sealed class FakeDbException(string message) : DbException(message);

    /// <summary>
    ///     A <see cref="DbProviderFactory"/> whose connections always fail to open, with a
    ///     caller-supplied (credential-bearing) message — the seam that drives the driver's
    ///     unreachable-database path with a fabricated provider exception.
    /// </summary>
    private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory
    {
        public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage);
    }

    /// <summary>A connection that throws <see cref="FakeDbException"/> on open; every other member is inert.</summary>
    private sealed class ThrowingConnection(string openFailureMessage) : DbConnection
    {
        [System.Diagnostics.CodeAnalysis.AllowNull]
        public override string ConnectionString { get; set; } = string.Empty;

        public override string Database => string.Empty;

        public override string DataSource => string.Empty;

        public override string ServerVersion => string.Empty;

        public override ConnectionState State => ConnectionState.Closed;

        public override void Open() => throw new FakeDbException(openFailureMessage);

        public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();

        public override void Close() { }

        protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
            => throw new NotSupportedException();

        protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
    }
}
