# LmxFakeProxy Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Build a .NET 10 gRPC server (`infra/lmxfakeproxy/`) that implements the `scada.ScadaService` proto and bridges to the OPC UA test server, enabling end-to-end testing of `RealLmxProxyClient`.

**Architecture:** A standalone ASP.NET Core gRPC server with three core components: `SessionManager` (ConcurrentDictionary-based session tracking), `OpcUaBridge` (shared OPC UA session with reconnection), and `ScadaServiceImpl` (gRPC service mapping proto RPCs to the bridge). The server runs on port 50051, maps LMX-style tag addresses to OPC UA NodeIds via a configurable prefix, and optionally enforces API key auth via a gRPC interceptor.

**Tech Stack:** .NET 10, Grpc.AspNetCore, OPCFoundation.NetStandard.Opc.Ua.Client, xunit, NSubstitute

**Design doc:** `docs/plans/2026-03-19-lmxfakeproxy-design.md`

---

### Task 1: Project Scaffolding

**Files:**
- Create: `infra/lmxfakeproxy/LmxFakeProxy.csproj`
- Create: `infra/lmxfakeproxy/Program.cs` (minimal, just enough to build)
- Create: `infra/lmxfakeproxy/Protos/scada.proto` (copy from DCL)
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj`

**Step 1: Create the project directory structure**

```bash
mkdir -p infra/lmxfakeproxy/Services infra/lmxfakeproxy/Bridge infra/lmxfakeproxy/Sessions infra/lmxfakeproxy/Protos
mkdir -p infra/lmxfakeproxy/tests/LmxFakeProxy.Tests
```

**Step 2: Create LmxFakeProxy.csproj**

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>LmxFakeProxy</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <Protobuf Include="Protos/scada.proto" GrpcServices="Server" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
    <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126" />
  </ItemGroup>
</Project>
```

**Step 3: Copy scada.proto and change the namespace**

Copy `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` to `infra/lmxfakeproxy/Protos/scada.proto`. Change the `csharp_namespace` option to:

```protobuf
option csharp_namespace = "LmxFakeProxy.Grpc";
```

Everything else in the proto stays the same — same package, same service, same messages.

**Step 4: Create minimal Program.cs**

```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGet("/", () => "LmxFakeProxy is running");
app.Run();
```

**Step 5: Create test project csproj**

```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>LmxFakeProxy.Tests</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
    <PackageReference Include="NSubstitute" Version="5.3.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../../LmxFakeProxy.csproj" />
  </ItemGroup>
</Project>
```

**Step 6: Verify build**

```bash
cd infra/lmxfakeproxy && dotnet build
cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/
```

Expected: Both build with 0 errors. The proto generates server-side stubs in `LmxFakeProxy.Grpc` namespace.

**Step 7: Commit**

```bash
git add infra/lmxfakeproxy/
git commit -m "feat(infra): scaffold LmxFakeProxy project with proto and test project"
```

---

### Task 2: TagMapper Utility + Tests

**Files:**
- Create: `infra/lmxfakeproxy/TagMapper.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs`

**Step 1: Write the failing tests**

Create `tests/LmxFakeProxy.Tests/TagMappingTests.cs`:

```csharp
namespace LmxFakeProxy.Tests;

public class TagMappingTests
{
    [Fact]
    public void ToOpcNodeId_PrependsPrefix()
    {
        var mapper = new TagMapper("ns=3;s=");
        Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
    }

    [Fact]
    public void ToOpcNodeId_CustomPrefix()
    {
        var mapper = new TagMapper("ns=2;s=MyFolder.");
        Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure"));
    }

    [Fact]
    public void ToOpcNodeId_EmptyPrefix_PassesThrough()
    {
        var mapper = new TagMapper("");
        Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
    }

    [Fact]
    public void ParseWriteValue_Double()
    {
        Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5"));
        Assert.IsType<double>(TagMapper.ParseWriteValue("42.5"));
    }

    [Fact]
    public void ParseWriteValue_Bool()
    {
        Assert.Equal(true, TagMapper.ParseWriteValue("true"));
        Assert.Equal(false, TagMapper.ParseWriteValue("False"));
    }

    [Fact]
    public void ParseWriteValue_Uint()
    {
        // "100" parses as double first (double.TryParse succeeds for integers)
        // So uint only hits for values that look like uint but not double — not realistic.
        // Actually, double.TryParse("100") succeeds, so this returns 100.0 (double).
        // That's fine — OPC UA accepts double writes to UInt32 nodes.
        var result = TagMapper.ParseWriteValue("100");
        Assert.IsType<double>(result);
    }

    [Fact]
    public void ParseWriteValue_FallsBackToString()
    {
        Assert.Equal("hello", TagMapper.ParseWriteValue("hello"));
        Assert.IsType<string>(TagMapper.ParseWriteValue("hello"));
    }

    [Fact]
    public void MapStatusCode_Good()
    {
        Assert.Equal("Good", TagMapper.MapQuality(0));
    }

    [Fact]
    public void MapStatusCode_Bad()
    {
        Assert.Equal("Bad", TagMapper.MapQuality(0x80000000));
    }

    [Fact]
    public void MapStatusCode_Uncertain()
    {
        Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000));
    }

    [Fact]
    public void ToVtqMessage_ConvertsCorrectly()
    {
        var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0);
        Assert.Equal("Motor.Speed", vtq.Tag);
        Assert.Equal("42.5", vtq.Value);
        Assert.Equal("Good", vtq.Quality);
        Assert.True(vtq.TimestampUtcTicks > 0);
    }
}
```

**Step 2: Run tests to verify they fail**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: FAIL — `TagMapper` class does not exist.

**Step 3: Implement TagMapper**

Create `infra/lmxfakeproxy/TagMapper.cs`:

```csharp
using LmxFakeProxy.Grpc;

namespace LmxFakeProxy;

public class TagMapper
{
    private readonly string _prefix;

    public TagMapper(string prefix)
    {
        _prefix = prefix;
    }

    public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}";

    public static object ParseWriteValue(string value)
    {
        if (double.TryParse(value, System.Globalization.NumberStyles.Float,
            System.Globalization.CultureInfo.InvariantCulture, out var d))
            return d;
        if (bool.TryParse(value, out var b))
            return b;
        return value;
    }

    public static string MapQuality(uint statusCode)
    {
        if (statusCode == 0) return "Good";
        if ((statusCode & 0x80000000) != 0) return "Bad";
        return "Uncertain";
    }

    public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode)
    {
        return new VtqMessage
        {
            Tag = tag,
            Value = value?.ToString() ?? string.Empty,
            TimestampUtcTicks = timestampUtc.Ticks,
            Quality = MapQuality(statusCode)
        };
    }
}
```

**Step 4: Run tests to verify they pass**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: All 11 tests PASS.

**Step 5: Commit**

```bash
git add infra/lmxfakeproxy/TagMapper.cs infra/lmxfakeproxy/tests/
git commit -m "feat(infra): add TagMapper with address mapping, value parsing, and quality mapping"
```

---

### Task 3: SessionManager + Tests

**Files:**
- Create: `infra/lmxfakeproxy/Sessions/SessionManager.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs`

**Step 1: Write the failing tests**

Create `tests/LmxFakeProxy.Tests/SessionManagerTests.cs`:

```csharp
namespace LmxFakeProxy.Tests;

using LmxFakeProxy.Sessions;

public class SessionManagerTests
{
    [Fact]
    public void Connect_ReturnsUniqueSessionId()
    {
        var mgr = new SessionManager(null);
        var (ok1, _, id1) = mgr.Connect("client1", "");
        var (ok2, _, id2) = mgr.Connect("client2", "");
        Assert.True(ok1);
        Assert.True(ok2);
        Assert.NotEqual(id1, id2);
    }

    [Fact]
    public void Connect_WithValidApiKey_Succeeds()
    {
        var mgr = new SessionManager("secret");
        var (ok, _, _) = mgr.Connect("client1", "secret");
        Assert.True(ok);
    }

    [Fact]
    public void Connect_WithInvalidApiKey_Fails()
    {
        var mgr = new SessionManager("secret");
        var (ok, msg, id) = mgr.Connect("client1", "wrong");
        Assert.False(ok);
        Assert.Empty(id);
        Assert.Contains("Invalid API key", msg);
    }

    [Fact]
    public void Connect_WithNoKeyConfigured_AcceptsAnyKey()
    {
        var mgr = new SessionManager(null);
        var (ok1, _, _) = mgr.Connect("c1", "anykey");
        var (ok2, _, _) = mgr.Connect("c2", "");
        Assert.True(ok1);
        Assert.True(ok2);
    }

    [Fact]
    public void Disconnect_RemovesSession()
    {
        var mgr = new SessionManager(null);
        var (_, _, id) = mgr.Connect("client1", "");
        Assert.True(mgr.ValidateSession(id));
        var ok = mgr.Disconnect(id);
        Assert.True(ok);
        Assert.False(mgr.ValidateSession(id));
    }

    [Fact]
    public void Disconnect_UnknownSession_ReturnsFalse()
    {
        var mgr = new SessionManager(null);
        Assert.False(mgr.Disconnect("nonexistent"));
    }

    [Fact]
    public void ValidateSession_ValidId_ReturnsTrue()
    {
        var mgr = new SessionManager(null);
        var (_, _, id) = mgr.Connect("client1", "");
        Assert.True(mgr.ValidateSession(id));
    }

    [Fact]
    public void ValidateSession_InvalidId_ReturnsFalse()
    {
        var mgr = new SessionManager(null);
        Assert.False(mgr.ValidateSession("bogus"));
    }

    [Fact]
    public void GetConnectionState_ReturnsCorrectInfo()
    {
        var mgr = new SessionManager(null);
        var (_, _, id) = mgr.Connect("myClient", "");
        var (found, clientId, ticks) = mgr.GetConnectionState(id);
        Assert.True(found);
        Assert.Equal("myClient", clientId);
        Assert.True(ticks > 0);
    }

    [Fact]
    public void GetConnectionState_UnknownSession_ReturnsNotConnected()
    {
        var mgr = new SessionManager(null);
        var (found, clientId, ticks) = mgr.GetConnectionState("unknown");
        Assert.False(found);
        Assert.Empty(clientId);
        Assert.Equal(0, ticks);
    }

    [Fact]
    public void CheckApiKey_NoKeyConfigured_AlwaysValid()
    {
        var mgr = new SessionManager(null);
        Assert.True(mgr.CheckApiKey("anything"));
        Assert.True(mgr.CheckApiKey(""));
    }

    [Fact]
    public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly()
    {
        var mgr = new SessionManager("mykey");
        Assert.True(mgr.CheckApiKey("mykey"));
        Assert.False(mgr.CheckApiKey("wrong"));
        Assert.False(mgr.CheckApiKey(""));
    }
}
```

**Step 2: Run tests to verify they fail**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: FAIL — `SessionManager` does not exist.

**Step 3: Implement SessionManager**

Create `infra/lmxfakeproxy/Sessions/SessionManager.cs`:

```csharp
using System.Collections.Concurrent;

namespace LmxFakeProxy.Sessions;

public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks);

public class SessionManager
{
    private readonly string? _requiredApiKey;
    private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();

    public SessionManager(string? requiredApiKey)
    {
        _requiredApiKey = requiredApiKey;
    }

    public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey)
    {
        if (!CheckApiKey(apiKey))
            return (false, "Invalid API key", string.Empty);

        var sessionId = Guid.NewGuid().ToString("N");
        var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks);
        _sessions[sessionId] = info;
        return (true, "Connected", sessionId);
    }

    public bool Disconnect(string sessionId)
    {
        return _sessions.TryRemove(sessionId, out _);
    }

    public bool ValidateSession(string sessionId)
    {
        return _sessions.ContainsKey(sessionId);
    }

    public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId)
    {
        if (_sessions.TryGetValue(sessionId, out var info))
            return (true, info.ClientId, info.ConnectedSinceUtcTicks);
        return (false, string.Empty, 0);
    }

    public bool CheckApiKey(string apiKey)
    {
        if (string.IsNullOrEmpty(_requiredApiKey))
            return true;
        return apiKey == _requiredApiKey;
    }
}
```

**Step 4: Run tests to verify they pass**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: All 23 tests PASS (11 TagMapping + 12 SessionManager).

**Step 5: Commit**

```bash
git add infra/lmxfakeproxy/Sessions/ infra/lmxfakeproxy/tests/
git commit -m "feat(infra): add SessionManager with full session tracking and API key validation"
```

---

### Task 4: IOpcUaBridge Interface + OpcUaBridge Implementation

**Files:**
- Create: `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`
- Create: `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`

**Step 1: Create the IOpcUaBridge interface**

Create `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`:

```csharp
namespace LmxFakeProxy.Bridge;

public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode);

public interface IOpcUaBridge : IAsyncDisposable
{
    bool IsConnected { get; }

    Task ConnectAsync(CancellationToken cancellationToken = default);

    Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default);

    Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default);

    /// <summary>
    /// Add monitored items to the shared OPC UA subscription.
    /// Returns a handle that can be used to remove them later.
    /// The callback receives (nodeId, value, timestamp, statusCode) on each data change.
    /// </summary>
    Task<string> AddMonitoredItemsAsync(
        IEnumerable<string> nodeIds,
        int samplingIntervalMs,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default);

    Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default);

    /// <summary>
    /// Raised when the OPC UA backend becomes unreachable.
    /// </summary>
    event Action? Disconnected;

    /// <summary>
    /// Raised when the OPC UA backend reconnects after a disconnection.
    /// </summary>
    event Action? Reconnected;
}
```

**Step 2: Implement OpcUaBridge**

Create `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`. This mirrors `RealOpcUaClient` from the main project but adds reconnection logic and multi-client monitored item management:

```csharp
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;

namespace LmxFakeProxy.Bridge;

public class OpcUaBridge : IOpcUaBridge
{
    private readonly string _endpointUrl;
    private readonly ILogger<OpcUaBridge> _logger;
    private ISession? _session;
    private Subscription? _subscription;
    private volatile bool _connected;
    private volatile bool _reconnecting;
    private CancellationTokenSource? _reconnectCts;

    // Track monitored items per handle (one handle per Subscribe call)
    private readonly Dictionary<string, List<MonitoredItem>> _handleItems = new();
    private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _handleCallbacks = new();
    private readonly object _lock = new();

    public OpcUaBridge(string endpointUrl, ILogger<OpcUaBridge> logger)
    {
        _endpointUrl = endpointUrl;
        _logger = logger;
    }

    public bool IsConnected => _connected;
    public event Action? Disconnected;
    public event Action? Reconnected;

    public async Task ConnectAsync(CancellationToken cancellationToken = default)
    {
        var appConfig = new ApplicationConfiguration
        {
            ApplicationName = "LmxFakeProxy",
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = new SecurityConfiguration
            {
                AutoAcceptUntrustedCertificates = true,
                ApplicationCertificate = new CertificateIdentifier(),
                TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") },
                TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") },
                RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") }
            },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
            TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
        };

        await appConfig.ValidateAsync(ApplicationType.Client);
        appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;

        EndpointDescription? endpoint;
        try
        {
#pragma warning disable CS0618
            using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl));
            var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
            endpoint = endpoints
                .Where(e => e.SecurityMode == MessageSecurityMode.None)
                .FirstOrDefault() ?? endpoints.FirstOrDefault();
        }
        catch
        {
            endpoint = new EndpointDescription(_endpointUrl);
        }

        var endpointConfig = EndpointConfiguration.Create(appConfig);
        var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);

#pragma warning disable CS0618
        var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
        _session = await sessionFactory.CreateAsync(
            appConfig, configuredEndpoint, false,
            "LmxFakeProxy-Session", 60000, null, null, cancellationToken);

        _session.KeepAlive += OnSessionKeepAlive;

        _subscription = new Subscription(_session.DefaultSubscription)
        {
            DisplayName = "LmxFakeProxy",
            PublishingEnabled = true,
            PublishingInterval = 500,
            KeepAliveCount = 10,
            LifetimeCount = 30,
            MaxNotificationsPerPublish = 1000
        };

        _session.AddSubscription(_subscription);
        await _subscription.CreateAsync(cancellationToken);

        _connected = true;
        _logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl);
    }

    public async Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value };
        var response = await _session!.ReadAsync(
            null, 0, TimestampsToReturn.Source,
            new ReadValueIdCollection { readValue }, cancellationToken);

        var result = response.Results[0];
        return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code);
    }

    public async Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        var writeValue = new WriteValue
        {
            NodeId = nodeId,
            AttributeId = Attributes.Value,
            Value = new DataValue(new Variant(value))
        };

        var response = await _session!.WriteAsync(
            null, new WriteValueCollection { writeValue }, cancellationToken);

        return response.Results[0].Code;
    }

    public async Task<string> AddMonitoredItemsAsync(
        IEnumerable<string> nodeIds,
        int samplingIntervalMs,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        var handle = Guid.NewGuid().ToString("N");
        var items = new List<MonitoredItem>();

        foreach (var nodeId in nodeIds)
        {
            var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
            {
                DisplayName = nodeId,
                StartNodeId = nodeId,
                AttributeId = Attributes.Value,
                SamplingInterval = samplingIntervalMs,
                QueueSize = 10,
                DiscardOldest = true
            };

            monitoredItem.Notification += (item, e) =>
            {
                if (e.NotificationValue is MonitoredItemNotification notification)
                {
                    var val = notification.Value?.Value;
                    var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
                    var sc = notification.Value?.StatusCode.Code ?? 0;
                    onValueChanged(nodeId, val, ts, sc);
                }
            };

            items.Add(monitoredItem);
            _subscription!.AddItem(monitoredItem);
        }

        await _subscription!.ApplyChangesAsync(cancellationToken);

        lock (_lock)
        {
            _handleItems[handle] = items;
            _handleCallbacks[handle] = onValueChanged;
        }

        return handle;
    }

    public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default)
    {
        List<MonitoredItem>? items;
        lock (_lock)
        {
            if (!_handleItems.Remove(handle, out items))
                return;
            _handleCallbacks.Remove(handle);
        }

        if (_subscription != null)
        {
            foreach (var item in items)
                _subscription.RemoveItem(item);

            try { await _subscription.ApplyChangesAsync(cancellationToken); }
            catch { /* best-effort during cleanup */ }
        }
    }

    private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
    {
        if (ServiceResult.IsBad(e.Status))
        {
            if (!_connected) return;
            _connected = false;
            _logger.LogWarning("OPC UA backend connection lost");
            Disconnected?.Invoke();
            StartReconnectLoop();
        }
    }

    private void StartReconnectLoop()
    {
        if (_reconnecting) return;
        _reconnecting = true;
        _reconnectCts = new CancellationTokenSource();

        _ = Task.Run(async () =>
        {
            while (!_reconnectCts.Token.IsCancellationRequested)
            {
                await Task.Delay(5000, _reconnectCts.Token);
                try
                {
                    _logger.LogInformation("Attempting OPC UA reconnection...");

                    // Clean up old session
                    if (_session != null)
                    {
                        _session.KeepAlive -= OnSessionKeepAlive;
                        try { await _session.CloseAsync(); } catch { }
                        _session = null;
                        _subscription = null;
                    }

                    await ConnectAsync(_reconnectCts.Token);

                    // Re-add monitored items for active handles
                    lock (_lock)
                    {
                        foreach (var (handle, callback) in _handleCallbacks)
                        {
                            if (_handleItems.TryGetValue(handle, out var oldItems))
                            {
                                var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList();
                                var newItems = new List<MonitoredItem>();

                                foreach (var nodeId in nodeIds)
                                {
                                    var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
                                    {
                                        DisplayName = nodeId,
                                        StartNodeId = nodeId,
                                        AttributeId = Attributes.Value,
                                        SamplingInterval = oldItems[0].SamplingInterval,
                                        QueueSize = 10,
                                        DiscardOldest = true
                                    };

                                    var capturedNodeId = nodeId;
                                    var capturedCallback = callback;
                                    monitoredItem.Notification += (item, ev) =>
                                    {
                                        if (ev.NotificationValue is MonitoredItemNotification notification)
                                        {
                                            var val = notification.Value?.Value;
                                            var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
                                            var sc = notification.Value?.StatusCode.Code ?? 0;
                                            capturedCallback(capturedNodeId, val, ts, sc);
                                        }
                                    };

                                    newItems.Add(monitoredItem);
                                    _subscription!.AddItem(monitoredItem);
                                }

                                _handleItems[handle] = newItems;
                            }
                        }
                    }

                    if (_subscription != null)
                        await _subscription.ApplyChangesAsync();

                    _reconnecting = false;
                    _logger.LogInformation("OPC UA reconnection successful");
                    Reconnected?.Invoke();
                    return;
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s");
                }
            }
        }, _reconnectCts.Token);
    }

    private void EnsureConnected()
    {
        if (!_connected || _session == null)
            throw new InvalidOperationException("OPC UA backend unavailable");
    }

    public async ValueTask DisposeAsync()
    {
        _reconnectCts?.Cancel();
        _reconnectCts?.Dispose();

        if (_subscription != null)
        {
            try { await _subscription.DeleteAsync(true); } catch { }
            _subscription = null;
        }
        if (_session != null)
        {
            _session.KeepAlive -= OnSessionKeepAlive;
            try { await _session.CloseAsync(); } catch { }
            _session = null;
        }
        _connected = false;
    }
}
```

**Step 3: Verify build**

```bash
cd infra/lmxfakeproxy && dotnet build
```

Expected: 0 errors.

**Step 4: Commit**

```bash
git add infra/lmxfakeproxy/Bridge/
git commit -m "feat(infra): add IOpcUaBridge interface and OpcUaBridge with OPC UA reconnection"
```

---

### Task 5: ScadaServiceImpl (gRPC Service) + Tests

**Files:**
- Create: `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`

**Step 1: Write the failing tests**

Create `tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`. These tests mock `IOpcUaBridge` and use real `SessionManager` + `TagMapper`:

```csharp
using Grpc.Core;
using NSubstitute;
using LmxFakeProxy.Bridge;
using LmxFakeProxy.Grpc;
using LmxFakeProxy.Sessions;
using LmxFakeProxy.Services;

namespace LmxFakeProxy.Tests;

public class ScadaServiceTests
{
    private readonly IOpcUaBridge _mockBridge;
    private readonly SessionManager _sessionMgr;
    private readonly TagMapper _tagMapper;
    private readonly ScadaServiceImpl _service;

    public ScadaServiceTests()
    {
        _mockBridge = Substitute.For<IOpcUaBridge>();
        _mockBridge.IsConnected.Returns(true);
        _sessionMgr = new SessionManager(null);
        _tagMapper = new TagMapper("ns=3;s=");
        _service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper);
    }

    private string ConnectClient(string clientId = "test-client")
    {
        var (_, _, sessionId) = _sessionMgr.Connect(clientId, "");
        return sessionId;
    }

    private static ServerCallContext MockContext()
    {
        return new TestServerCallContext();
    }

    // --- Connection ---

    [Fact]
    public async Task Connect_ReturnsSessionId()
    {
        var resp = await _service.Connect(
            new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext());
        Assert.True(resp.Success);
        Assert.NotEmpty(resp.SessionId);
    }

    // --- Read ---

    [Fact]
    public async Task Read_ValidSession_ReturnsVtq()
    {
        var sid = ConnectClient();
        _mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any<CancellationToken>())
            .Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0));

        var resp = await _service.Read(
            new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext());

        Assert.True(resp.Success);
        Assert.Equal("42.5", resp.Vtq.Value);
        Assert.Equal("Good", resp.Vtq.Quality);
    }

    [Fact]
    public async Task Read_InvalidSession_ReturnsFailure()
    {
        var resp = await _service.Read(
            new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext());
        Assert.False(resp.Success);
        Assert.Contains("Invalid", resp.Message);
    }

    [Fact]
    public async Task ReadBatch_ReturnsAllTags()
    {
        var sid = ConnectClient();
        _mockBridge.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0));

        var req = new ReadBatchRequest { SessionId = sid };
        req.Tags.AddRange(["Motor.Speed", "Pump.FlowRate"]);

        var resp = await _service.ReadBatch(req, MockContext());

        Assert.True(resp.Success);
        Assert.Equal(2, resp.Vtqs.Count);
    }

    // --- Write ---

    [Fact]
    public async Task Write_ValidSession_Succeeds()
    {
        var sid = ConnectClient();
        _mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any<object?>(), Arg.Any<CancellationToken>())
            .Returns(0u);

        var resp = await _service.Write(
            new WriteRequest { SessionId = sid, Tag = "Motor.Speed", Value = "42.5" }, MockContext());

        Assert.True(resp.Success);
    }

    [Fact]
    public async Task Write_InvalidSession_ReturnsFailure()
    {
        var resp = await _service.Write(
            new WriteRequest { SessionId = "bogus", Tag = "Motor.Speed", Value = "42.5" }, MockContext());
        Assert.False(resp.Success);
    }

    [Fact]
    public async Task WriteBatch_ReturnsPerItemResults()
    {
        var sid = ConnectClient();
        _mockBridge.WriteAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
            .Returns(0u);

        var req = new WriteBatchRequest { SessionId = sid };
        req.Items.Add(new WriteItem { Tag = "Motor.Speed", Value = "42.5" });
        req.Items.Add(new WriteItem { Tag = "Pump.FlowRate", Value = "10.0" });

        var resp = await _service.WriteBatch(req, MockContext());

        Assert.True(resp.Success);
        Assert.Equal(2, resp.Results.Count);
        Assert.All(resp.Results, r => Assert.True(r.Success));
    }

    // --- CheckApiKey ---

    [Fact]
    public async Task CheckApiKey_Valid_ReturnsTrue()
    {
        var resp = await _service.CheckApiKey(
            new CheckApiKeyRequest { ApiKey = "anything" }, MockContext());
        Assert.True(resp.IsValid);
    }

    [Fact]
    public async Task CheckApiKey_Invalid_ReturnsFalse()
    {
        var mgr = new SessionManager("secret");
        var svc = new ScadaServiceImpl(mgr, _mockBridge, _tagMapper);

        var resp = await svc.CheckApiKey(
            new CheckApiKeyRequest { ApiKey = "wrong" }, MockContext());
        Assert.False(resp.IsValid);
    }
}

/// <summary>
/// Minimal ServerCallContext implementation for unit testing gRPC services.
/// </summary>
internal class TestServerCallContext : ServerCallContext
{
    protected override string MethodCore => "test";
    protected override string HostCore => "localhost";
    protected override string PeerCore => "test-peer";
    protected override DateTime DeadlineCore => DateTime.MaxValue;
    protected override Metadata RequestHeadersCore => new();
    protected override CancellationToken CancellationTokenCore => CancellationToken.None;
    protected override Metadata ResponseTrailersCore => new();
    protected override Status StatusCore { get; set; }
    protected override WriteOptions? WriteOptionsCore { get; set; }
    protected override AuthContext AuthContextCore => new("test", new Dictionary<string, List<AuthProperty>>());

    protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
        throw new NotImplementedException();
    protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
}
```

**Step 2: Run tests to verify they fail**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: FAIL — `ScadaServiceImpl` does not exist.

**Step 3: Implement ScadaServiceImpl**

Create `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs`:

```csharp
using Grpc.Core;
using LmxFakeProxy.Bridge;
using LmxFakeProxy.Grpc;
using LmxFakeProxy.Sessions;

namespace LmxFakeProxy.Services;

public class ScadaServiceImpl : ScadaService.ScadaServiceBase
{
    private readonly SessionManager _sessions;
    private readonly IOpcUaBridge _bridge;
    private readonly TagMapper _tagMapper;

    public ScadaServiceImpl(SessionManager sessions, IOpcUaBridge bridge, TagMapper tagMapper)
    {
        _sessions = sessions;
        _bridge = bridge;
        _tagMapper = tagMapper;
    }

    public override Task<ConnectResponse> Connect(ConnectRequest request, ServerCallContext context)
    {
        var (success, message, sessionId) = _sessions.Connect(request.ClientId, request.ApiKey);
        return Task.FromResult(new ConnectResponse
        {
            Success = success,
            Message = message,
            SessionId = sessionId
        });
    }

    public override Task<DisconnectResponse> Disconnect(DisconnectRequest request, ServerCallContext context)
    {
        var ok = _sessions.Disconnect(request.SessionId);
        return Task.FromResult(new DisconnectResponse
        {
            Success = ok,
            Message = ok ? "Disconnected" : "Session not found"
        });
    }

    public override Task<GetConnectionStateResponse> GetConnectionState(
        GetConnectionStateRequest request, ServerCallContext context)
    {
        var (found, clientId, ticks) = _sessions.GetConnectionState(request.SessionId);
        return Task.FromResult(new GetConnectionStateResponse
        {
            IsConnected = found,
            ClientId = clientId,
            ConnectedSinceUtcTicks = ticks
        });
    }

    public override Task<CheckApiKeyResponse> CheckApiKey(CheckApiKeyRequest request, ServerCallContext context)
    {
        var valid = _sessions.CheckApiKey(request.ApiKey);
        return Task.FromResult(new CheckApiKeyResponse
        {
            IsValid = valid,
            Message = valid ? "Valid" : "Invalid API key"
        });
    }

    public override async Task<ReadResponse> Read(ReadRequest request, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            return new ReadResponse { Success = false, Message = "Invalid or expired session" };

        try
        {
            var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
            var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
            return new ReadResponse
            {
                Success = true,
                Vtq = TagMapper.ToVtqMessage(request.Tag, result.Value, result.SourceTimestamp, result.StatusCode)
            };
        }
        catch (Exception ex)
        {
            return new ReadResponse { Success = false, Message = ex.Message };
        }
    }

    public override async Task<ReadBatchResponse> ReadBatch(ReadBatchRequest request, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            return new ReadBatchResponse { Success = false, Message = "Invalid or expired session" };

        var response = new ReadBatchResponse { Success = true };
        foreach (var tag in request.Tags)
        {
            try
            {
                var nodeId = _tagMapper.ToOpcNodeId(tag);
                var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
                response.Vtqs.Add(TagMapper.ToVtqMessage(tag, result.Value, result.SourceTimestamp, result.StatusCode));
            }
            catch (Exception ex)
            {
                response.Vtqs.Add(new VtqMessage
                {
                    Tag = tag, Value = "", Quality = "Bad",
                    TimestampUtcTicks = DateTime.UtcNow.Ticks
                });
                response.Message = ex.Message;
            }
        }
        return response;
    }

    public override async Task<WriteResponse> Write(WriteRequest request, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            return new WriteResponse { Success = false, Message = "Invalid or expired session" };

        try
        {
            var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
            var value = TagMapper.ParseWriteValue(request.Value);
            var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);

            return statusCode == 0
                ? new WriteResponse { Success = true }
                : new WriteResponse { Success = false, Message = $"OPC UA write failed: 0x{statusCode:X8}" };
        }
        catch (Exception ex)
        {
            return new WriteResponse { Success = false, Message = ex.Message };
        }
    }

    public override async Task<WriteBatchResponse> WriteBatch(WriteBatchRequest request, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            return new WriteBatchResponse { Success = false, Message = "Invalid or expired session" };

        var response = new WriteBatchResponse { Success = true };
        foreach (var item in request.Items)
        {
            try
            {
                var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
                var value = TagMapper.ParseWriteValue(item.Value);
                var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);

                response.Results.Add(new Grpc.WriteResult
                {
                    Tag = item.Tag,
                    Success = statusCode == 0,
                    Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
                });

                if (statusCode != 0) response.Success = false;
            }
            catch (Exception ex)
            {
                response.Results.Add(new Grpc.WriteResult
                {
                    Tag = item.Tag, Success = false, Message = ex.Message
                });
                response.Success = false;
            }
        }
        return response;
    }

    public override async Task<WriteBatchAndWaitResponse> WriteBatchAndWait(
        WriteBatchAndWaitRequest request, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            return new WriteBatchAndWaitResponse { Success = false, Message = "Invalid or expired session" };

        var startTime = DateTime.UtcNow;

        // Write all items
        var writeResults = new List<Grpc.WriteResult>();
        var allWritesOk = true;
        foreach (var item in request.Items)
        {
            try
            {
                var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
                var value = TagMapper.ParseWriteValue(item.Value);
                var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
                writeResults.Add(new Grpc.WriteResult
                {
                    Tag = item.Tag,
                    Success = statusCode == 0,
                    Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
                });
                if (statusCode != 0) allWritesOk = false;
            }
            catch (Exception ex)
            {
                writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message });
                allWritesOk = false;
            }
        }

        if (!allWritesOk)
        {
            var resp = new WriteBatchAndWaitResponse { Success = false, Message = "Write failed" };
            resp.WriteResults.AddRange(writeResults);
            return resp;
        }

        // Poll for flag value
        var flagNodeId = _tagMapper.ToOpcNodeId(request.FlagTag);
        var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000;
        var pollMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
        var deadline = startTime.AddMilliseconds(timeoutMs);

        while (DateTime.UtcNow < deadline)
        {
            context.CancellationToken.ThrowIfCancellationRequested();
            try
            {
                var readResult = await _bridge.ReadAsync(flagNodeId, context.CancellationToken);
                if (readResult.Value?.ToString() == request.FlagValue)
                {
                    var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
                    var resp = new WriteBatchAndWaitResponse
                    {
                        Success = true, FlagReached = true, ElapsedMs = elapsed
                    };
                    resp.WriteResults.AddRange(writeResults);
                    return resp;
                }
            }
            catch { /* read failure during poll — keep trying */ }

            await Task.Delay(pollMs, context.CancellationToken);
        }

        var finalResp = new WriteBatchAndWaitResponse
        {
            Success = true, FlagReached = false,
            ElapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds,
            Message = "Timeout waiting for flag value"
        };
        finalResp.WriteResults.AddRange(writeResults);
        return finalResp;
    }

    public override async Task Subscribe(
        SubscribeRequest request, IServerStreamWriter<VtqMessage> responseStream, ServerCallContext context)
    {
        if (!_sessions.ValidateSession(request.SessionId))
            throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session"));

        var nodeIds = request.Tags.Select(t => _tagMapper.ToOpcNodeId(t)).ToList();
        var tagByNodeId = request.Tags.Zip(nodeIds).ToDictionary(p => p.Second, p => p.First);

        var handle = await _bridge.AddMonitoredItemsAsync(
            nodeIds,
            request.SamplingMs,
            (nodeId, value, timestamp, statusCode) =>
            {
                if (tagByNodeId.TryGetValue(nodeId, out var tag))
                {
                    var vtq = TagMapper.ToVtqMessage(tag, value, timestamp, statusCode);
                    try { responseStream.WriteAsync(vtq).Wait(); }
                    catch { /* stream closed */ }
                }
            },
            context.CancellationToken);

        try
        {
            // Keep the stream open until the client cancels
            await Task.Delay(Timeout.Infinite, context.CancellationToken);
        }
        catch (OperationCanceledException) { }
        finally
        {
            await _bridge.RemoveMonitoredItemsAsync(handle);
        }
    }
}
```

**Step 4: Run tests to verify they pass**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```

Expected: All tests PASS (11 TagMapping + 12 SessionManager + 9 ScadaService = 32).

**Step 5: Commit**

```bash
git add infra/lmxfakeproxy/Services/ infra/lmxfakeproxy/tests/
git commit -m "feat(infra): add ScadaServiceImpl with full proto parity for all RPCs"
```

---

### Task 6: Program.cs — Host Builder with CLI Args

**Files:**
- Modify: `infra/lmxfakeproxy/Program.cs`

**Step 1: Replace the minimal Program.cs with the full host builder**

```csharp
using LmxFakeProxy;
using LmxFakeProxy.Bridge;
using LmxFakeProxy.Services;
using LmxFakeProxy.Sessions;

var builder = WebApplication.CreateBuilder(args);

// Configuration: env vars take precedence over CLI args
var port = Environment.GetEnvironmentVariable("PORT") ?? GetArg(args, "--port") ?? "50051";
var opcEndpoint = Environment.GetEnvironmentVariable("OPC_ENDPOINT") ?? GetArg(args, "--opc-endpoint") ?? "opc.tcp://localhost:50000";
var opcPrefix = Environment.GetEnvironmentVariable("OPC_PREFIX") ?? GetArg(args, "--opc-prefix") ?? "ns=3;s=";
var apiKey = Environment.GetEnvironmentVariable("API_KEY") ?? GetArg(args, "--api-key");

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(int.Parse(port), listenOptions =>
    {
        listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
    });
});

// Register services
var sessionManager = new SessionManager(apiKey);
var tagMapper = new TagMapper(opcPrefix);
var opcUaBridge = new OpcUaBridge(opcEndpoint, builder.Services.BuildServiceProvider().GetRequiredService<ILogger<OpcUaBridge>>());

builder.Services.AddSingleton(sessionManager);
builder.Services.AddSingleton(tagMapper);
builder.Services.AddSingleton<IOpcUaBridge>(opcUaBridge);
builder.Services.AddGrpc();

var app = builder.Build();

app.MapGrpcService<ScadaServiceImpl>();
app.MapGet("/", () => "LmxFakeProxy is running");

// Connect to OPC UA backend
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("LmxFakeProxy starting on port {Port}", port);
logger.LogInformation("OPC UA endpoint: {Endpoint}, prefix: {Prefix}", opcEndpoint, opcPrefix);
logger.LogInformation("API key enforcement: {Enforced}", apiKey != null ? "enabled" : "disabled (accept all)");

try
{
    await opcUaBridge.ConnectAsync();
    logger.LogInformation("OPC UA bridge connected");
}
catch (Exception ex)
{
    logger.LogWarning(ex, "Initial OPC UA connection failed — will retry in background");
}

await app.RunAsync();

static string? GetArg(string[] args, string name)
{
    var idx = Array.IndexOf(args, name);
    return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
```

**Step 2: Verify build and basic startup**

```bash
cd infra/lmxfakeproxy && dotnet build
```

Expected: 0 errors.

**Step 3: Commit**

```bash
git add infra/lmxfakeproxy/Program.cs
git commit -m "feat(infra): wire up Program.cs with CLI args, env vars, and OPC UA bridge startup"
```

---

### Task 7: Dockerfile + Docker Compose Integration

**Files:**
- Create: `infra/lmxfakeproxy/Dockerfile`
- Modify: `infra/docker-compose.yml`

**Step 1: Create the Dockerfile**

Create `infra/lmxfakeproxy/Dockerfile`:

```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY LmxFakeProxy.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 50051
ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"]
```

Note: Do NOT copy the `tests/` directory into the build — the `.dockerignore` or the `COPY` pattern handles this naturally since only the project root files and source folders are needed.

**Step 2: Create `.dockerignore`**

Create `infra/lmxfakeproxy/.dockerignore`:

```
tests/
bin/
obj/
```

**Step 3: Add the service to docker-compose.yml**

Add the following service block before the `volumes:` section in `infra/docker-compose.yml`:

```yaml
  lmxfakeproxy:
    build: ./lmxfakeproxy
    container_name: scadalink-lmxfakeproxy
    ports:
      - "50051:50051"
    environment:
      OPC_ENDPOINT: "opc.tcp://opcua:50000"
      OPC_PREFIX: "ns=3;s="
    depends_on:
      - opcua
    networks:
      - scadalink-net
    restart: unless-stopped
```

**Step 4: Verify Docker build**

```bash
cd infra && docker compose build lmxfakeproxy
```

Expected: Build succeeds.

**Step 5: Commit**

```bash
git add infra/lmxfakeproxy/Dockerfile infra/lmxfakeproxy/.dockerignore infra/docker-compose.yml
git commit -m "feat(infra): add LmxFakeProxy Dockerfile and docker-compose service"
```

---

### Task 8: Documentation Updates

**Files:**
- Modify: `docs/test_infra/test_infra.md`
- Modify: `infra/README.md`
- Create: `docs/test_infra/test_infra_lmxfakeproxy.md`
- Modify: `docs/requirements/Component-DataConnectionLayer.md`

**Step 1: Update docs/test_infra/test_infra.md**

Add a row to the Services table:

```
| LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars |
```

Add a bullet to the per-service documentation list:

```
- [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge)
```

Update the Files section to add:

```
  lmxfakeproxy/              # .NET gRPC proxy bridging LmxProxy protocol to OPC UA
```

**Step 2: Update infra/README.md**

Add a row to the quick-start table:

```
| LmxFakeProxy (.NET gRPC) | 50051 (gRPC) | LmxProxy-compatible server bridging to OPC UA test server |
```

**Step 3: Create docs/test_infra/test_infra_lmxfakeproxy.md**

```markdown
# Test Infrastructure: LmxFakeProxy

## Overview

LmxFakeProxy is a .NET gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of `RealLmxProxyClient` and the LmxProxy DCL adapter.

## Image & Ports

- **Image**: Custom build (`infra/lmxfakeproxy/Dockerfile`)
- **gRPC endpoint**: `localhost:50051`

## Configuration

| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `PORT` | `50051` | gRPC listen port |
| `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | Backend OPC UA server |
| `OPC_PREFIX` | `ns=3;s=` | Prefix prepended to LMX tags to form OPC UA NodeIds |
| `API_KEY` | *(none)* | If set, enforces API key on all gRPC calls |

## Tag Address Mapping

LMX-style flat addresses are mapped to OPC UA NodeIds by prepending the configured prefix:

| LMX Tag | OPC UA NodeId |
|---------|--------------|
| `Motor.Speed` | `ns=3;s=Motor.Speed` |
| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` |
| `Tank.Level` | `ns=3;s=Tank.Level` |

## Supported RPCs

Full parity with the `scada.ScadaService` proto:

- **Connect / Disconnect / GetConnectionState** — Session management
- **Read / ReadBatch** — Read tag values via OPC UA
- **Write / WriteBatch / WriteBatchAndWait** — Write values via OPC UA
- **Subscribe** — Server-streaming subscriptions via OPC UA MonitoredItems
- **CheckApiKey** — API key validation

## Verification

1. Ensure the OPC UA test server is running:
```bash
docker ps --filter name=scadalink-opcua
```

2. Start the fake proxy:
```bash
docker compose up -d lmxfakeproxy
```

3. Check logs:
```bash
docker logs scadalink-lmxfakeproxy
```

4. Test with the ScadaLink CLI or a gRPC client.

## Running Standalone (without Docker)

```bash
cd infra/lmxfakeproxy
dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s="
```

With API key enforcement:
```bash
dotnet run -- --api-key my-secret-key
```

## Relevance to ScadaLink Components

- **Data Connection Layer** — Test `RealLmxProxyClient` and `LmxProxyDataConnection` against real OPC UA data
- **Site Runtime** — Deploy instances with LmxProxy data connections pointing at this server
- **Integration Tests** — End-to-end tests of the LmxProxy protocol path
```

**Step 4: Update docs/requirements/Component-DataConnectionLayer.md**

Add a note in the LmxProxy section (after the "Proto Source" paragraph, before "## Subscription Management"):

```markdown
**Test Infrastructure**: The `infra/lmxfakeproxy/` project provides a fake LmxProxy server that bridges to the OPC UA test server. It implements the full `scada.ScadaService` proto, enabling end-to-end testing of `RealLmxProxyClient` without a Windows LmxProxy deployment. See [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) for setup.
```

**Step 5: Commit**

```bash
git add docs/test_infra/test_infra.md docs/test_infra/test_infra_lmxfakeproxy.md infra/README.md docs/requirements/Component-DataConnectionLayer.md
git commit -m "docs: add LmxFakeProxy to test infrastructure documentation"
```

---

### Task 9: Integration Smoke Test with RealLmxProxyClient

**Files:**
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs`

This test is an end-to-end smoke test that verifies the fake proxy works with the actual `RealLmxProxyClient`. It requires the OPC UA test server and LmxFakeProxy to both be running (Docker or standalone). Mark it with a `[Trait]` so it can be skipped in CI.

**Step 1: Add the DCL project reference to the test csproj**

Add to `tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj`:

```xml
<ProjectReference Include="../../../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />
```

**Step 2: Write the integration test**

Create `tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs`:

```csharp
using ScadaLink.DataConnectionLayer.Adapters;

namespace LmxFakeProxy.Tests;

/// <summary>
/// End-to-end smoke test that connects RealLmxProxyClient to LmxFakeProxy.
/// Requires both OPC UA test server and LmxFakeProxy to be running.
/// Run manually: dotnet test --filter "Category=Integration"
/// </summary>
[Trait("Category", "Integration")]
public class IntegrationSmokeTest
{
    private const string Host = "localhost";
    private const int Port = 50051;

    [Fact]
    public async Task ConnectReadWriteSubscribe_EndToEnd()
    {
        var client = new RealLmxProxyClient(Host, Port, apiKey: null);

        try
        {
            // Connect
            await client.ConnectAsync();
            Assert.True(client.IsConnected);

            // Read initial value
            var vtq = await client.ReadAsync("Motor.Speed");
            Assert.Equal(LmxQuality.Good, vtq.Quality);

            // Write a value
            await client.WriteAsync("Motor.Speed", 42.5);

            // Read back
            var vtq2 = await client.ReadAsync("Motor.Speed");
            Assert.Equal(42.5, (double)vtq2.Value!);

            // ReadBatch
            var batch = await client.ReadBatchAsync(["Motor.Speed", "Pump.FlowRate"]);
            Assert.Equal(2, batch.Count);

            // Subscribe briefly
            LmxVtq? lastUpdate = null;
            var sub = await client.SubscribeAsync(
                ["Motor.Speed"],
                (tag, v) => lastUpdate = v);

            // Write to trigger subscription update
            await client.WriteAsync("Motor.Speed", 99.0);
            await Task.Delay(2000); // Wait for subscription delivery

            await sub.DisposeAsync();

            // Verify we got at least one subscription update
            Assert.NotNull(lastUpdate);

            // Disconnect
            await client.DisconnectAsync();
        }
        finally
        {
            await client.DisposeAsync();
        }
    }
}
```

**Step 3: Verify build (do NOT run yet — requires running infra)**

```bash
cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/
```

Expected: 0 errors.

**Step 4: Run unit tests only (exclude integration)**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n
```

Expected: All unit tests pass. Integration test is skipped.

**Step 5: Commit**

```bash
git add infra/lmxfakeproxy/tests/
git commit -m "test(infra): add integration smoke test for RealLmxProxyClient against LmxFakeProxy"
```

---

### Task 10: End-to-End Verification

**No new files — verification only.**

**Step 1: Start the infrastructure**

```bash
cd infra && docker compose up -d
```

Wait for OPC UA server to be ready:

```bash
docker logs scadalink-opcua 2>&1 | tail -5
```

**Step 2: Verify LmxFakeProxy logs**

```bash
docker logs scadalink-lmxfakeproxy
```

Expected: "OPC UA bridge connected" message.

**Step 3: Run the integration smoke test**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category=Integration" -v n
```

Expected: Integration test passes — connect, read, write, read-back, subscribe all work.

**Step 4: Run all unit tests to confirm no regressions**

```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n
```

Expected: All unit tests pass.

**Step 5: Final commit (if any fixes were needed)**

```bash
git add -A && git commit -m "fix(infra): address issues found during end-to-end verification"
```

Only commit if changes were needed. If everything passed cleanly, skip this step.
