using System.Text.Json; using Microsoft.Data.Sqlite; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.SiteRuntime.Persistence; namespace ScadaLink.SiteRuntime.Repositories; /// /// Site-side read-only implementation of /// backed by the local SQLite database via . /// Write operations throw because site-local /// artifacts are managed exclusively through deployment from Central. /// public class SiteExternalSystemRepository : IExternalSystemRepository { private readonly SiteStorageService _storage; public SiteExternalSystemRepository(SiteStorageService storage) { _storage = storage ?? throw new ArgumentNullException(nameof(storage)); } // ── ExternalSystemDefinition (read) ── public async Task> GetAllExternalSystemsAsync( CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" SELECT name, endpoint_url, auth_type, auth_configuration FROM external_systems"; var results = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { results.Add(MapExternalSystem(reader)); } return results; } public async Task GetExternalSystemByIdAsync( int id, CancellationToken cancellationToken = default) { // The SQLite table is keyed by name, not by integer ID. // Scan all rows and match on the synthetic ID derived from the name. var all = await GetAllExternalSystemsAsync(cancellationToken); return all.FirstOrDefault(e => e.Id == id); } // ── ExternalSystemMethod (read) ── public async Task> GetMethodsByExternalSystemIdAsync( int externalSystemId, CancellationToken cancellationToken = default) { // Find the parent system to get its name, then parse its method_definitions JSON. var system = await GetExternalSystemByIdAsync(externalSystemId, cancellationToken); if (system is null) return Array.Empty(); await using var connection = CreateConnection(); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" SELECT method_definitions FROM external_systems WHERE name = @name"; command.Parameters.AddWithValue("@name", system.Name); var json = (string?)await command.ExecuteScalarAsync(cancellationToken); if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); return ParseMethodDefinitions(json, externalSystemId); } public async Task GetExternalSystemMethodByIdAsync( int id, CancellationToken cancellationToken = default) { // Scan all systems and their methods to find the matching synthetic ID. var systems = await GetAllExternalSystemsAsync(cancellationToken); foreach (var system in systems) { var methods = await GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken); var match = methods.FirstOrDefault(m => m.Id == id); if (match is not null) return match; } return null; } // ── DatabaseConnectionDefinition (read) ── public async Task> GetAllDatabaseConnectionsAsync( CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" SELECT name, connection_string, max_retries, retry_delay_ms FROM database_connections"; var results = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var def = new DatabaseConnectionDefinition( name: reader.GetString(0), connectionString: reader.GetString(1)) { Id = GenerateSyntheticId(reader.GetString(0)), MaxRetries = reader.GetInt32(2), RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3)) }; results.Add(def); } return results; } public async Task GetDatabaseConnectionByIdAsync( int id, CancellationToken cancellationToken = default) { var all = await GetAllDatabaseConnectionsAsync(cancellationToken); return all.FirstOrDefault(d => d.Id == id); } // ── Write operations (not supported on site) ── public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); // ── Private helpers ── /// /// Creates a new SQLite connection against the site database via /// (SiteRuntime-006). The /// connection string is owned by ; the repository /// no longer reaches into its private state via reflection. /// private SqliteConnection CreateConnection() => _storage.CreateConnection(); private static ExternalSystemDefinition MapExternalSystem(SqliteDataReader reader) { var name = reader.GetString(0); return new ExternalSystemDefinition( name: name, endpointUrl: reader.GetString(1), authType: reader.GetString(2)) { Id = GenerateSyntheticId(name), AuthConfiguration = reader.IsDBNull(3) ? null : reader.GetString(3) }; } private static IReadOnlyList ParseMethodDefinitions( string json, int externalSystemId) { try { var methods = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (methods is null) return Array.Empty(); return methods.Select(m => new ExternalSystemMethod( name: m.Name ?? string.Empty, httpMethod: m.HttpMethod ?? "GET", path: m.Path ?? string.Empty) { Id = GenerateSyntheticId($"{externalSystemId}:{m.Name}"), ExternalSystemDefinitionId = externalSystemId, ParameterDefinitions = m.ParameterDefinitions, ReturnDefinition = m.ReturnDefinition }).ToList(); } catch (JsonException) { return Array.Empty(); } } /// /// Generates a stable positive integer ID from a string name (SiteRuntime-007). /// Uses a deterministic FNV-1a hash rather than , /// which is randomized per process on .NET Core and would therefore change every /// time the process restarts — breaking any caller that stored an ID and later /// looks the entity up by that ID. /// private static int GenerateSyntheticId(string name) => SyntheticId.From(name); /// /// DTO for deserializing individual method entries from the method_definitions JSON column. /// private sealed class MethodDefinitionDto { public string? Name { get; set; } public string? HttpMethod { get; set; } public string? Path { get; set; } public string? ParameterDefinitions { get; set; } public string? ReturnDefinition { get; set; } } }