using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.TestSupport;

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

/// <summary>
/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling.
/// </summary>
public class ExternalSystemClientTests : IDisposable
{
    private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
    private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();

    /// <summary>
    /// Temp local databases opened by the Store-and-Forward tests, torn down in
    /// <see cref="Dispose"/>.
    /// </summary>
    private readonly List<TestLocalDb> _localDbs = new();

    /// <summary>
    /// Opens a real <c>ILocalDb</c> over a fresh temp file for a test that needs a live
    /// <see cref="StoreAndForwardStorage"/>. The store takes an <c>ILocalDb</c> and LocalDb
    /// has no in-memory mode, so each test gets its own file rather than a shared-cache
    /// in-memory database held open by a keep-alive connection.
    /// </summary>
    private TestLocalDb CreateLocalDb(string prefix)
    {
        var localDb = TestLocalDb.CreateTemp(prefix);
        _localDbs.Add(localDb);
        return localDb;
    }

    /// <summary>
    /// Disposes every temp local database and then deletes its files — in that order,
    /// because the master connection LocalDb holds anchors the WAL sidecars.
    /// </summary>
    public void Dispose()
    {
        foreach (var localDb in _localDbs)
        {
            var path = localDb.Path;
            localDb.Dispose();
            TestLocalDb.DeleteFiles(path);
        }

        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Configures the repository substitute for the name-keyed resolution path used by
    /// <c>ExternalSystemClient</c> (ExternalSystemGateway-011). A <c>null</c> system or
    /// method models a "not found" — the substitute returns <c>null</c> by default, so
    /// no stub is needed for the absent entity.
    /// </summary>
    private void StubResolution(ExternalSystemDefinition? system, ExternalSystemMethod? method)
    {
        if (system != null)
        {
            _repository.GetExternalSystemByNameAsync(system.Name, Arg.Any<CancellationToken>())
                .Returns(system);
        }

        if (system != null && method != null)
        {
            _repository.GetMethodByNameAsync(system.Id, method.Name, Arg.Any<CancellationToken>())
                .Returns(method);
        }
    }

    [Fact]
    public async Task Call_SystemNotFound_ReturnsError()
    {
        StubResolution(system: null, method: null);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("nonexistent", "method");

        Assert.False(result.Success);
        Assert.Contains("not found", result.ErrorMessage);
    }

    [Fact]
    public async Task Call_MethodNotFound_ReturnsError()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        StubResolution(system, method: null);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "missingMethod");

        Assert.False(result.Success);
        Assert.Contains("not found", result.ErrorMessage);
    }

    [Fact]
    public async Task Call_SuccessfulHttp_ReturnsResponse()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };

        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "getData");

        Assert.True(result.Success);
        Assert.Contains("42", result.ResponseJson!);
    }

    [Fact]
    public async Task Call_Transient500_ReturnsTransientError()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };

        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "failMethod");

        Assert.False(result.Success);
        Assert.Contains("Transient error", result.ErrorMessage);
    }

    [Fact]
    public async Task Call_Permanent400_ReturnsPermanentError()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };

        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "badMethod");

        Assert.False(result.Success);
        Assert.Contains("Permanent error", result.ErrorMessage);
    }

    [Fact]
    public async Task CachedCall_SystemNotFound_ReturnsError()
    {
        StubResolution(system: null, method: null);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CachedCallAsync("nonexistent", "method");

        Assert.False(result.Success);
        Assert.Contains("not found", result.ErrorMessage);
    }

    [Fact]
    public async Task CachedCall_Success_ReturnsDirectly()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };

        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CachedCallAsync("TestAPI", "getData");

        Assert.True(result.Success);
        Assert.False(result.WasBuffered);
    }

    // ── ExternalSystemGateway-001: buffered-call delivery handler ──

    private static ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage BufferedCall(
        string systemName, string methodName) =>
        new()
        {
            Id = Guid.NewGuid().ToString("N"),
            Category = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
            Target = systemName,
            PayloadJson =
                $$"""{"SystemName":"{{systemName}}","MethodName":"{{methodName}}","Parameters":null}""",
        };

    [Fact]
    public async Task DeliverBuffered_SuccessfulHttp_ReturnsTrue()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getData"));

        Assert.True(delivered);
    }

    [Fact]
    public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
    {
        StubResolution(system: null, method: null);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var delivered = await client.DeliverBufferedAsync(BufferedCall("GoneAPI", "method"));

        Assert.False(delivered); // permanent — the S&F engine parks the message
    }

    [Fact]
    public async Task DeliverBuffered_Transient500_ThrowsSoEngineRetries()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await Assert.ThrowsAsync<TransientExternalSystemException>(
            () => client.DeliverBufferedAsync(BufferedCall("TestAPI", "failMethod")));
    }

    // ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──

    [Fact]
    public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
    {
        // No repository / HTTP stubs needed — deserialization fails before any
        // resolution or HTTP call. If the JsonException were to escape (the
        // pre-018 behaviour) the S&F engine would treat it as transient and
        // retry the same poison row forever.
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var poisonMessage = new StoreAndForwardMessage
        {
            Id = Guid.NewGuid().ToString("N"),
            Category = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
            Target = "TestAPI",
            // Truncated mid-write — `{` opens an object that never closes.
            PayloadJson = "{\"SystemName\":\"TestAPI\",\"MethodName\":\"get",
        };

        var delivered = await client.DeliverBufferedAsync(poisonMessage);

        Assert.False(delivered); // permanent — the S&F engine parks the message
    }

    // ── ExternalSystemGateway-003: CachedCall must not double-dispatch ──

    [Fact]
    public async Task CachedCall_TransientFailure_DoesNotImmediatelyRedispatchViaRegisteredHandler()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        // The HTTP layer always fails transiently (500).
        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        // A real S&F service with a registered delivery handler that counts invocations.
        var localDb = CreateLocalDb("EsgDoubleDispatch");
        var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        var sfOptions = new StoreAndForwardOptions
        {
            DefaultRetryInterval = TimeSpan.FromMinutes(10),
            RetryTimerInterval = TimeSpan.FromMinutes(10),
        };
        var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);

        var handlerInvocations = 0;
        sf.RegisterDeliveryHandler(
            ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
            _ => { Interlocked.Increment(ref handlerInvocations); return Task.FromResult(false); });

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance,
            storeAndForward: sf);

        var result = await client.CachedCallAsync("TestAPI", "postData");

        // The call already made one HTTP attempt; EnqueueAsync must NOT invoke the
        // registered handler again synchronously (which would dispatch a 2nd request).
        Assert.True(result.WasBuffered);
        Assert.Equal(0, handlerInvocations);
    }

    // ── ExternalSystemGateway-002: per-system call timeout ──

    [Fact]
    public async Task Call_SlowSystem_TimesOutAsTransientErrorWithinConfiguredWindow()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        // Handler that hangs far longer than the configured timeout and the test budget.
        var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        // Configure a short timeout so the call must fail quickly.
        var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMilliseconds(200) };
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance,
            options: Microsoft.Extensions.Options.Options.Create(options));

        var sw = System.Diagnostics.Stopwatch.StartNew();
        var result = await client.CallAsync("TestAPI", "getData");
        sw.Stop();

        Assert.False(result.Success);
        Assert.Contains("Transient error", result.ErrorMessage);
        Assert.Contains("Timeout", result.ErrorMessage);
        // Must fail near the configured 200ms, well before HttpClient's default 100s.
        Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
            $"Call took {sw.Elapsed}, expected to time out near the configured 200ms window");
    }

    /// <summary>
    /// ExternalSystemGateway-019 regression: <see cref="HttpClient.Timeout"/> defaults
    /// to 100 seconds and is enforced internally by <c>SendAsync</c> via its own
    /// private CTS — a <see cref="TaskCanceledException"/> raised by that internal
    /// CTS does not trip either the caller's token or the gateway's timeout CTS,
    /// so any operator-configured <see cref="ExternalSystemGatewayOptions.DefaultHttpTimeout"/>
    /// greater than 100 s would be silently clipped. The fix sets
    /// <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> on the rented client
    /// so the per-call <c>CancellationTokenSource(DefaultHttpTimeout)</c> in
    /// <c>InvokeHttpAsync</c> is the sole timeout source. This test verifies the
    /// property is in fact set before any request is dispatched.
    /// </summary>
    [Fact]
    public async Task Call_DisablesHttpClientFrameworkTimeoutSoLongTimeoutsArentClipped()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{}"));
        // Sanity check: the factory-supplied default is the framework's 100 s — exactly
        // the value the fix must override so an operator-configured timeout > 100 s is
        // honoured verbatim.
        Assert.Equal(TimeSpan.FromSeconds(100), httpClient.Timeout);

        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance,
            options: Microsoft.Extensions.Options.Options.Create(options));

        var result = await client.CallAsync("TestAPI", "getData");

        Assert.True(result.Success);
        // After InvokeHttpAsync runs, the rented client's Timeout must have been
        // set to InfiniteTimeSpan — proving the framework-default 100 s clip is
        // disabled and the per-call CTS built from DefaultHttpTimeout is the
        // sole timeout source.
        Assert.Equal(Timeout.InfiniteTimeSpan, httpClient.Timeout);
    }

    [Fact]
    public async Task Call_CallerCancellation_IsNotMisreportedAsTimeout()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance,
            options: Microsoft.Extensions.Options.Options.Create(options));

        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));

        // Caller-initiated cancellation must surface as OperationCanceledException,
        // not be swallowed as a transient timeout error.
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token));
    }

    // ── ExternalSystemGateway-004: per-system retry settings honoured for cached calls ──

    [Fact]
    public async Task CachedCall_TransientFailure_BuffersWithSystemRetrySettings()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none")
        {
            Id = 1,
            MaxRetries = 7,
            RetryDelay = TimeSpan.FromSeconds(42),
        };
        var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var localDb = CreateLocalDb("EsgRetry");
        var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        // S&F defaults deliberately different from the system's settings.
        var sfOptions = new StoreAndForwardOptions
        {
            DefaultMaxRetries = 3,
            DefaultRetryInterval = TimeSpan.FromMinutes(10),
            RetryTimerInterval = TimeSpan.FromMinutes(10),
        };
        var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
            storeAndForward: sf);

        // Audit Log #23 (ExecutionId Task 4): a known execution id / source
        // script so the gateway -> EnqueueAsync hop can be asserted below.
        var executionId = Guid.NewGuid();
        const string sourceScript = "ScriptActor:CheckPressure";

        var result = await client.CachedCallAsync(
            "TestAPI", "postData",
            executionId: executionId, sourceScript: sourceScript);
        Assert.True(result.WasBuffered);

        var depth = await storage.GetBufferDepthByCategoryAsync();
        Assert.Equal(1, depth[ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]);

        var buffered = ReadBufferedRetrySettings(localDb.Db);
        Assert.Equal(7, buffered.MaxRetries);
        Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs);

        // ExecutionId Task 4: the gateway must forward executionId / sourceScript
        // into EnqueueAsync, and the S&F layer must persist them on the
        // sf_messages row so the retry loop can stamp the right provenance.
        Assert.Equal(executionId, buffered.ExecutionId);
        Assert.Equal(sourceScript, buffered.SourceScript);
    }

    private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript)
        ReadBufferedRetrySettings(ILocalDb localDb)
    {
        // CreateConnection hands back an already-open, pragma-configured connection.
        using var conn = localDb.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText =
            "SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages";
        using var reader = cmd.ExecuteReader();
        Assert.True(reader.Read(), "expected exactly one buffered message");
        var result = (
            reader.GetInt32(0),
            reader.GetInt64(1),
            reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)),
            reader.IsDBNull(3) ? null : reader.GetString(3));
        Assert.False(reader.Read(), "expected exactly one buffered message");
        return result;
    }

    [Fact]
    public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
    {
        // ExternalSystemGateway-015: the Store-and-Forward engine interprets a stored
        // MaxRetries of 0 as "no limit" (retry forever) — see StoreAndForwardMessage.cs
        // and the retry-sweep guard `MaxRetries > 0 && ...`. The entity's non-nullable
        // int default is also 0, so passing 0 verbatim would buffer every cached call
        // as an unbounded retry loop. The ESG must therefore treat the entity's
        // MaxRetries == 0 as "unset" and pass null, so the bounded S&F default applies.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none")
        {
            Id = 1,
            MaxRetries = 0,
            RetryDelay = TimeSpan.FromSeconds(5),
        };
        var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var localDb = CreateLocalDb("EsgRetryZero");
        var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        var sfOptions = new StoreAndForwardOptions
        {
            DefaultMaxRetries = 99,
            DefaultRetryInterval = TimeSpan.FromMinutes(10),
            RetryTimerInterval = TimeSpan.FromMinutes(10),
        };
        var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
            storeAndForward: sf);

        await client.CachedCallAsync("TestAPI", "postData");

        var (maxRetries, _, _, _) = ReadBufferedRetrySettings(localDb.Db);
        // Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
        Assert.Equal(99, maxRetries);
        Assert.NotEqual(0, maxRetries);
    }

    // ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ──

    [Fact]
    public async Task Call_SuccessfulHttp_DisposesRequestAndResponse()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed");
        Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed");
    }

    [Fact]
    public async Task Call_PermanentFailure_StillDisposesRequestAndResponse()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "badMethod");

        Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed on the error path");
        Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed on the error path");
    }

    // ── ExternalSystemGateway-006: BuildUrl — empty path must not append a trailing slash ──

    [Fact]
    public async Task Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
        var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "root");

        Assert.Equal("https://api.example.com/api", handler.LastUri!.ToString());
    }

    [Fact]
    public async Task Call_MethodWithPath_BuildsExpectedUrl()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.Equal("https://api.example.com/api/data", handler.LastUri!.ToString());
    }

    // ── ExternalSystemGateway-007: external error body must be truncated, not echoed verbatim ──

    [Fact]
    public async Task Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var hugeBody = new string('X', 500_000);
        var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody);
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "badMethod");

        Assert.False(result.Success);
        // The error message must be bounded — a misbehaving endpoint cannot inflate
        // every script-visible error string / event-log entry.
        Assert.True(result.ErrorMessage!.Length < 4096,
            $"Error message was {result.ErrorMessage.Length} chars — expected it to be truncated");
    }

    // ── ExternalSystemGateway-008: cancellation of a CachedCall must not be buffered ──

    [Fact]
    public async Task CachedCall_CallerCancellation_IsNotBufferedAsTransient()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var localDb = CreateLocalDb("EsgCancel");
        var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        var sfOptions = new StoreAndForwardOptions
        {
            DefaultRetryInterval = TimeSpan.FromMinutes(10),
            RetryTimerInterval = TimeSpan.FromMinutes(10),
        };
        var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);

        var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
            storeAndForward: sf,
            options: Microsoft.Extensions.Options.Options.Create(options));

        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));

        // Caller asked to abandon the work — it must NOT be buffered for retry.
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => client.CachedCallAsync("TestAPI", "postData", cancellationToken: cts.Token));

        var depth = await storage.GetBufferDepthByCategoryAsync();
        Assert.False(
            depth.TryGetValue(ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0,
            "A caller-cancelled CachedCall must not be buffered for retry");
    }

    // ── ExternalSystemGateway-014: BuildUrl query-string, ApplyAuth, connection errors ──

    [Fact]
    public async Task Call_GetWithParameters_AppendsEscapedQueryString()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
        {
            ["q"] = "a b&c",
            ["page"] = 2,
        });

        // AbsoluteUri preserves percent-encoding; the '&' inside a value must be
        // escaped so it is not mistaken for a parameter separator.
        var uri = handler.LastUri!.AbsoluteUri;
        Assert.StartsWith("https://api.example.com/search?", uri);
        Assert.Contains("q=a%20b%26c", uri);
        Assert.Contains("page=2", uri);
    }

    [Fact]
    public async Task Call_GetWithAllNullParameters_DoesNotAppendTrailingQuestionMark()
    {
        // ExternalSystemGateway-017: a GET method invoked with a non-empty parameter
        // dictionary whose values are all null has an effectively empty query string.
        // The URL must be identical to the no-parameters case — no bare trailing '?'.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
        {
            ["q"] = null,
            ["page"] = null,
        });

        var uri = handler.LastUri!.AbsoluteUri;
        Assert.Equal("https://api.example.com/search", uri);
        Assert.DoesNotContain("?", uri);
    }

    [Fact]
    public async Task Call_PostWithParameters_SendsJsonBody()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "create", new Dictionary<string, object?> { ["name"] = "widget" });

        Assert.Equal("https://api.example.com/create", handler.LastUri!.ToString());
        Assert.Contains("\"name\":\"widget\"", handler.LastBody);
    }

    [Fact]
    public async Task Call_ApiKeyAuthWithDefaultHeader_SendsXApiKeyHeader()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
        {
            Id = 1,
            AuthConfiguration = "secret-key-123",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.LastHeaders!.TryGetValues("X-API-Key", out var values));
        Assert.Equal("secret-key-123", values!.Single());
    }

    [Fact]
    public async Task Call_ApiKeyAuthWithCustomHeader_SendsNamedHeader()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
        {
            Id = 1,
            AuthConfiguration = "Authorization-Token:abc",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.LastHeaders!.TryGetValues("Authorization-Token", out var values));
        Assert.Equal("abc", values!.Single());
    }

    [Fact]
    public async Task Call_BasicAuth_SendsBase64AuthorizationHeader()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "basic")
        {
            Id = 1,
            AuthConfiguration = "alice:s3cret",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        var auth = handler.LastHeaders!.Authorization;
        Assert.NotNull(auth);
        Assert.Equal("Basic", auth!.Scheme);
        var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter!));
        Assert.Equal("alice:s3cret", decoded);
    }

    [Fact]
    public async Task ApiKeyAuth_JsonConfig_SetsConfiguredHeaderAndKeepsColons()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
        {
            Id = 1,
            AuthConfiguration = "{\"header\":\"X-Auth\",\"key\":\"ab:cd:ef\"}",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.LastHeaders!.TryGetValues("X-Auth", out var values));
        Assert.Equal("ab:cd:ef", values!.Single());
    }

    [Fact]
    public async Task ApiKeyAuth_JsonConfig_DefaultHeader()
    {
        // The UI-placeholder shape ({"key":"xyz"}) — silently misparsed by the
        // legacy colon-split before Task 15 (it produced header name '{"key').
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
        {
            Id = 1,
            AuthConfiguration = "{\"key\":\"xyz\"}",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.LastHeaders!.TryGetValues("X-API-Key", out var values));
        Assert.Equal("xyz", values!.Single());
    }

    [Fact]
    public async Task BasicAuth_JsonConfig_EncodesUsernamePassword()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "basic")
        {
            Id = 1,
            AuthConfiguration = "{\"username\":\"u\",\"password\":\"p:w\"}",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        var auth = handler.LastHeaders!.Authorization;
        Assert.NotNull(auth);
        Assert.Equal("Basic", auth!.Scheme);
        var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter!));
        Assert.Equal("u:p:w", decoded);
    }

    [Fact]
    public async Task ApiKeyAuth_LegacyColonConfig_StillWorks()
    {
        // Backward compatibility: the legacy "HeaderName:KeyValue" colon form must
        // keep working for existing rows.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
        {
            Id = 1,
            AuthConfiguration = "X-Custom:secret",
        };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getData");

        Assert.True(handler.LastHeaders!.TryGetValues("X-Custom", out var values));
        Assert.Equal("secret", values!.Single());
    }

    [Fact]
    public async Task Call_ConnectionError_IsClassifiedAsTransient()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        // A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
        var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused"));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "getData");

        Assert.False(result.Success);
        Assert.Contains("Transient error", result.ErrorMessage);
    }

    // ── ExternalSystemGateway-012: permanent failures must be logged ──

    [Fact]
    public async Task Call_PermanentFailure_LogsAWarning()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var logger = new CapturingLogger<ExternalSystemClient>();
        var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);

        await client.CallAsync("TestAPI", "badMethod");

        // The design doc requires permanent failures to be surfaced to Site Event
        // Logging — the gateway must emit at least a warning, not stay silent.
        Assert.Contains(logger.Entries, e =>
            e.Level >= LogLevel.Warning && e.Message.Contains("TestAPI"));
    }

    [Fact]
    public async Task PerSystemTimeout_OverridesGatewayDefault()
    {
        // System timeout 1s; handler black-holes far longer; gateway default is 30s.
        var system = new ExternalSystemDefinition("slow", "http://x", "none") { Id = 1, TimeoutSeconds = 1 };
        var method = new ExternalSystemMethod("m", "GET", "/p") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        // No options override → DefaultHttpTimeout is the 30s default; only the
        // per-system 1s should govern the round-trip.
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository,
            NullLogger<ExternalSystemClient>.Instance);

        var sw = System.Diagnostics.Stopwatch.StartNew();
        var result = await client.CallAsync("slow", "m");
        sw.Stop();

        Assert.False(result.Success);
        Assert.Contains("Timeout", result.ErrorMessage);
        Assert.True(sw.Elapsed < TimeSpan.FromSeconds(4),
            "per-system 1s timeout must apply, not the 30s default");
    }

    [Fact]
    public async Task Call_TransientFailure_DoesNotLogAtWarningOrAbove()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
        var httpClient = new HttpClient(handler);
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);

        var logger = new CapturingLogger<ExternalSystemClient>();
        var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);

        await client.CallAsync("TestAPI", "failMethod");

        // A transient failure is normal operation handled by retry/S&F — it must not
        // be logged at warning level (only permanent failures are).
        Assert.DoesNotContain(logger.Entries, e => e.Level >= LogLevel.Warning);
    }

    /// <summary>Test helper: an ILogger that records every entry for assertions.</summary>
    private sealed class CapturingLogger<T> : ILogger<T>
    {
        public List<(LogLevel Level, string Message)> Entries { get; } = new();

        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId, TState state, Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            Entries.Add((logLevel, formatter(state, exception)));
        }

        private sealed class NullScope : IDisposable
        {
            public static readonly NullScope Instance = new();
            public void Dispose() { }
        }
    }

    // ── ESG: bound outbound response buffering (MaxResponseBodyBytes) ──

    [Fact]
    public async Task OversizedResponseBody_FailsPermanently_WithoutBufferingWholeBody()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        // The endpoint returns a 2 MiB body — far above the 1 KiB cap below.
        // Hand out a fresh HttpClient per CreateClient call: the client mutates
        // HttpClient.Timeout before its first request, so a single shared instance
        // cannot serve the two calls this test makes.
        var hugeBody = new string('x', 2 * 1024 * 1024);
        _httpClientFactory.CreateClient(Arg.Any<string>())
            .Returns(_ => new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, hugeBody)));

        // A real S&F service so we can assert the oversized response is NOT buffered.
        var localDb = CreateLocalDb("EsgOversize");
        var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        var sfOptions = new StoreAndForwardOptions
        {
            DefaultRetryInterval = TimeSpan.FromMinutes(10),
            RetryTimerInterval = TimeSpan.FromMinutes(10),
        };
        var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);

        var options = new ExternalSystemGatewayOptions { MaxResponseBodyBytes = 1024 };
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
            storeAndForward: sf,
            options: Microsoft.Extensions.Options.Options.Create(options));

        // Sync call: oversize is a permanent failure — retrying will not shrink it.
        var result = await client.CallAsync("TestAPI", "postData");
        Assert.False(result.Success);
        Assert.Contains("exceeded", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);

        // Cached call of the same shape: a permanent failure must NOT be buffered
        // into S&F (buffering it would defeat the response-size cap).
        var cached = await client.CachedCallAsync("TestAPI", "postData");
        Assert.False(cached.WasBuffered);

        var depth = await storage.GetBufferDepthByCategoryAsync();
        Assert.False(
            depth.TryGetValue(ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0,
            "An oversized (permanent-failure) response must not be buffered for retry");
    }

    /// <summary>
    /// Test helper: mock HTTP message handler.
    /// </summary>
    private class MockHttpMessageHandler : HttpMessageHandler
    {
        private readonly HttpStatusCode _statusCode;
        private readonly string _body;

        public MockHttpMessageHandler(HttpStatusCode statusCode, string body)
        {
            _statusCode = statusCode;
            _body = body;
        }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            return Task.FromResult(new HttpResponseMessage(_statusCode)
            {
                Content = new StringContent(_body)
            });
        }
    }

    /// <summary>
    /// Test helper: tracks disposal of the request and the response content.
    /// </summary>
    private class DisposalTrackingHandler : HttpMessageHandler
    {
        private readonly HttpStatusCode _statusCode;
        private readonly string _body;

        public DisposalTrackingHandler(HttpStatusCode statusCode, string body)
        {
            _statusCode = statusCode;
            _body = body;
        }

        public bool RequestDisposed { get; private set; }
        public bool ResponseContentDisposed { get; private set; }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            request.Content = new TrackingContent(string.Empty, () => RequestDisposed = true);
            var response = new HttpResponseMessage(_statusCode)
            {
                Content = new TrackingContent(_body, () => ResponseContentDisposed = true)
            };
            return Task.FromResult(response);
        }

        private sealed class TrackingContent : StringContent
        {
            private readonly Action _onDispose;
            public TrackingContent(string content, Action onDispose) : base(content) => _onDispose = onDispose;

            protected override void Dispose(bool disposing)
            {
                if (disposing) _onDispose();
                base.Dispose(disposing);
            }
        }
    }

    /// <summary>
    /// Test helper: captures the URI, headers and body of the last request.
    /// </summary>
    private class RequestCapturingHandler : HttpMessageHandler
    {
        private readonly HttpStatusCode _statusCode;
        private readonly string _body;

        public RequestCapturingHandler(HttpStatusCode statusCode, string body)
        {
            _statusCode = statusCode;
            _body = body;
        }

        public Uri? LastUri { get; private set; }
        public HttpRequestHeaders? LastHeaders { get; private set; }
        public string? LastBody { get; private set; }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            LastUri = request.RequestUri;
            LastHeaders = request.Headers;
            LastBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
            return new HttpResponseMessage(_statusCode)
            {
                Content = new StringContent(_body)
            };
        }
    }

    /// <summary>Test helper: an HTTP handler that throws a connection-level exception.</summary>
    private class ThrowingHttpMessageHandler : HttpMessageHandler
    {
        private readonly Exception _exception;
        public ThrowingHttpMessageHandler(Exception exception) => _exception = exception;

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
            => throw _exception;
    }

    /// <summary>
    /// Test helper: an HTTP handler that hangs until cancelled (simulates a slow/hung system).
    /// </summary>
    private class HangingHttpMessageHandler : HttpMessageHandler
    {
        private readonly TimeSpan _delay;

        public HangingHttpMessageHandler(TimeSpan delay) => _delay = delay;

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            await Task.Delay(_delay, cancellationToken);
            return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
        }
    }

    /// <summary>
    /// ExternalSystemGateway-022: an HTTP method outside the documented allowlist
    /// (GET/POST/PUT/PATCH/DELETE) must be rejected at the gateway entry with a
    /// clear <see cref="ArgumentException"/> — not silently dispatched as a
    /// non-standard verb whose parameters land in neither body nor query string.
    /// </summary>
    [Theory]
    [InlineData("FOO")]
    [InlineData("DLETE")]                 // common typo for DELETE
    [InlineData("GIT")]
    [InlineData("OPTIONS")]               // valid HTTP verb but outside the gateway's documented set
    [InlineData("HEAD")]
    public async Task Call_UnsupportedHttpMethod_ThrowsArgumentException(string httpMethod)
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("badVerb", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        // No handler is needed — validation rejects the verb before any HTTP traffic.
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(new RequestCapturingHandler(HttpStatusCode.OK, "{}")));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var ex = await Assert.ThrowsAsync<ArgumentException>(
            () => client.CallAsync("TestAPI", "badVerb"));
        Assert.Contains(httpMethod, ex.Message);
        Assert.Contains("GET", ex.Message);
    }

    [Theory]
    [InlineData("GET")]
    [InlineData("get")]                   // case-insensitive — operator-authored strings
    [InlineData("Post")]
    [InlineData("PATCH")]
    [InlineData("delete")]
    public async Task Call_DocumentedHttpMethod_IsAccepted(string httpMethod)
    {
        // The allowlist is case-insensitive (the entity column is free-form).
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("doIt", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "doIt");

        Assert.True(result.Success);
    }

    // ── ExternalSystemGateway: {param} path-template substitution ──

    [Fact]
    public async Task PathTemplate_SubstitutesAndConsumesParameter()
    {
        // A `{id}` placeholder in the method path is replaced with the escaped
        // parameter value and the consumed name is removed from the query string.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "getRecipe", new Dictionary<string, object?>
        {
            ["id"] = "R 1",
            ["filter"] = "x",
        });

        var uri = handler.LastUri!.AbsoluteUri;
        // The value is substituted into the path (escaped) and consumed — it must
        // NOT reappear in the query string; the remaining parameter still does.
        Assert.Equal("https://api.example.com/recipes/R%201?filter=x", uri);
        Assert.DoesNotContain("id=", uri);
    }

    [Fact]
    public async Task PathTemplate_Post_ConsumedParamExcludedFromBody()
    {
        // A path-template parameter consumed by a POST path must not also be
        // serialized into the JSON body.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("addLine", "POST", "/orders/{orderId}/lines") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        await client.CallAsync("TestAPI", "addLine", new Dictionary<string, object?>
        {
            ["orderId"] = 5,
            ["qty"] = 2,
        });

        Assert.Equal("https://api.example.com/orders/5/lines", handler.LastUri!.ToString());
        Assert.Contains("\"qty\":2", handler.LastBody);
        Assert.DoesNotContain("orderId", handler.LastBody);
    }

    [Fact]
    public async Task PathTemplate_MissingParameter_ThrowsClearError()
    {
        // A `{id}` placeholder with no matching parameter is an authoring error —
        // reject it with a clear ArgumentException naming the placeholder.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);

        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(new RequestCapturingHandler(HttpStatusCode.OK, "{}")));

        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var ex = await Assert.ThrowsAsync<ArgumentException>(
            () => client.CallAsync("TestAPI", "getRecipe"));
        Assert.Contains("id", ex.Message);
    }

    [Fact]
    public async Task DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks()
    {
        // Arch-review R2 N2: a method whose path gained a {param} placeholder AFTER
        // calls were buffered throws ArgumentException deterministically for every
        // already-buffered message — it must park immediately (permanent), not burn
        // MaxRetries "transient" attempts (the S&F sweep's catch-all retries any throw).
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);
        _httpClientFactory.CreateClient(Arg.Any<string>())
            .Returns(new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{}")));
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        // BufferedCall carries Parameters:null — the {id} placeholder has no value.
        var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getRecipe"));

        Assert.False(delivered);
    }

    [Fact]
    public async Task DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks()
    {
        // ValidateHttpMethod throws the same deterministic ArgumentException shape.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("oddVerb", "FOO", "/p") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "oddVerb"));

        Assert.False(delivered);
    }

    [Fact]
    public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset()
    {
        // Arch-review R2 N4: the bounded read must honor the declared charset —
        // UTF-8-decoding an iso-8859-1 body turns every non-ASCII byte into U+FFFD.
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);
        var handler = new ByteBodyHandler(
            HttpStatusCode.OK,
            Encoding.Latin1.GetBytes("température 25°C"),
            "text/plain; charset=iso-8859-1");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "getData");

        Assert.True(result.Success);
        Assert.Contains("température 25°C", result.ResponseJson);
    }

    [Fact]
    public async Task Call_UnknownCharset_FallsBackToUtf8()
    {
        var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
        var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
        StubResolution(system, method);
        var handler = new ByteBodyHandler(
            HttpStatusCode.OK, Encoding.UTF8.GetBytes("ok"), "text/plain; charset=not-a-charset");
        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
        var client = new ExternalSystemClient(
            _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

        var result = await client.CallAsync("TestAPI", "getData");

        Assert.True(result.Success);
        Assert.Contains("ok", result.ResponseJson);
    }

    /// <summary>Test helper: returns a fixed byte body with an explicit Content-Type.</summary>
    private sealed class ByteBodyHandler(
        HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var response = new HttpResponseMessage(statusCode) { Content = new ByteArrayContent(body) };
            response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
            return Task.FromResult(response);
        }
    }
}
