using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;

/// <summary>
/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the
/// cross-site Route.To() routing surface inbound API scripts use.
///
/// InboundAPI-017: this surface previously had zero coverage.
/// InboundAPI-016: routed calls must inherit the executing method's deadline token.
/// </summary>
public class RouteHelperTests
{
    private readonly IInstanceLocator _locator = Substitute.For<IInstanceLocator>();
    private readonly IInstanceRouter _router = Substitute.For<IInstanceRouter>();

    private RouteHelper CreateHelper() => new(_locator, _router);

    private void SiteResolves(string instanceCode, string siteId) =>
        _locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>())
            .Returns(siteId);

    // --- Call ---

    [Fact]
    public async Task Call_HappyPath_ResolvesSiteAndReturnsValue()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, 99, null, DateTimeOffset.UtcNow));

        var result = await CreateHelper().To("inst-1").Call("doWork", new { x = 1 });

        Assert.Equal(99, result);
    }

    [Fact]
    public async Task Call_GeneratesCorrelationId()
    {
        SiteResolves("inst-1", "SiteA");
        RouteToCallRequest? captured = null;
        _router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").Call("doWork");

        Assert.NotNull(captured);
        Assert.True(Guid.TryParse(captured!.CorrelationId, out _));
    }

    [Fact]
    public async Task Call_RemoteFailure_ThrowsInvalidOperationException()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, false, null, "site exploded", DateTimeOffset.UtcNow));

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => CreateHelper().To("inst-1").Call("doWork"));
        Assert.Equal("site exploded", ex.Message);
    }

    [Fact]
    public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException()
    {
        // Locator returns null → instance not found / no assigned site.
        _locator.GetSiteIdForInstanceAsync("ghost", Arg.Any<CancellationToken>())
            .Returns((string?)null);

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => CreateHelper().To("ghost").Call("doWork"));
        Assert.Contains("ghost", ex.Message);
    }

    // --- GetAttribute(s) ---

    [Fact]
    public async Task GetAttributes_HappyPath_ReturnsValues()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 },
                true, null, DateTimeOffset.UtcNow));

        var result = await CreateHelper().To("inst-1").GetAttributes(new[] { "a", "b" });

        Assert.Equal(1, result["a"]);
        Assert.Equal(2, result["b"]);
    }

    [Fact]
    public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?> { ["temp"] = 21.5 },
                true, null, DateTimeOffset.UtcNow));

        var value = await CreateHelper().To("inst-1").GetAttribute("temp");

        Assert.Equal(21.5, value);
    }

    [Fact]
    public async Task GetAttribute_AbsentKey_ReturnsNull()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?>(), // batch returns nothing for the key
                true, null, DateTimeOffset.UtcNow));

        var value = await CreateHelper().To("inst-1").GetAttribute("missing");

        Assert.Null(value);
    }

    [Fact]
    public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?>(), false, "read failed", DateTimeOffset.UtcNow));

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => CreateHelper().To("inst-1").GetAttributes(new[] { "a" }));
        Assert.Equal("read failed", ex.Message);
    }

    // --- WaitForAttribute (spec §6) ---

    [Fact]
    public async Task WaitForAttribute_Matched_ReturnsTrue()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: true, Value: true, Quality: "Good", TimedOut: false,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        var matched = await CreateHelper().To("inst-1")
            .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.True(matched);
    }

    [Fact]
    public async Task WaitForAttribute_TimedOut_ReturnsFalse()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: false, Value: null, Quality: null, TimedOut: true,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        var matched = await CreateHelper().To("inst-1")
            .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.False(matched);
    }

    [Fact]
    public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException()
    {
        // Success=false is a routing-level outcome (e.g. instance not found on the
        // site), distinct from the wait outcome (Matched/TimedOut).
        SiteResolves("inst-1", "SiteA");
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: false, Value: null, Quality: null, TimedOut: false,
                Success: false, ErrorMessage: "instance not found", DateTimeOffset.UtcNow));

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)));
        Assert.Equal("instance not found", ex.Message);
    }

    [Fact]
    public async Task WaitForAttribute_EncodesTargetValue_OnRequest()
    {
        // Value-equality only across the wire: the target value is encoded via the
        // canonical AttributeValueCodec, identical to how attribute values travel.
        SiteResolves("inst-1", "SiteA");
        RouteToWaitForAttributeRequest? captured = null;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: true, Value: true, Quality: "Good", TimedOut: false,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.NotNull(captured);
        Assert.Equal("Flag", captured!.AttributeName);
        Assert.Equal(TimeSpan.FromSeconds(30), captured.Timeout);
        Assert.Equal(AttributeValueCodec.Encode(true), captured.TargetValueEncoded);
        Assert.True(Guid.TryParse(captured.CorrelationId, out _));
    }

    [Fact]
    public async Task WaitForAttribute_IsNotBoundByMethodDeadline_WaitTimeoutGoverns()
    {
        // InboundAPI-029 (spec §6): unlike Call/GetAttributes/SetAttributes, the wait is
        // bounded by its OWN timeout, not the method deadline. Even with the method
        // deadline ALREADY cancelled, the wait still proceeds and returns the site's
        // wait-timeout result — the deadline does not cut it short.
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        await deadline.CancelAsync(); // method deadline already elapsed
        CancellationToken seen = default;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: false, Value: null, Quality: null, TimedOut: true,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithDeadline(deadline.Token);
        var matched = await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.False(matched);                      // the site's wait-timeout result, not a cancellation
        Assert.False(seen.IsCancellationRequested); // the wait token ignores the cancelled method deadline
        Assert.NotEqual(deadline.Token, seen);      // it is a per-wait token, not the method deadline
    }

    [Fact]
    public async Task WaitForAttribute_ExplicitToken_IsHonoured()
    {
        // An explicit caller token still applies (a tighter bound the script chose).
        SiteResolves("inst-1", "SiteA");
        using var explicitCts = new CancellationTokenSource();
        await explicitCts.CancelAsync();
        CancellationToken seen = default;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: false, Value: null, Quality: null, TimedOut: true,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1")
            .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30), explicitCts.Token);

        Assert.True(seen.IsCancellationRequested); // the explicit caller token cancels the wait
    }

    [Fact]
    public async Task WaitForAttribute_ClientDisconnect_CancelsTheWait()
    {
        // A client disconnect (the raw request-abort token, threaded via WithRequestAborted)
        // still cancels the wait even though the method deadline does not bound it.
        SiteResolves("inst-1", "SiteA");
        using var clientAbort = new CancellationTokenSource();
        await clientAbort.CancelAsync();
        CancellationToken seen = default;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: false, Value: null, Quality: null, TimedOut: true,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithRequestAborted(clientAbort.Token);
        await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.True(seen.IsCancellationRequested); // the client-abort token cancels the wait
    }

    [Fact]
    public async Task WaitForAttribute_SlowTimedOutResponse_NotPreemptedByLocalBackstop()
    {
        // InboundAPI-031: WaitForAttribute must NOT impose a local wait-timeout backstop.
        // The site enforces the wait timeout and returns Matched=false; the round trip is
        // bounded by CommunicationService's Ask (timeout + IntegrationTimeout). A local CTS
        // of `timeout + small grace` (the prior InboundAPI-029 approach) was TIGHTER than
        // that round-trip budget, so a slow-but-valid timed-out response would be cancelled
        // into an exception (a 500) instead of the spec §6 `false`. With the backstop
        // removed, a response arriving well after the (tiny) wait timeout still returns a
        // clean `false`, and the token the router observed was never cancelled by RouteHelper
        // — if a tight local backstop were reintroduced, the honoured token below would throw.
        SiteResolves("inst-1", "SiteA");
        CancellationToken seen = default;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(async ci =>
            {
                var token = (CancellationToken)ci[2];
                // Simulate the site enforcing the wait + a round trip far longer than the
                // tiny wait timeout; honour the token so a (re)introduced local backstop
                // would surface here as an OperationCanceledException.
                await Task.Delay(TimeSpan.FromMilliseconds(400), token);
                return new RouteToWaitForAttributeResponse(
                    ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                    Matched: false, Value: null, Quality: null, TimedOut: true,
                    Success: true, ErrorMessage: null, DateTimeOffset.UtcNow);
            });

        var matched = await CreateHelper().To("inst-1")
            .WaitForAttribute("Flag", true, TimeSpan.FromMilliseconds(20));

        Assert.False(matched);                       // clean spec §6 false, not a cancellation
        Assert.False(seen.IsCancellationRequested);  // no local wait-timeout backstop fired
    }

    [Fact]
    public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest()
    {
        SiteResolves("inst-1", "SiteA");
        var inboundExecutionId = Guid.NewGuid();
        RouteToWaitForAttributeRequest? captured = null;
        _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToWaitForAttributeResponse(
                ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
                Matched: true, Value: true, Quality: "Good", TimedOut: false,
                Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
        await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));

        Assert.NotNull(captured);
        Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
    }

    // --- SetAttribute(s) ---

    [Fact]
    public async Task SetAttribute_DelegatesToBatch_WithSingleEntry()
    {
        SiteResolves("inst-1", "SiteA");
        RouteToSetAttributesRequest? captured = null;
        _router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToSetAttributesResponse(
                ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").SetAttribute("setpoint", "42");

        Assert.NotNull(captured);
        Assert.Equal("42", captured!.AttributeValues["setpoint"]);
        Assert.Single(captured.AttributeValues);
    }

    [Fact]
    public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException()
    {
        SiteResolves("inst-1", "SiteA");
        _router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToSetAttributesResponse(
                ((RouteToSetAttributesRequest)ci[1]).CorrelationId, false, "write rejected", DateTimeOffset.UtcNow));

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => CreateHelper().To("inst-1").SetAttributes(
                new Dictionary<string, string> { ["x"] = "1" }));
        Assert.Equal("write rejected", ex.Message);
    }

    // --- InboundAPI-016: routed calls inherit the method deadline token ---

    [Fact]
    public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken()
    {
        // A natural script — Route.To("x").Call("s", p) — passes no token. The routed
        // call must still be bounded by the executing method's timeout: the helper
        // bound to a deadline token must forward THAT token to the router.
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        CancellationToken seen = default;
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithDeadline(deadline.Token);
        await bound.To("inst-1").Call("doWork");

        Assert.Equal(deadline.Token, seen);
    }

    [Fact]
    public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation()
    {
        // When the method timeout fires, an in-flight routed call must see the
        // cancellation rather than running orphaned past the deadline.
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
            .Returns(async ci =>
            {
                var token = (CancellationToken)ci[2];
                await Task.Delay(TimeSpan.FromSeconds(30), token);
                return new RouteToCallResponse(
                    ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow);
            });

        var bound = CreateHelper().WithDeadline(deadline.Token);
        deadline.CancelAfter(TimeSpan.FromMilliseconds(100));

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => bound.To("inst-1").Call("doWork"));
    }

    [Fact]
    public async Task Call_ExplicitToken_OverridesDeadlineToken()
    {
        // A script that DOES pass a (tighter) token must have that token honoured.
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        using var explicitCts = new CancellationTokenSource();
        CancellationToken seen = default;
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithDeadline(deadline.Token);
        await bound.To("inst-1").Call("doWork", null, explicitCts.Token);

        Assert.Equal(explicitCts.Token, seen);
    }

    // --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the
    //     inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId ---

    [Fact]
    public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull()
    {
        // A RouteHelper not bound to an inbound execution id (e.g. the Central UI
        // sandbox path) builds requests with ParentExecutionId null.
        SiteResolves("inst-1", "SiteA");
        RouteToCallRequest? captured = null;
        _router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").Call("doWork");

        Assert.NotNull(captured);
        Assert.Null(captured!.ParentExecutionId);
    }

    [Fact]
    public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest()
    {
        // A RouteHelper bound to the inbound request's ExecutionId must stamp that
        // id onto the routed RouteToCallRequest so the site script records it as
        // its ParentExecutionId.
        SiteResolves("inst-1", "SiteA");
        var inboundExecutionId = Guid.NewGuid();
        RouteToCallRequest? captured = null;
        _router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
        await bound.To("inst-1").Call("doWork");

        Assert.NotNull(captured);
        Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
        // ParentExecutionId is a separate concern from the per-op CorrelationId —
        // the helper still mints its own routed-call correlation id.
        Assert.True(Guid.TryParse(captured.CorrelationId, out _));
    }

    [Fact]
    public async Task WithParentExecutionId_PreservesDeadlineToken()
    {
        // The two builder methods compose — binding a parent execution id must
        // not drop a previously-bound deadline token.
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        CancellationToken seen = default;
        RouteToCallRequest? captured = null;
        _router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToCallResponse(
                ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper()
            .WithDeadline(deadline.Token)
            .WithParentExecutionId(Guid.NewGuid());
        await bound.To("inst-1").Call("doWork");

        Assert.Equal(deadline.Token, seen);
        Assert.NotNull(captured!.ParentExecutionId);
    }

    [Fact]
    public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
    {
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        CancellationToken seen = default;
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithDeadline(deadline.Token);
        await bound.To("inst-1").GetAttributes(new[] { "a" });

        Assert.Equal(deadline.Token, seen);
    }

    [Fact]
    public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
    {
        SiteResolves("inst-1", "SiteA");
        using var deadline = new CancellationTokenSource();
        CancellationToken seen = default;
        _router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
            .Returns(ci => new RouteToSetAttributesResponse(
                ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithDeadline(deadline.Token);
        await bound.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });

        Assert.Equal(deadline.Token, seen);
    }

    // --- InboundAPI-021: ParentExecutionId flows through Get/SetAttributes too ---

    [Fact]
    public async Task GetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
    {
        SiteResolves("inst-1", "SiteA");
        RouteToGetAttributesRequest? captured = null;
        _router.RouteToGetAttributesAsync("SiteA", Arg.Do<RouteToGetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").GetAttributes(new[] { "a" });

        Assert.NotNull(captured);
        Assert.Null(captured!.ParentExecutionId);
    }

    [Fact]
    public async Task GetAttributes_WithParentExecutionId_CarriesItOnRouteToGetAttributesRequest()
    {
        // Symmetric with Call: a RouteHelper bound to the inbound request's
        // ExecutionId stamps it onto the routed GetAttributes request so
        // future site-side audit can record the inbound→site link.
        SiteResolves("inst-1", "SiteA");
        var inboundExecutionId = Guid.NewGuid();
        RouteToGetAttributesRequest? captured = null;
        _router.RouteToGetAttributesAsync("SiteA", Arg.Do<RouteToGetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToGetAttributesResponse(
                ((RouteToGetAttributesRequest)ci[1]).CorrelationId,
                new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
        await bound.To("inst-1").GetAttributes(new[] { "a" });

        Assert.NotNull(captured);
        Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
    }

    [Fact]
    public async Task SetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
    {
        SiteResolves("inst-1", "SiteA");
        RouteToSetAttributesRequest? captured = null;
        _router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToSetAttributesResponse(
                ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));

        await CreateHelper().To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });

        Assert.NotNull(captured);
        Assert.Null(captured!.ParentExecutionId);
    }

    [Fact]
    public async Task SetAttributes_WithParentExecutionId_CarriesItOnRouteToSetAttributesRequest()
    {
        SiteResolves("inst-1", "SiteA");
        var inboundExecutionId = Guid.NewGuid();
        RouteToSetAttributesRequest? captured = null;
        _router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(ci => new RouteToSetAttributesResponse(
                ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));

        var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
        await bound.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });

        Assert.NotNull(captured);
        Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
    }

    // --- N3: typed unreachability at the Ask boundary ---

    [Fact]
    public async Task Call_AskTimeout_ThrowsSiteUnreachableException()
    {
        SiteResolves("inst", "SiteA");
        _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException<RouteToCallResponse>(
                new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));

        await Assert.ThrowsAsync<SiteUnreachableException>(
            () => CreateHelper().To("inst").Call("s"));
    }

    [Fact]
    public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException()
    {
        SiteResolves("inst", "SiteA");
        _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException<RouteToGetAttributesResponse>(
                new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));

        await Assert.ThrowsAsync<SiteUnreachableException>(
            () => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
    }
}
