using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;

/// <summary>
/// Direct SQLite persistence for site-local deployment state.
/// Stores deployed instance configurations (as JSON) and static attribute overrides.
/// This is NOT EF Core — uses Microsoft.Data.Sqlite directly for lightweight site storage.
/// </summary>
public class SiteStorageService
{
    private readonly ILocalDb _localDb;
    private readonly ILogger<SiteStorageService> _logger;

    /// <summary>
    /// Initializes the service over the consolidated site database.
    /// </summary>
    /// <param name="localDb">
    /// The consolidated site database. Every connection it hands out is already open and carries
    /// the per-connection pragmas (including the busy timeout that this class used to pin itself)
    /// plus the <c>zb_hlc_next()</c> UDF that the config tables' capture triggers call — which is
    /// exactly why the service no longer builds its own connection string. A raw connection would
    /// lack the UDF and every write to a replicated table would fail closed.
    /// </param>
    /// <param name="logger">Logger instance for diagnostic messages.</param>
    public SiteStorageService(ILocalDb localDb, ILogger<SiteStorageService> logger)
    {
        ArgumentNullException.ThrowIfNull(localDb);
        ArgumentNullException.ThrowIfNull(logger);
        _localDb = localDb;
        _logger = logger;
    }

    /// <summary>
    /// Returns an <b>already-open</b> connection against the site database.
    /// Exposed so site-local repositories can get their own connections without reaching
    /// into private state via reflection. The caller owns the connection and is
    /// responsible for disposing it.
    /// </summary>
    /// <remarks>
    /// <b>Contract change:</b> this used to return an <i>unopened</i> connection that the
    /// caller opened. LocalDb hands out connections already open, pragma-configured, and
    /// carrying the <c>zb_hlc_next()</c> UDF — calling <c>Open</c>/<c>OpenAsync</c> on one
    /// throws. Callers must not open it.
    /// </remarks>
    /// <returns>An open <see cref="SqliteConnection"/> against the site database.</returns>
    public SqliteConnection CreateConnection() => _localDb.CreateConnection();

    /// <summary>
    /// Convenience alias used internally, mirroring the other LocalDb-backed stores.
    /// </summary>
    private SqliteConnection OpenConnection() => _localDb.CreateConnection();

    /// <summary>
    /// Creates the SQLite tables if they do not exist.
    /// Called once on site startup.
    /// </summary>
    /// <returns>A task that completes when all tables have been created or verified.</returns>
    public Task InitializeAsync()
    {
        // No journal-mode pragma and no busy-timeout normalization here any more:
        // LocalDb owns the file and sets WAL plus the per-connection pragmas on every
        // connection it hands out.
        using var connection = OpenConnection();

        // The DDL itself lives in SiteStorageSchema so the Host can apply it to a
        // LocalDb-managed connection before RegisterReplicated installs the capture
        // triggers. The service still calls it, so a directly-constructed service
        // (tests, tooling) remains self-sufficient.
        SiteStorageSchema.Apply(connection);

        _logger.LogInformation("Site SQLite storage initialized");
        return Task.CompletedTask;
    }

    // ── Deployed Configuration CRUD ──

    /// <summary>
    /// Returns all deployed instance configurations from SQLite.
    /// </summary>
    /// <returns>A task that resolves to the list of all deployed instance configurations.</returns>
    public async Task<List<DeployedInstance>> GetAllDeployedConfigsAsync()
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            SELECT instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at
            FROM deployed_configurations";

        var results = new List<DeployedInstance>();
        await using var reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            results.Add(new DeployedInstance
            {
                InstanceUniqueName = reader.GetString(0),
                ConfigJson = reader.GetString(1),
                DeploymentId = reader.GetString(2),
                RevisionHash = reader.GetString(3),
                IsEnabled = reader.GetInt64(4) != 0,
                DeployedAt = reader.GetString(5)
            });
        }

        return results;
    }

    /// <summary>
    /// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <param name="configJson">The deployed configuration as JSON.</param>
    /// <param name="deploymentId">The unique deployment identifier.</param>
    /// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
    /// <param name="isEnabled">Whether the instance is enabled.</param>
    /// <returns>A task that completes when the configuration has been stored or updated.</returns>
    public async Task StoreDeployedConfigAsync(
        string instanceName,
        string configJson,
        string deploymentId,
        string revisionHash,
        bool isEnabled)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO deployed_configurations (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
            VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
            ON CONFLICT(instance_unique_name) DO UPDATE SET
                config_json   = excluded.config_json,
                deployment_id = excluded.deployment_id,
                revision_hash = excluded.revision_hash,
                is_enabled    = excluded.is_enabled,
                deployed_at   = excluded.deployed_at";

        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@json", configJson);
        command.Parameters.AddWithValue("@depId", deploymentId);
        command.Parameters.AddWithValue("@hash", revisionHash);
        command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
        command.Parameters.AddWithValue("@deployedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("Stored deployed config for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
    }

    /// <summary>
    /// Stores a deployed instance configuration only if the incoming write is strictly
    /// newer than any already-stored row for the same instance.  If no row exists the
    /// write is always performed (insert path).  Uses SQLite's conditional
    /// <c>ON CONFLICT … WHERE excluded.deployed_at &gt; deployed_configurations.deployed_at</c>
    /// clause so the guard is atomic with no application-level read-modify-write.
    /// </summary>
    /// <remarks>
    /// <b>This is the reconciliation write path.</b>  <c>SiteReconciliationActor</c> runs a
    /// per-node startup self-heal against central: it asks central what this node should be
    /// running and fetches anything missing.  That fetch races real deploys, so the
    /// <c>deployed_at</c> guard is what stops a slow reconcile response from overwriting a
    /// newer config that landed while it was in flight.  The active-node apply path
    /// (<see cref="StoreDeployedConfigAsync"/>) remains unguarded and always overwrites,
    /// because a deploy is always authoritative.
    /// <para>
    /// It was originally the <i>standby</i> write path as well, under notify-and-fetch: the
    /// standby was told a deploy had happened and fetched the config itself.  LocalDb Phase 2
    /// replaced that with change-data-capture — the config row simply replicates — so the
    /// standby no longer writes here at all, and last-writer-wins on the primary key (not
    /// this guard) is what orders concurrent writes between the two nodes.  Reconciliation is
    /// the reason the method survives; do not port the <c>deployed_at</c> guard onto the
    /// replication path, where it would fight the HLC rather than help it.
    /// </para>
    /// <para>
    /// <paramref name="deployedAtOverride"/> is exposed for testing so that the exact
    /// <c>deployed_at</c> value can be controlled without sleeping between calls.
    /// Production callers omit it and receive the default <see cref="DateTimeOffset.UtcNow"/>
    /// semantics, identical to <see cref="StoreDeployedConfigAsync"/>.
    /// </para>
    /// </remarks>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <param name="configJson">The deployed configuration as JSON.</param>
    /// <param name="deploymentId">The unique deployment identifier.</param>
    /// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
    /// <param name="isEnabled">Whether the instance is enabled.</param>
    /// <param name="deployedAtOverride">
    /// Optional explicit <c>deployed_at</c> timestamp.  Defaults to
    /// <see cref="DateTimeOffset.UtcNow"/> when <see langword="null"/>.
    /// </param>
    /// <returns>A task that completes when the conditional upsert has been executed.</returns>
    public async Task StoreDeployedConfigIfNewerAsync(
        string instanceName,
        string configJson,
        string deploymentId,
        string revisionHash,
        bool isEnabled,
        DateTimeOffset? deployedAtOverride = null)
    {
        var deployedAt = (deployedAtOverride ?? DateTimeOffset.UtcNow).ToString("O");

        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO deployed_configurations
                (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
            VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
            ON CONFLICT(instance_unique_name) DO UPDATE SET
                config_json   = excluded.config_json,
                deployment_id = excluded.deployment_id,
                revision_hash = excluded.revision_hash,
                is_enabled    = excluded.is_enabled,
                deployed_at   = excluded.deployed_at
            WHERE excluded.deployed_at > deployed_configurations.deployed_at";

        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@json", configJson);
        command.Parameters.AddWithValue("@depId", deploymentId);
        command.Parameters.AddWithValue("@hash", revisionHash);
        command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
        command.Parameters.AddWithValue("@deployedAt", deployedAt);

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("StoreDeployedConfigIfNewer for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
    }

    /// <summary>
    /// Removes a deployed instance configuration and its static overrides.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance to remove.</param>
    /// <returns>A task that completes when the configuration and its overrides have been removed.</returns>
    public async Task RemoveDeployedConfigAsync(string instanceName)
    {
        await using var connection = OpenConnection();

        await using var transaction = await connection.BeginTransactionAsync();

        await using (var cmd = connection.CreateCommand())
        {
            cmd.Transaction = (SqliteTransaction)transaction;
            cmd.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
            cmd.Parameters.AddWithValue("@name", instanceName);
            await cmd.ExecuteNonQueryAsync();
        }

        await using (var cmd = connection.CreateCommand())
        {
            cmd.Transaction = (SqliteTransaction)transaction;
            cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
            cmd.Parameters.AddWithValue("@name", instanceName);
            await cmd.ExecuteNonQueryAsync();
        }

        await using (var cmd = connection.CreateCommand())
        {
            cmd.Transaction = (SqliteTransaction)transaction;
            cmd.CommandText = "DELETE FROM deployed_configurations WHERE instance_unique_name = @name";
            cmd.Parameters.AddWithValue("@name", instanceName);
            await cmd.ExecuteNonQueryAsync();
        }

        await transaction.CommitAsync();
        _logger.LogInformation("Removed deployed config and overrides for {Instance}", instanceName);
    }

    /// <summary>
    /// Sets the enabled/disabled state of a deployed instance.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <param name="isEnabled">Whether the instance should be enabled.</param>
    /// <returns>A task that completes when the enabled flag has been updated.</returns>
    public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            UPDATE deployed_configurations
            SET is_enabled = @enabled
            WHERE instance_unique_name = @name";

        command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
        command.Parameters.AddWithValue("@name", instanceName);

        var rows = await command.ExecuteNonQueryAsync();
        if (rows == 0)
        {
            _logger.LogWarning("SetInstanceEnabled: instance {Instance} not found", instanceName);
        }
    }

    // ── Static Attribute Override CRUD ──

    /// <summary>
    /// Returns all static attribute overrides for an instance.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <returns>A task that resolves to a dictionary mapping attribute names to their override values.</returns>
    public async Task<Dictionary<string, string>> GetStaticOverridesAsync(string instanceName)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            SELECT attribute_name, override_value
            FROM static_attribute_overrides
            WHERE instance_unique_name = @name";
        command.Parameters.AddWithValue("@name", instanceName);

        var results = new Dictionary<string, string>();
        await using var reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            results[reader.GetString(0)] = reader.GetString(1);
        }

        return results;
    }

    /// <summary>
    /// Sets or updates a single static attribute override for an instance.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <param name="attributeName">The name of the attribute to override.</param>
    /// <param name="value">The override value for the attribute.</param>
    /// <returns>A task that completes when the override has been saved.</returns>
    public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
            VALUES (@name, @attr, @val, @updatedAt)
            ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
                override_value = excluded.override_value,
                updated_at     = excluded.updated_at";

        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@attr", attributeName);
        command.Parameters.AddWithValue("@val", value);
        command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
    }

    /// <summary>
    /// Batch counterpart of <see cref="SetStaticOverrideAsync"/> (perf remediation,
    /// arch-review WP1.5): upserts a set of static attribute overrides for one instance
    /// in a SINGLE connection + transaction with one prepared command re-bound per row.
    /// Used by the InstanceActor's coalesced flush — mirrors NativeAlarmActor's
    /// MarkDirtyUpsert/FlushDirtyUpserts (P4) pattern — so a burst of rapid SetAttribute
    /// calls on an instance collapses into one batched write instead of a write per call.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <param name="overrides">The attribute name/value pairs to upsert (latest value per name).</param>
    /// <returns>A task that completes when all rows have been committed.</returns>
    public async Task SetStaticOverridesAsync(
        string instanceName, IReadOnlyList<(string AttributeName, string Value)> overrides)
    {
        if (overrides.Count == 0)
            return;

        await using var connection = OpenConnection();
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();

        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = @"
            INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
            VALUES (@name, @attr, @val, @updatedAt)
            ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
                override_value = excluded.override_value,
                updated_at     = excluded.updated_at";

        var pName = command.Parameters.Add("@name", SqliteType.Text);
        var pAttr = command.Parameters.Add("@attr", SqliteType.Text);
        var pVal = command.Parameters.Add("@val", SqliteType.Text);
        var pUpdatedAt = command.Parameters.Add("@updatedAt", SqliteType.Text);
        pName.Value = instanceName;

        var updatedAt = DateTimeOffset.UtcNow.ToString("O");
        foreach (var (attributeName, value) in overrides)
        {
            pAttr.Value = attributeName;
            pVal.Value = value;
            pUpdatedAt.Value = updatedAt;
            await command.ExecuteNonQueryAsync();
        }

        await transaction.CommitAsync();
    }

    /// <summary>
    /// Clears all static attribute overrides for an instance.
    /// Called on redeployment to reset overrides.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance.</param>
    /// <returns>A task that completes when all overrides for the instance have been deleted.</returns>
    public async Task ClearStaticOverridesAsync(string instanceName)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
        command.Parameters.AddWithValue("@name", instanceName);

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
    }

    // ── Native Alarm State store (read-only mirror of source A&C conditions) ──

    /// <summary>
    /// Inserts or updates a single mirrored native alarm condition, keyed by
    /// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
    /// </summary>
    /// <param name="instanceName">Unique name of the instance owning the alarm.</param>
    /// <param name="sourceCanonicalName">Canonical name of the source binding (connection/alarm source).</param>
    /// <param name="sourceReference">Source-system reference key identifying the specific alarm condition.</param>
    /// <param name="conditionJson">Serialized <see cref="AlarmConditionState"/> JSON snapshot.</param>
    /// <param name="lastTransitionAt">Timestamp of the most recent condition transition.</param>
    /// <param name="metadataJson">Optional serialized metadata JSON for the alarm condition; <c>null</c> when not supplied.</param>
    /// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
    public async Task UpsertNativeAlarmAsync(
        string instanceName, string sourceCanonicalName, string sourceReference,
        string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO native_alarm_state
                (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
            VALUES (@name, @source, @ref, @json, @at, @meta)
            ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
                condition_json     = excluded.condition_json,
                last_transition_at = excluded.last_transition_at,
                metadata_json      = excluded.metadata_json";

        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@source", sourceCanonicalName);
        command.Parameters.AddWithValue("@ref", sourceReference);
        command.Parameters.AddWithValue("@json", conditionJson);
        command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
        command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);

        await command.ExecuteNonQueryAsync();
    }

    /// <summary>
    /// Batch counterpart of <see cref="UpsertNativeAlarmAsync"/> (P4): upserts a set of mirrored
    /// native alarm conditions for one instance/source in a SINGLE connection + transaction with
    /// one prepared command re-bound per row. Used by the NativeAlarmActor's coalesced flush so an
    /// alarm storm collapses into one batched write instead of a write per transition.
    /// </summary>
    /// <param name="instanceName">Unique name of the instance owning the alarms.</param>
    /// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
    /// <param name="rows">The conditions to upsert (source reference, serialized condition, transition time).</param>
    /// <returns>A task that completes when all rows have been committed.</returns>
    public async Task UpsertNativeAlarmsAsync(
        string instanceName, string sourceCanonicalName,
        IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows)
    {
        if (rows.Count == 0)
            return;

        await using var connection = OpenConnection();
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();

        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = @"
            INSERT INTO native_alarm_state
                (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
            VALUES (@name, @source, @ref, @json, @at, @meta)
            ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
                condition_json     = excluded.condition_json,
                last_transition_at = excluded.last_transition_at,
                metadata_json      = excluded.metadata_json";

        var pName = command.Parameters.Add("@name", SqliteType.Text);
        var pSource = command.Parameters.Add("@source", SqliteType.Text);
        var pRef = command.Parameters.Add("@ref", SqliteType.Text);
        var pJson = command.Parameters.Add("@json", SqliteType.Text);
        var pAt = command.Parameters.Add("@at", SqliteType.Text);
        var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
        pName.Value = instanceName;
        pSource.Value = sourceCanonicalName;

        foreach (var row in rows)
        {
            pRef.Value = row.SourceReference;
            pJson.Value = row.ConditionJson;
            pAt.Value = row.LastTransitionAt.ToString("O");
            pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
            await command.ExecuteNonQueryAsync();
        }

        await transaction.CommitAsync();
    }

    /// <summary>
    /// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
    /// </summary>
    /// <param name="instanceName">Unique name of the instance owning the alarm.</param>
    /// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
    /// <param name="sourceReference">Source-system reference key identifying the alarm condition to remove.</param>
    /// <returns>A task that completes when the alarm condition row has been deleted.</returns>
    public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            DELETE FROM native_alarm_state
            WHERE instance_unique_name = @name
              AND source_canonical_name = @source
              AND source_reference = @ref";
        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@source", sourceCanonicalName);
        command.Parameters.AddWithValue("@ref", sourceReference);

        await command.ExecuteNonQueryAsync();
    }

    /// <summary>
    /// Returns all mirrored native alarm conditions for an instance's source binding,
    /// used to rehydrate a NativeAlarmActor on (re)start.
    /// </summary>
    /// <param name="instanceName">Unique name of the instance to query.</param>
    /// <param name="sourceCanonicalName">Canonical name of the source binding to query.</param>
    /// <returns>A task that resolves to the list of stored native alarm condition rows for the binding.</returns>
    public async Task<IReadOnlyList<NativeAlarmRow>> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            SELECT source_reference, condition_json, last_transition_at, metadata_json
            FROM native_alarm_state
            WHERE instance_unique_name = @name AND source_canonical_name = @source";
        command.Parameters.AddWithValue("@name", instanceName);
        command.Parameters.AddWithValue("@source", sourceCanonicalName);

        var results = new List<NativeAlarmRow>();
        await using var reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            results.Add(new NativeAlarmRow(
                reader.GetString(0),
                reader.GetString(1),
                DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind),
                reader.IsDBNull(3) ? null : reader.GetString(3)));
        }

        return results;
    }

    /// <summary>
    /// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
    /// </summary>
    /// <param name="instanceName">Unique name of the instance whose native alarm state should be cleared.</param>
    /// <returns>A task that completes when all native alarm rows for the instance have been deleted.</returns>
    public async Task ClearNativeAlarmsForInstanceAsync(string instanceName)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
        command.Parameters.AddWithValue("@name", instanceName);

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
    }

    // ── Shared Script CRUD ──

    /// <summary>
    /// Stores or updates a shared script. Uses UPSERT semantics.
    /// </summary>
    /// <param name="name">The name of the shared script.</param>
    /// <param name="code">The script code.</param>
    /// <param name="parameterDefs">JSON representation of parameter definitions, if any.</param>
    /// <param name="returnDef">JSON representation of the return type definition, if any.</param>
    /// <returns>A task that completes when the shared script has been stored or updated.</returns>
    public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at)
            VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt)
            ON CONFLICT(name) DO UPDATE SET
                code = excluded.code,
                parameter_definitions = excluded.parameter_definitions,
                return_definition = excluded.return_definition,
                updated_at = excluded.updated_at";

        command.Parameters.AddWithValue("@name", name);
        command.Parameters.AddWithValue("@code", code);
        command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value);
        command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value);
        command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("Stored shared script '{Name}'", name);
    }

    /// <summary>
    /// Returns all stored shared scripts.
    /// </summary>
    /// <returns>A task that resolves to the list of all stored shared scripts.</returns>
    public async Task<List<StoredSharedScript>> GetAllSharedScriptsAsync()
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts";

        var results = new List<StoredSharedScript>();
        await using var reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            results.Add(new StoredSharedScript
            {
                Name = reader.GetString(0),
                Code = reader.GetString(1),
                ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2),
                ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3)
            });
        }

        return results;
    }

    // ── External System CRUD ──

    /// <summary>
    /// Stores or updates an external system definition.
    /// </summary>
    /// <param name="name">The name of the external system.</param>
    /// <param name="endpointUrl">The REST API endpoint URL.</param>
    /// <param name="authType">The authentication type (e.g., 'ApiKey', 'BasicAuth').</param>
    /// <param name="authConfig">Authentication configuration JSON, if applicable.</param>
    /// <param name="methodDefs">JSON representation of available method definitions, if any.</param>
    /// <param name="timeoutSeconds">Per-system call timeout in seconds (0 = use the site default).</param>
    /// <returns>A task that completes when the external system definition has been stored or updated.</returns>
    public async Task StoreExternalSystemAsync(
        string name, string endpointUrl, string authType, string? authConfig, string? methodDefs,
        int timeoutSeconds = 0)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, timeout_seconds, updated_at)
            VALUES (@name, @url, @authType, @authConfig, @methodDefs, @timeoutSeconds, @updatedAt)
            ON CONFLICT(name) DO UPDATE SET
                endpoint_url = excluded.endpoint_url,
                auth_type = excluded.auth_type,
                auth_configuration = excluded.auth_configuration,
                method_definitions = excluded.method_definitions,
                timeout_seconds = excluded.timeout_seconds,
                updated_at = excluded.updated_at";

        command.Parameters.AddWithValue("@name", name);
        command.Parameters.AddWithValue("@url", endpointUrl);
        command.Parameters.AddWithValue("@authType", authType);
        command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value);
        command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value);
        command.Parameters.AddWithValue("@timeoutSeconds", timeoutSeconds);
        command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
    }

    // ── Database Connection CRUD ──

    /// <summary>
    /// Stores or updates a database connection definition.
    /// </summary>
    /// <param name="name">The name of the database connection.</param>
    /// <param name="connectionString">The database connection string.</param>
    /// <param name="maxRetries">Maximum number of retry attempts.</param>
    /// <param name="retryDelay">Delay between retry attempts.</param>
    /// <returns>A task that completes when the database connection definition has been stored or updated.</returns>
    public async Task StoreDatabaseConnectionAsync(
        string name, string connectionString, int maxRetries, TimeSpan retryDelay)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at)
            VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt)
            ON CONFLICT(name) DO UPDATE SET
                connection_string = excluded.connection_string,
                max_retries = excluded.max_retries,
                retry_delay_ms = excluded.retry_delay_ms,
                updated_at = excluded.updated_at";

        command.Parameters.AddWithValue("@name", name);
        command.Parameters.AddWithValue("@connStr", connectionString);
        command.Parameters.AddWithValue("@maxRetries", maxRetries);
        command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
        command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
    }

    // ── Notification List CRUD ──

    /// <summary>
    /// Notification delivery is central-only.
    /// Sites store-and-forward notifications to the central cluster and never deliver
    /// over SMTP, so notification lists and SMTP configuration must never live on a
    /// site. This purges every row from the site-local <c>notification_lists</c> and
    /// <c>smtp_configurations</c> tables, clearing any rows a prior (now-corrected)
    /// build may have shipped — most importantly the plaintext SMTP password. It is
    /// idempotent and is invoked on every artifact apply / deploy so existing exposure
    /// is cleared, not just future writes. The tables themselves are retained (the
    /// schema is harmless once empty); only their contents are removed.
    ///
    /// The site-side write paths (<c>StoreNotificationListAsync</c>/<c>StoreSmtpConfigurationAsync</c>)
    /// and the <c>SiteNotificationRepository</c> were removed 2026-07-10 (arch-review 08 §1.3)
    /// because notification config is central-only and must never be written to a site — this
    /// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
    /// do not reintroduce site-side notification writes.
    /// </summary>
    /// <returns>A task that completes when both tables have been emptied.</returns>
    public async Task PurgeCentralOnlyNotificationConfigAsync()
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            DELETE FROM notification_lists;
            DELETE FROM smtp_configurations;";
        await command.ExecuteNonQueryAsync();
    }

    // ── Data Connection Definition CRUD ──

    /// <summary>
    /// Stores or updates a data connection definition (OPC UA endpoint, etc.).
    /// </summary>
    /// <param name="name">The name of the data connection.</param>
    /// <param name="protocol">The protocol type (e.g., 'OpcUa').</param>
    /// <param name="configJson">Primary configuration as JSON.</param>
    /// <param name="backupConfigJson">Backup configuration as JSON, if applicable.</param>
    /// <param name="failoverRetryCount">Number of retries for failover attempts.</param>
    /// <returns>A task that completes when the data connection definition has been stored or updated.</returns>
    public async Task StoreDataConnectionDefinitionAsync(
        string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3)
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = @"
            INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at)
            VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt)
            ON CONFLICT(name) DO UPDATE SET
                protocol = excluded.protocol,
                configuration = excluded.configuration,
                backup_configuration = excluded.backup_configuration,
                failover_retry_count = excluded.failover_retry_count,
                updated_at = excluded.updated_at";

        command.Parameters.AddWithValue("@name", name);
        command.Parameters.AddWithValue("@protocol", protocol);
        command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value);
        command.Parameters.AddWithValue("@backupConfig", (object?)backupConfigJson ?? DBNull.Value);
        command.Parameters.AddWithValue("@failoverRetryCount", failoverRetryCount);
        command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync();
        _logger.LogDebug("Stored data connection definition '{Name}' (protocol={Protocol})", name, protocol);
    }

    /// <summary>
    /// Returns all stored data connection definitions.
    /// </summary>
    /// <returns>A task that resolves to the list of all stored data connection definitions.</returns>
    public async Task<List<StoredDataConnectionDefinition>> GetAllDataConnectionDefinitionsAsync()
    {
        await using var connection = OpenConnection();

        await using var command = connection.CreateCommand();
        command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions";

        var results = new List<StoredDataConnectionDefinition>();
        await using var reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            results.Add(new StoredDataConnectionDefinition
            {
                Name = reader.GetString(0),
                Protocol = reader.GetString(1),
                ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2),
                BackupConfigurationJson = reader.IsDBNull(3) ? null : reader.GetString(3),
                FailoverRetryCount = reader.GetInt32(4)
            });
        }

        return results;
    }

    // ── Artifact set reconciliation ──

    /// <summary>
    /// Deletes shared scripts absent from the supplied full set and returns the deleted names.
    /// </summary>
    /// <param name="keepNames">The complete set of shared script names that should exist.</param>
    /// <returns>A task that resolves to the names of the rows that were deleted.</returns>
    public Task<List<string>> DeleteSharedScriptsExceptAsync(IReadOnlyCollection<string> keepNames)
        => DeleteRowsExceptAsync("shared_scripts", keepNames);

    /// <summary>
    /// Deletes external system definitions absent from the supplied full set and returns the deleted names.
    /// </summary>
    /// <param name="keepNames">The complete set of external system names that should exist.</param>
    /// <returns>A task that resolves to the names of the rows that were deleted.</returns>
    public Task<List<string>> DeleteExternalSystemsExceptAsync(IReadOnlyCollection<string> keepNames)
        => DeleteRowsExceptAsync("external_systems", keepNames);

    /// <summary>
    /// Deletes database connection definitions absent from the supplied full set and returns the deleted names.
    /// </summary>
    /// <param name="keepNames">The complete set of database connection names that should exist.</param>
    /// <returns>A task that resolves to the names of the rows that were deleted.</returns>
    public Task<List<string>> DeleteDatabaseConnectionsExceptAsync(IReadOnlyCollection<string> keepNames)
        => DeleteRowsExceptAsync("database_connections", keepNames);

    /// <summary>
    /// Deletes data connection definitions absent from the supplied full set and returns the deleted names.
    /// </summary>
    /// <param name="keepNames">The complete set of data connection names that should exist.</param>
    /// <returns>A task that resolves to the names of the rows that were deleted.</returns>
    public Task<List<string>> DeleteDataConnectionDefinitionsExceptAsync(IReadOnlyCollection<string> keepNames)
        => DeleteRowsExceptAsync("data_connection_definitions", keepNames);

    /// <summary>
    /// Deletes every row of an artifact table whose <c>name</c> is not in
    /// <paramref name="keepNames"/>, returning the deleted names.
    ///
    /// Central always ships the COMPLETE system-wide set of each artifact class on an
    /// artifact deployment, so a stored row absent from the incoming set means the
    /// artifact was deleted centrally. The store methods are upsert-only, which used to
    /// leave such rows orphaned on the site forever (a centrally-deleted external
    /// system stayed callable from site scripts indefinitely); the artifact apply now
    /// reconciles by calling this after upserting. An empty <paramref name="keepNames"/>
    /// legitimately deletes every row — central saying "none of these exist anymore".
    /// </summary>
    private async Task<List<string>> DeleteRowsExceptAsync(string table, IReadOnlyCollection<string> keepNames)
    {
        await using var connection = OpenConnection();
        await using var transaction = connection.BeginTransaction();

        var keepParams = keepNames.Select((name, i) => (Key: $"@k{i}", Value: name)).ToList();
        var predicate = keepParams.Count == 0
            ? string.Empty
            : $" WHERE name NOT IN ({string.Join(", ", keepParams.Select(p => p.Key))})";

        var removed = new List<string>();
        await using (var select = connection.CreateCommand())
        {
            select.Transaction = transaction;
            select.CommandText = $"SELECT name FROM {table}{predicate}";
            foreach (var (key, value) in keepParams)
                select.Parameters.AddWithValue(key, value);

            await using var reader = await select.ExecuteReaderAsync();
            while (await reader.ReadAsync())
                removed.Add(reader.GetString(0));
        }

        if (removed.Count > 0)
        {
            await using var delete = connection.CreateCommand();
            delete.Transaction = transaction;
            delete.CommandText = $"DELETE FROM {table}{predicate}";
            foreach (var (key, value) in keepParams)
                delete.Parameters.AddWithValue(key, value);

            await delete.ExecuteNonQueryAsync();
            _logger.LogInformation(
                "Artifact reconcile removed {Count} orphaned row(s) from {Table}: {Names}",
                removed.Count, table, string.Join(", ", removed));
        }

        transaction.Commit();
        return removed;
    }
}

/// <summary>
/// Represents a deployed instance configuration as stored in SQLite.
/// </summary>
public class DeployedInstance
{
    /// <summary>
    /// The unique name of the instance.
    /// </summary>
    public string InstanceUniqueName { get; init; } = string.Empty;
    /// <summary>
    /// The deployed configuration as JSON.
    /// </summary>
    public string ConfigJson { get; init; } = string.Empty;
    /// <summary>
    /// The unique deployment identifier.
    /// </summary>
    public string DeploymentId { get; init; } = string.Empty;
    /// <summary>
    /// The configuration revision hash for staleness detection.
    /// </summary>
    public string RevisionHash { get; init; } = string.Empty;
    /// <summary>
    /// Whether the instance is enabled.
    /// </summary>
    public bool IsEnabled { get; init; }
    /// <summary>
    /// The timestamp when the configuration was deployed.
    /// </summary>
    public string DeployedAt { get; init; } = string.Empty;
}

/// <summary>
/// Represents a shared script stored locally in SQLite.
/// </summary>
public class StoredSharedScript
{
    /// <summary>
    /// The name of the shared script.
    /// </summary>
    public string Name { get; init; } = string.Empty;
    /// <summary>
    /// The script code.
    /// </summary>
    public string Code { get; init; } = string.Empty;
    /// <summary>
    /// JSON representation of parameter definitions, if any.
    /// </summary>
    public string? ParameterDefinitions { get; init; }
    /// <summary>
    /// JSON representation of the return type definition, if any.
    /// </summary>
    public string? ReturnDefinition { get; init; }
}

/// <summary>
/// Represents a data connection definition stored locally in SQLite.
/// </summary>
public class StoredDataConnectionDefinition
{
    /// <summary>
    /// The name of the data connection.
    /// </summary>
    public string Name { get; init; } = string.Empty;
    /// <summary>
    /// The protocol type (e.g., 'OpcUa').
    /// </summary>
    public string Protocol { get; init; } = string.Empty;
    /// <summary>
    /// Primary configuration as JSON.
    /// </summary>
    public string? ConfigurationJson { get; init; }
    /// <summary>
    /// Backup configuration as JSON, if applicable.
    /// </summary>
    public string? BackupConfigurationJson { get; init; }
    /// <summary>
    /// Number of retries for failover attempts.
    /// </summary>
    public int FailoverRetryCount { get; init; } = 3;
}

/// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
/// <paramref name="MetadataJson"/> carries the serialized display metadata (type/category/message/
/// values) restored on rehydration (UA4); null on rows written before the column existed.
/// </summary>
public record NativeAlarmRow(
    string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);
