using NSubstitute;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;

namespace ScadaLink.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);
    }

    // --- 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);
    }

    [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);
    }
}
