using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

/// <summary>
///     Phase C Task 3 — the node-manager's OPC UA HistoryRead override (Raw / Processed / AtTime) over
///     historized variable nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness
///     <see cref="NodeManagerHistorizeTests"/> uses), materialises a historized variable via
///     <see cref="OtOpcUaNodeManager.EnsureVariable"/>, wires a recording fake
///     <see cref="IHistorianDataSource"/>, then invokes the node manager's PUBLIC
///     <c>HistoryRead(OperationContext, …)</c> directly. The base CustomNodeManager2 builds the node
///     handles + dispatches to the protected per-details overrides, so this exercises the real dispatch
///     path in-process — fast + deterministic, no client socket.
/// </summary>
public sealed class NodeManagerHistoryReadTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    private readonly string _pkiRoot = Path.Combine(
        Path.GetTempPath(),
        $"otopcua-historyread-{Guid.NewGuid():N}");

    /// <summary>Raw read: the fake receives the resolved tagname + StartTime/EndTime/NumValuesPerNode,
    /// and the returned samples decode to a HistoryData whose DataValues mirror value/status/source+server
    /// timestamps. StatusCode is Good when samples are present.</summary>
    [Fact]
    public async Task Raw_dispatches_to_source_and_maps_samples()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource();
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
            writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;

        var src = DateTime.UtcNow.AddSeconds(-5);
        var srv = DateTime.UtcNow;
        fake.RawResult = new HistorianRead(
            new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null);

        var start = DateTime.UtcNow.AddHours(-1);
        var end = DateTime.UtcNow;
        var details = new ReadRawModifiedDetails
        {
            StartTime = start,
            EndTime = end,
            NumValuesPerNode = 100,
            IsReadModified = false,
        };

        var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        // The source saw the resolved tagname + the request window + cap.
        fake.LastCall.ShouldBe("Raw");
        fake.LastTagname.ShouldBe("WW.Temp");
        fake.LastStart.ShouldBe(start);
        fake.LastEnd.ShouldBe(end);
        fake.LastMaxValues.ShouldBe(100u);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
        data.DataValues.Count.ShouldBe(1);
        var dv = data.DataValues[0];
        dv.Value.ShouldBe(42.5f);
        dv.StatusCode.Code.ShouldBe(StatusCodes.Good);
        dv.SourceTimestamp.ShouldBe(src);
        dv.ServerTimestamp.ShouldBe(srv);

        await host.DisposeAsync();
    }

    /// <summary>The resolved tagname that reaches the source is the OVERRIDE tagname (distinct from the
    /// NodeId / FullName) — the override resolves the NodeId→tagname map, not the bare NodeId.</summary>
    [Fact]
    public async Task Raw_resolves_override_tagname_not_node_id()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { RawResult = Empty() };
        nm.HistorianDataSource = fake;

        // Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
        nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
            writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            // ReadRawModifiedDetails.Initialize() sets m_isReadModified=true (SDK-generated ctor body),
            // so the default from new ReadRawModifiedDetails() is TRUE. A plain raw read must explicitly
            // clear it to false to avoid hitting the IsReadModified=true early-return in the override.
            IsReadModified = false,
        };

        InvokeHistoryRead(server, nm, details, nodeId);

        fake.LastTagname.ShouldBe("Plant.Flow.PV");

        await host.DisposeAsync();
    }

    /// <summary>Empty samples ⇒ the node's StatusCode is GoodNoData (the node is historized, the window
    /// just held no data).</summary>
    [Fact]
    public async Task Raw_empty_samples_yields_GoodNoData()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { RawResult = Empty() };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
            writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            IsReadModified = false,
        };

        var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
        var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
        data.DataValues.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>A non-historized node (plain variable, no HistoryRead bit) reaching HistoryRead yields
    /// BadHistoryOperationUnsupported — the source is never invoked.</summary>
    [Fact]
    public async Task Non_historized_node_yields_BadHistoryOperationUnsupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { RawResult = Empty() };
        nm.HistorianDataSource = fake;

        // Plain (non-historized) variable — no HistoryRead access bit.
        nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
            writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        fake.LastCall.ShouldBeNull(); // source never reached

        await host.DisposeAsync();
    }

    /// <summary>Raw with IsReadModified=true ⇒ BadHistoryOperationUnsupported (we don't serve modified
    /// history); the source is never invoked.</summary>
    [Fact]
    public async Task Raw_read_modified_yields_BadHistoryOperationUnsupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { RawResult = Empty() };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
            writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            IsReadModified = true,
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        fake.LastCall.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>Processed read with a known aggregate (Average) reaches the source as
    /// HistoryAggregateType.Average + the ProcessingInterval as a TimeSpan.</summary>
    [Fact]
    public async Task Processed_known_aggregate_dispatches_with_interval()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
            writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;

        var details = new ReadProcessedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            ProcessingInterval = 10_000.0, // ms
            AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average },
        };

        InvokeHistoryRead(server, nm, details, nodeId);

        fake.LastCall.ShouldBe("Processed");
        fake.LastTagname.ShouldBe("WW.Avg");
        fake.LastAggregate.ShouldBe(HistoryAggregateType.Average);
        fake.LastInterval.ShouldBe(TimeSpan.FromMilliseconds(10_000.0));

        await host.DisposeAsync();
    }

    /// <summary>Processed read with an UNKNOWN aggregate NodeId ⇒ BadAggregateNotSupported; the source
    /// is never invoked.</summary>
    [Fact]
    public async Task Processed_unknown_aggregate_yields_BadAggregateNotSupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
            writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;

        var details = new ReadProcessedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            ProcessingInterval = 10_000.0,
            // StandardDeviationSample is a real OPC UA aggregate we do not serve.
            AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample },
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
        fake.LastCall.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
    [Fact]
    public async Task AtTime_dispatches_requested_timestamps()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { AtTimeResult = Empty() };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
            writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;

        var t1 = DateTime.UtcNow.AddMinutes(-2);
        var t2 = DateTime.UtcNow.AddMinutes(-1);
        var details = new ReadAtTimeDetails
        {
            ReqTimes = new DateTimeCollection { t1, t2 },
        };

        InvokeHistoryRead(server, nm, details, nodeId);

        fake.LastCall.ShouldBe("AtTime");
        fake.LastTagname.ShouldBe("WW.At");
        fake.LastTimestamps.ShouldBe(new[] { t1, t2 });

        await host.DisposeAsync();
    }

    /// <summary>
    ///     Two nodes in a single Raw HistoryRead batch — one backend succeeds, one throws. Proves that
    ///     per-node error isolation is enforced across handle indices: the good node's slot is
    ///     Good+samples, the bad node's slot is Bad, and neither status leaks into the other.
    /// </summary>
    [Fact]
    public async Task Raw_multi_node_per_node_error_isolation()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        // Materialise two distinct historized variables under separate equipment folders.
        nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
            writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns);
        nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
            writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns);

        var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
        var badNodeId  = nm.TryGetVariable("eqB/bad")!.NodeId;

        var src = DateTime.UtcNow.AddSeconds(-5);
        var srv = DateTime.UtcNow;

        // Per-tagname fake: A.PV returns one sample; B.PV throws.
        var perTagFake = new PerTagnameHistorianDataSource(tagname =>
            tagname == "A.PV"
                ? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null))
                : throw new InvalidOperationException("backend boom for B.PV"));
        nm.HistorianDataSource = perTagFake;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime   = DateTime.UtcNow,
            NumValuesPerNode = 10,
            IsReadModified = false,
        };

        // Single batch listing BOTH nodes in order [goodNodeId, badNodeId].
        // handle.Index maps by position: good=0, bad=1.
        var (results, errors) = InvokeHistoryRead(server, nm, details, goodNodeId, badNodeId);

        // Good node (index 0): should have a Good status + one sample.
        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
        data.DataValues.Count.ShouldBe(1);
        data.DataValues[0].Value.ShouldBe(1.0f);

        // Bad node (index 1): errors slot must be Bad; results slot must NOT carry any HistoryData
        // (no data was projected into it).  The base may pre-seed results[i] to a default-constructed
        // SdkHistoryReadResult whose StatusCode is 0 (Good) — that's fine for the result field, but the
        // distinguishing signal is the error slot AND the absence of HistoryData from the good node.
        StatusCode.IsBad(errors[1].StatusCode).ShouldBeTrue();
        errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        // HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1.
        (results[1].HistoryData == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception
    /// escapes the HistoryRead call.</summary>
    [Fact]
    public async Task Backend_throw_yields_bad_status_and_does_not_escape()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
        nm.HistorianDataSource = fake;

        nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
            writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;

        var details = new ReadRawModifiedDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            IsReadModified = false,
        };

        // The call must not throw even though the backend does.
        var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        // The result slot was never filled with a GoodNoData success.
        (results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
    /// results + errors. Uses a session-less <see cref="OperationContext"/> (the
    /// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's
    /// handle-building only needs the NodeId + namespace, not a session.</summary>
    private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
        OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
        => InvokeHistoryRead(server, nm, details, new[] { nodeId });

    /// <summary>Multi-node overload — issues one HistoryRead batch for all supplied node ids in order.
    /// Results and errors are returned in the same order as <paramref name="nodeIds"/>.</summary>
    private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
        OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
    {
        var context = new OperationContext(
            new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);

        var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
        var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
        var errors  = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();

        nm.HistoryRead(
            context,
            details,
            TimestampsToReturn.Both,
            releaseContinuationPoints: false,
            nodesToRead,
            results,
            errors);

        return (results, errors);
    }

    private static HistorianRead Empty() => new(Array.Empty<DataValueSnapshot>(), null);

    /// <summary>A recording fake historian source — captures the last call's kind + arguments and returns
    /// a configured result (or throws when <see cref="ThrowOnRead"/> is set).</summary>
    private sealed class RecordingHistorianDataSource : IHistorianDataSource
    {
        public bool ThrowOnRead { get; init; }
        public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
        public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
        public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);

        public string? LastCall { get; private set; }
        public string? LastTagname { get; private set; }
        public DateTime LastStart { get; private set; }
        public DateTime LastEnd { get; private set; }
        public uint LastMaxValues { get; private set; }
        public TimeSpan LastInterval { get; private set; }
        public HistoryAggregateType LastAggregate { get; private set; }
        public IReadOnlyList<DateTime>? LastTimestamps { get; private set; }

        public Task<HistorianRead> ReadRawAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
            CancellationToken cancellationToken)
        {
            if (ThrowOnRead) throw new InvalidOperationException("backend boom");
            LastCall = "Raw";
            LastTagname = fullReference;
            LastStart = startUtc;
            LastEnd = endUtc;
            LastMaxValues = maxValuesPerNode;
            return Task.FromResult(RawResult);
        }

        public Task<HistorianRead> ReadProcessedAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
            HistoryAggregateType aggregate, CancellationToken cancellationToken)
        {
            if (ThrowOnRead) throw new InvalidOperationException("backend boom");
            LastCall = "Processed";
            LastTagname = fullReference;
            LastStart = startUtc;
            LastEnd = endUtc;
            LastInterval = interval;
            LastAggregate = aggregate;
            return Task.FromResult(ProcessedResult);
        }

        public Task<HistorianRead> ReadAtTimeAsync(
            string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
        {
            if (ThrowOnRead) throw new InvalidOperationException("backend boom");
            LastCall = "AtTime";
            LastTagname = fullReference;
            LastTimestamps = timestampsUtc;
            return Task.FromResult(AtTimeResult);
        }

        public Task<HistoricalEventsResult> ReadEventsAsync(
            string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
            CancellationToken cancellationToken) =>
            Task.FromResult(new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null));

        public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();

        public void Dispose()
        {
        }
    }

    /// <summary>A per-tagname historian source that delegates each raw read to a caller-supplied
    /// <see cref="Func{T, TResult}"/>. The func may throw to simulate a backend failure for a
    /// specific tagname while returning results for others — enabling per-node isolation tests.
    /// Processed / AtTime / Events are not wired (they delegate to the null source).</summary>
    private sealed class PerTagnameHistorianDataSource(Func<string, Task<HistorianRead>> rawHandler)
        : IHistorianDataSource
    {
        public Task<HistorianRead> ReadRawAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
            CancellationToken cancellationToken) => rawHandler(fullReference);

        public Task<HistorianRead> ReadProcessedAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
            HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);

        public Task<HistorianRead> ReadAtTimeAsync(
            string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);

        public Task<HistoricalEventsResult> ReadEventsAsync(
            string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
            CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);

        public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();

        public void Dispose() { }
    }

    private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.HistoryReadTest",
                ApplicationUri = $"urn:OtOpcUa.HistoryReadTest:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var server = new OtOpcUaSdkServer();
        await host.StartAsync(server, Ct);
        return (host, server);
    }

    private static int AllocateFreePort()
    {
        using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
        listener.Start();
        var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    /// <summary>Cleans up the PKI root directory.</summary>
    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort cleanup */ }
        }
    }
}
