using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using Microsoft.Data.Sqlite;
using System.Data.Common;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

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

/// <summary>
/// Auth re-arch (A+B): <see cref="EndpointExtensions.HandleInboundApiRequest"/> now
/// authenticates with the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier — a Bearer
/// token (<c>Authorization: Bearer sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) replaces the
/// raw X-API-Key header, and a key's <c>Scopes</c> set (the API-method names it may
/// call) replaces the per-method approval table for authorization. These tests drive
/// the end-to-end POST /api/{methodName} flow through a TestServer, seeding the
/// library SQLite store via <see cref="ApiKeyAdminCommands"/>, so a regression in any
/// of the seams below would be caught here:
///
///   1. happy path — valid Bearer + in-scope → 200 + script result body.
///   2. valid key, method NOT in scope → 403 (authz).
///   3. unknown method → 403 with the SAME body as not-in-scope
///      (enumeration-safety — neither the status nor the message reveals which).
///   4. missing / garbage Bearer → 401 (generic, no stage leak).
///   5. revoked key → 401.
///   6. invalid JSON body → 400 + sanitized error.
///   7. parameter validation failure → 400 + ParameterValidator's error message.
///   8. script failure → 500 + ErrorMessage in body.
///   9. successful auth must publish the verified key's DISPLAY NAME into
///      <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the
///      AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
/// </summary>
public sealed class EndpointExtensionsTests : IDisposable
{
    // The pepper used to back the seeded keys. The verifier resolves it from
    // configuration under the name ApiKeyOptions.PepperSecretName; the seed path
    // resolves it through the same IApiKeyPepperProvider, so both agree.
    private const string Pepper = "test-pepper-at-least-16-chars-long";
    private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper";
    private const string TokenPrefix = "sbk";
    private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";

    // Review #1: the EXACT canonical 403 body shared by the "method not found" and
    // "key not in scope" branches. Asserting against this literal (not just that the
    // two bodies are equal-to-each-other) catches a single-branch divergence where
    // BOTH branches change in lockstep but away from the agreed enumeration-safe
    // body — the equal-to-each-other check alone would still pass in that case.
    // Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. Task 5 (C3): the
    // error body now additionally carries the machine-readable "code" field; both 403
    // branches still emit this identical body, preserving enumeration-safety.
    private const string NotApprovedBodyJson =
        """{"error":"API key not approved for this method","code":"NOT_APPROVED"}""";

    // Each test gets its own throwaway SQLite database so seeded keys never leak
    // between tests; the file is deleted on Dispose.
    private readonly string _sqlitePath =
        Path.Combine(Path.GetTempPath(), $"inbound-api-keys-{Guid.NewGuid():N}.sqlite");

    /// <summary>
    /// Inline middleware that captures the value at
    /// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> after the
    /// inbound endpoint runs, so the actor-stash invariant can be asserted from
    /// the test without running the real AuditWriteMiddleware.
    /// </summary>
    private sealed class AuditActorCapture
    {
        public string? CapturedActor { get; set; }
    }

    private static ApiMethod SeedMethod(
        int id, string name, string script, string? paramDefs = null)
    {
        return new ApiMethod(name, script)
        {
            Id = id,
            TimeoutSeconds = 10,
            ParameterDefinitions = paramDefs,
        };
    }

    [Fact]
    public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson()
    {
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        // Seed a key whose scope set contains the method name "echo".
        var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
            scopes: new[] { "echo" });
        var client = host.GetTestClient();

        var request = BuildPost("echo", """{"value":7}""", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("7", body);
    }

    [Fact]
    public async Task HappyPath_ValidXApiKeyInScope_Returns200WithScriptResultJson()
    {
        // X-API-Key restore: the SAME seeded key, presented in the alternate
        // "X-API-Key: sbk_<keyId>_<secret>" header (raw token, no "Bearer " prefix),
        // must authenticate through the SAME shared verifier and reach the script —
        // proving the broadened credential extraction feeds X-API-Key to VerifyAsync.
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
            scopes: new[] { "echo" });
        var client = host.GetTestClient();

        var request = BuildPostWithApiKeyHeader("echo", """{"value":7}""", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("7", body);
    }

    [Fact]
    public async Task BothHeadersPresent_AuthorizationWins_ValidBearerBeatsGarbageXApiKey()
    {
        // Precedence: when BOTH headers are present, the Authorization header is the
        // credential passed to the verifier. A VALID Bearer token alongside a GARBAGE
        // X-API-Key must authenticate (200) — proving Authorization, not X-API-Key,
        // was the value extracted and verified.
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var validToken = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
            scopes: new[] { "echo" });
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo")
        {
            Content = new StringContent("""{"value":7}""", Encoding.UTF8, "application/json"),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", validToken);
        request.Headers.Add("X-API-Key", "sbk_garbage_not-a-real-secret");
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("7", body);
    }

    [Fact]
    public async Task BothHeadersPresent_AuthorizationWins_GarbageBearerBeatsValidXApiKey_Returns401()
    {
        // Precedence (the converse): a GARBAGE Bearer alongside an otherwise-VALID
        // X-API-Key must STILL fail with 401 — the present (non-blank) Authorization
        // header takes precedence and is the value handed to the verifier, so the
        // valid X-API-Key is never consulted. This nails down that Authorization wins
        // because it is PRESENT, not because it happened to be valid.
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var validToken = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
            scopes: new[] { "echo" });
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo")
        {
            Content = new StringContent("""{"value":7}""", Encoding.UTF8, "application/json"),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-real-token");
        request.Headers.Add("X-API-Key", validToken);
        var response = await client.SendAsync(request);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task NeitherHeader_Returns401()
    {
        // X-API-Key restore must NOT weaken the failure path: with NEITHER the
        // Authorization nor the X-API-Key header present, the empty credential still
        // fails the verifier and maps to the same generic 401.
        var method = SeedMethod(1, "echo", "return 1;");

        using var host = await BuildHostAsync(method);
        await SeedKeyAsync(host, "key1", "echo-caller", new[] { "echo" });
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo")
        {
            Content = new StringContent("{}", Encoding.UTF8, "application/json"),
        };
        var response = await client.SendAsync(request);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task WhitespaceAuthorization_ValidXApiKey_Returns200()
    {
        // IsNullOrWhiteSpace fall-through: a PRESENT but whitespace-only Authorization
        // header is treated as absent, so a valid X-API-Key in the same request must
        // still authenticate (200). This pins the IsNullOrWhiteSpace check: without
        // it, a non-null-but-blank Authorization value would be passed straight to
        // the verifier (which would fail), and the X-API-Key would never be consulted.
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller",
            scopes: new[] { "echo" });
        var client = host.GetTestClient();

        var request = BuildPostWithApiKeyHeader("echo", """{"value":7}""", token);
        request.Headers.TryAddWithoutValidation("Authorization", "   ");
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("7", body);
    }

    [Fact]
    public async Task GarbageBearer_Returns401()
    {
        var method = SeedMethod(1, "noKey", "return 1;");

        using var host = await BuildHostAsync(method);
        await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" });
        var client = host.GetTestClient();

        // A well-formed Authorization header carrying a non-parseable / wrong-prefix
        // token is indistinguishable from a missing credential — still 401.
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
        {
            Content = new StringContent("{}", Encoding.UTF8, "application/json"),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-real-token");
        var response = await client.SendAsync(request);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task RevokedKey_Returns401()
    {
        var method = SeedMethod(1, "echo", "return 1;");

        using var host = await BuildHostAsync(method);
        // Seed-then-revoke: the token is well-formed and the secret matches, but
        // the key is revoked — the verifier fails closed and the endpoint maps it
        // to the generic 401 (no "revoked" stage leak).
        var token = await SeedKeyAsync(host, "key1", "echo-caller", new[] { "echo" });
        await RevokeKeyAsync(host, "key1");
        var client = host.GetTestClient();

        var request = BuildPost("echo", "{}", token);
        var response = await client.SendAsync(request);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task ValidKey_MethodNotInScope_Returns403()
    {
        // The key authenticates fine, but its scope set does NOT contain the
        // requested method name — authorization fails with 403.
        var method = SeedMethod(1, "secured", "return 1;");

        using var host = await BuildHostAsync(method);
        // Scope grants "otherMethod", not "secured".
        var token = await SeedKeyAsync(host, "key1", "limited-caller", new[] { "otherMethod" });
        var client = host.GetTestClient();

        var request = BuildPost("secured", "{}", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        // Review #1: pin the not-in-scope body to the exact canonical literal so a
        // single-branch divergence is caught here too (not only in the
        // identical-bodies comparison test).
        Assert.Equal(NotApprovedBodyJson, body);
    }

    [Fact]
    public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope()
    {
        // Enumeration-safety: "method not found" and "key not in scope" must be
        // indistinguishable — same 403 status AND same response body. We drive both
        // cases through the same valid key and assert byte-identical bodies.
        var method = SeedMethod(1, "knownMethod", "return 1;");

        using var host = await BuildHostAsync(method);
        // Key is in scope for "knownMethod" only — so:
        //   - posting "unknownMethod"  → method-not-found 403
        //   - posting "knownMethod" with a key scoped elsewhere would be not-in-scope 403
        var unknownToken = await SeedKeyAsync(host, "key1", "caller", new[] { "unknownMethod" });
        var notInScopeToken = await SeedKeyAsync(host, "key2", "caller2", new[] { "somethingElse" });
        var client = host.GetTestClient();

        // (a) unknown method — the key IS in scope for "unknownMethod" (so scope
        //     passes) but no such method exists in the repository → method-not-found 403.
        var unknownResponse = await client.SendAsync(BuildPost("unknownMethod", "{}", unknownToken));
        var unknownBody = await unknownResponse.Content.ReadAsStringAsync();

        // (b) known method but key not in scope for it → not-in-scope 403.
        var notInScopeResponse = await client.SendAsync(BuildPost("knownMethod", "{}", notInScopeToken));
        var notInScopeBody = await notInScopeResponse.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.Forbidden, unknownResponse.StatusCode);
        Assert.Equal(HttpStatusCode.Forbidden, notInScopeResponse.StatusCode);
        // The crux of the enumeration-safety invariant: identical bodies.
        Assert.Equal(notInScopeBody, unknownBody);
        // Review #1: pin BOTH bodies to the exact canonical literal, so a future
        // change that diverges either branch from the agreed body is caught even if
        // the two branches change together (the equal-to-each-other check above
        // would not catch that on its own).
        Assert.Equal(NotApprovedBodyJson, unknownBody);
        Assert.Equal(NotApprovedBodyJson, notInScopeBody);
    }

    [Fact]
    public async Task ErrorBodies_CarryMachineReadableCode()
    {
        // Task 5 (C3): every error body carries a machine-readable "code" field for
        // external integrators — UNAUTHORIZED (401), INVALID_JSON (400),
        // VALIDATION_FAILED (400).
        var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "needsParam" });
        var client = host.GetTestClient();

        // (a) missing credential → UNAUTHORIZED
        var unauth = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/needsParam")
        {
            Content = new StringContent("{}", Encoding.UTF8, "application/json"),
        });
        Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode);
        Assert.Equal("UNAUTHORIZED", await ReadCodeAsync(unauth));

        // (b) in-scope but invalid JSON → INVALID_JSON
        var badJson = await client.SendAsync(BuildPost("needsParam", "{ not json", token));
        Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode);
        Assert.Equal("INVALID_JSON", await ReadCodeAsync(badJson));

        // (c) missing required parameter → VALIDATION_FAILED
        var missing = await client.SendAsync(BuildPost("needsParam", "{}", token));
        Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
        Assert.Equal("VALIDATION_FAILED", await ReadCodeAsync(missing));
    }

    private static async Task<string?> ReadCodeAsync(HttpResponseMessage response)
    {
        var body = await response.Content.ReadAsStringAsync();
        using var doc = JsonDocument.Parse(body);
        return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
    }

    [Fact]
    public async Task InvalidJsonBody_Returns400()
    {
        var method = SeedMethod(1, "badJson", "return 1;");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "badJson" });
        var client = host.GetTestClient();

        var request = BuildPost("badJson", "{ not json", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Contains("Invalid JSON", body);
    }

    [Fact]
    public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
    {
        var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "needsParam" });
        var client = host.GetTestClient();

        // Body is empty object — required parameter "value" is missing.
        var request = BuildPost("needsParam", "{}", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        // ParameterValidator's error message is surfaced.
        Assert.Contains("value", body);
    }

    [Fact]
    public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
    {
        // Throws inside the script body — InboundScriptExecutor catches the
        // exception, logs it server-side, and surfaces the generic "Internal
        // script error" message to the caller (the executor deliberately does
        // not leak raw exception details). The endpoint maps the script failure
        // to HTTP 500.
        var method = SeedMethod(1, "boom",
            """throw new System.InvalidOperationException("boom-msg");""");

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "boom" });
        var client = host.GetTestClient();

        var request = BuildPost("boom", "{}", token);
        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        // Wiring contract: error body is JSON-shaped and the raw exception
        // message is not leaked (the executor sanitises before this point).
        Assert.Contains("error", body);
        Assert.DoesNotContain("boom-msg", body);
    }

    [Fact]
    public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems()
    {
        // The handler stashes the VERIFIED key's display name at
        // HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth+authz
        // succeeded, so AuditWriteMiddleware sees a populated Actor when it emits
        // the audit row. A capture middleware reads the slot once the endpoint
        // finishes, proving the wiring still publishes it.
        var method = SeedMethod(1, "stamp", "return 1;");

        var capture = new AuditActorCapture();

        using var host = await BuildHostAsync(method, customize: builder =>
        {
            builder.Use(async (ctx, next) =>
            {
                await next();
                if (ctx.Items.TryGetValue(
                        AuditWriteMiddleware.AuditActorItemKey, out var stashed)
                    && stashed is string actorName)
                {
                    capture.CapturedActor = actorName;
                }
            });
        }, additionalServices: services =>
        {
            services.AddSingleton(capture);
        });
        var token = await SeedKeyAsync(host, "key1", "audit-actor-name", new[] { "stamp" });
        var client = host.GetTestClient();

        var request = BuildPost("stamp", "{}", token);
        var response = await client.SendAsync(request);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("audit-actor-name", capture.CapturedActor);
    }

    [Fact]
    public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName()
    {
        // Telemetry follow-on: a successful inbound request increments
        // scadabridge.inbound_api.requests once, tagged with the resolved,
        // registered method name (method.Name) — the bounded identifier, not the
        // raw route value.
        var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
            """[{"name":"value","type":"Integer","required":true}]""");

        using var collector = new InboundApiRequestCounterCollector();

        using var host = await BuildHostAsync(method);
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "echo" });
        var client = host.GetTestClient();

        var response = await client.SendAsync(BuildPost("echo", """{"value":7}""", token));

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        // Filter by the method tag this test produced: the counter is a process-wide
        // static, so a parallel test class could otherwise leak measurements in.
        var echoTotal = collector.Measurements
            .Where(m => m.Method == "echo")
            .Sum(m => m.Value);
        Assert.Equal(1, echoTotal);
    }

    [Fact]
    public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
    {
        // Telemetry follow-on: an authz failure is still counted, but the tag is a
        // bounded sentinel ("<forbidden>") rather than the arbitrary caller-supplied
        // route value — so an attacker posting random method names cannot blow up
        // the `method` tag cardinality.
        var method = SeedMethod(1, "knownMethod", "return 1;");

        using var collector = new InboundApiRequestCounterCollector();

        using var host = await BuildHostAsync(method);
        // Key is in scope for the made-up name, so scope passes and the request
        // falls through to method-not-found (403) — exercising the forbidden path.
        var token = await SeedKeyAsync(host, "key1", "caller", new[] { "totally-made-up-name" });
        var client = host.GetTestClient();

        var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}", token));

        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        var measurements = collector.Measurements;
        // Cardinality safety: the arbitrary route value is never used as a tag.
        Assert.DoesNotContain(measurements, m => m.Method == "totally-made-up-name");
        // The failure path counts the request against the bounded sentinel.
        Assert.Contains(measurements, m => m.Method == "<forbidden>" && m.Value == 1);
    }

    /// <summary>
    /// Captures <c>scadabridge.inbound_api.requests</c> measurements (value + the
    /// <c>method</c> tag) via a <see cref="MeterListener"/> for the duration of a test.
    /// </summary>
    private sealed class InboundApiRequestCounterCollector : IDisposable
    {
        private readonly MeterListener _listener;
        private readonly List<(long Value, string? Method)> _measurements = new();
        private readonly object _gate = new();

        public InboundApiRequestCounterCollector()
        {
            _listener = new MeterListener
            {
                InstrumentPublished = (instrument, listener) =>
                {
                    if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName
                        && instrument.Name == "scadabridge.inbound_api.requests")
                    {
                        listener.EnableMeasurementEvents(instrument);
                    }
                },
            };
            _listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
            {
                string? method = null;
                foreach (var tag in tags)
                {
                    if (tag.Key == "method")
                    {
                        method = tag.Value as string;
                    }
                }

                lock (_gate)
                {
                    _measurements.Add((value, method));
                }
            });
            _listener.Start();
        }

        public IReadOnlyList<(long Value, string? Method)> Measurements
        {
            get
            {
                lock (_gate)
                {
                    return _measurements.ToList();
                }
            }
        }

        public void Dispose() => _listener.Dispose();
    }

    // --- InboundAPI-028: drive the new script-facing capabilities (Database +
    //     WaitForAttribute) end-to-end through the real POST /api/{methodName} flow,
    //     so a wiring regression in executor→context→helper / RouteHelper is caught. ---

    [Fact]
    public async Task Script_UsingDatabase_RunsEndToEndThroughEndpoint()
    {
        // A method whose script reads through Database.QueryAsync — proving the executor
        // builds the Database helper, resolves IDatabaseGateway from the per-execution
        // DI scope, and the parameterized read reaches the body. (InboundAPI-028)
        var method = SeedMethod(1, "movein",
            "return await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });",
            """[{"name":"sap","type":"String","required":true}]""");

        using var host = await BuildHostAsync(method, additionalServices: services =>
        {
            services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
        });
        var token = await SeedKeyAsync(host, keyId: "key1", displayName: "movein-caller",
            scopes: new[] { "movein" });
        var client = host.GetTestClient();

        var response = await client.SendAsync(BuildPost("movein", """{"sap":"131453"}""", token));
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("Z28061A", body);
    }

    [Fact]
    public async Task Script_UsingWaitForAttribute_RunsEndToEndThroughEndpoint()
    {
        // A method whose script awaits Route.To(...).WaitForAttribute(...) — proving the
        // executor binds the RouteHelper and the wait routes through IInstanceRouter and
        // returns its result through the endpoint. (InboundAPI-028)
        var method = SeedMethod(2, "waitmethod",
            "return await Route.To(\"inst-1\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(1));");

        var locator = Substitute.For<IInstanceLocator>();
        locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
        var router = Substitute.For<IInstanceRouter>();
        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));

        using var host = await BuildHostAsync(method, additionalServices: services =>
        {
            services.RemoveAll<IInstanceLocator>();
            services.AddSingleton(locator);
            services.RemoveAll<IInstanceRouter>();
            services.AddSingleton(router);
        });
        var token = await SeedKeyAsync(host, keyId: "key1", displayName: "wait-caller",
            scopes: new[] { "waitmethod" });
        var client = host.GetTestClient();

        var response = await client.SendAsync(BuildPost("waitmethod", "{}", token));
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("true", body);
    }

    // SQLite-backed IDatabaseGateway fake. Owns its keep-alive connection so the shared
    // in-memory db lives for the gateway's (= host's) lifetime — NOT a GC-eligible local,
    // which could be collected mid-suite (under GC pressure + SqliteConnection.ClearAllPools
    // in Dispose) and drop the db before the request opens its own connection. A unique db
    // name per instance also keeps tests from contaminating each other. Seeded with
    // Machine(Code,SAPID)=('Z28061A','131453').
    private sealed class SqliteGateway : IDatabaseGateway, IDisposable
    {
        private readonly string _cs;
        private readonly SqliteConnection _keepAlive;
        public SqliteGateway(string cs)
        {
            _cs = cs;
            _keepAlive = new SqliteConnection(cs);
            _keepAlive.Open();
            using var cmd = _keepAlive.CreateCommand();
            cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
            cmd.ExecuteNonQuery();
        }
        public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
        { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
        public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
            IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
            TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
            => throw new NotImplementedException();
        public void Dispose() => _keepAlive.Dispose();
    }

    private static SqliteGateway SeededSqliteGateway() =>
        new($"DataSource=file:movein-endpoint-{Guid.NewGuid():N}?mode=memory&cache=shared");

    private static HttpRequestMessage BuildPost(string methodName, string body, string bearerToken)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new StringContent(body, Encoding.UTF8, "application/json"),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
        return request;
    }

    /// <summary>
    /// X-API-Key restore: builds a POST carrying the credential in the alternate
    /// <c>X-API-Key: sbk_&lt;keyId&gt;_&lt;secret&gt;</c> header (the RAW token, no
    /// "Bearer " prefix) instead of Authorization. The endpoint must extract this
    /// header value and feed it to the SAME shared verifier.
    /// </summary>
    private static HttpRequestMessage BuildPostWithApiKeyHeader(
        string methodName, string body, string apiKeyToken)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new StringContent(body, Encoding.UTF8, "application/json"),
        };
        request.Headers.Add("X-API-Key", apiKeyToken);
        return request;
    }

    /// <summary>
    /// Seeds a key with the given scopes into the library SQLite store via
    /// <see cref="ApiKeyAdminCommands.CreateKeyAsync"/> (the public admin seam) and
    /// returns the assembled Bearer token (<c>sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) —
    /// the only moment the secret is ever available. The verifier registered in the
    /// host's DI shares the same SQLite path + pepper, so it accepts this token.
    /// </summary>
    private static async Task<string> SeedKeyAsync(
        IHost host, string keyId, string displayName, IReadOnlyCollection<string> scopes)
    {
        var commands = BuildAdminCommands(host);
        var result = await commands.CreateKeyAsync(
            keyId, displayName, new HashSet<string>(scopes),
            constraintsJson: null, remoteAddress: null, CancellationToken.None);
        Assert.NotNull(result.Token);
        return result.Token!;
    }

    private static async Task RevokeKeyAsync(IHost host, string keyId)
    {
        var commands = BuildAdminCommands(host);
        await commands.RevokeKeyAsync(keyId, remoteAddress: null, CancellationToken.None);
    }

    /// <summary>
    /// Builds an <see cref="ApiKeyAdminCommands"/> over the stores + pepper provider +
    /// migrator that AddZbApiKeyAuth registered in the host's DI, so seeding writes to
    /// the exact same database/pepper the verifier reads from.
    /// </summary>
    private static ApiKeyAdminCommands BuildAdminCommands(IHost host)
    {
        var services = host.Services;
        return new ApiKeyAdminCommands(
            services.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
            services.GetRequiredService<IApiKeyAdminStore>(),
            services.GetRequiredService<IApiKeyAuditStore>(),
            services.GetRequiredService<IApiKeyPepperProvider>(),
            services.GetRequiredService<SqliteAuthStoreMigrator>());
    }

    private async Task<IHost> BuildHostAsync(
        ApiMethod method,
        Action<IApplicationBuilder>? customize = null,
        Action<IServiceCollection>? additionalServices = null)
    {
        var repo = Substitute.For<IInboundApiRepository>();
        repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
            .Returns(method);

        // The pepper provider (ConfigurationApiKeyPepperProvider) reads the HOST's
        // IConfiguration, and AddZbApiKeyAuth only TryAdds its own config (so the
        // host registration wins). The api-key settings — including the pepper —
        // must therefore live in the host configuration, not a separate object.
        var apiKeySettings = new Dictionary<string, string?>
        {
            [PepperConfigKey] = Pepper,
            [$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix,
            [$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey,
            [$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath,
            [$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
        };

        var hostBuilder = new HostBuilder()
            .ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings))
            .ConfigureWebHost(webBuilder =>
            {
                webBuilder
                    .UseTestServer()
                    .ConfigureServices((context, services) =>
                    {
                        services.AddRouting();
                        services.AddSingleton(repo);
                        services.AddSingleton(Substitute.For<IInstanceLocator>());
                        services.Configure<InboundApiOptions>(_ => { });
                        services.AddInboundAPI();
                        // Stand up the shared verifier + SQLite store + migration
                        // hosted service against the per-test database and pepper,
                        // binding from the host configuration the pepper provider reads.
                        services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
                        // Replace the production CommunicationService-backed router
                        // with a test stub (it would otherwise need a real
                        // CommunicationService which isn't wired here).
                        services.RemoveAll<IInstanceRouter>();
                        services.AddSingleton(Substitute.For<IInstanceRouter>());
                        services.AddLogging();
                        additionalServices?.Invoke(services);
                    })
                    .Configure(app =>
                    {
                        app.UseRouting();
                        customize?.Invoke(app);
                        app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
                    });
            });

        return await hostBuilder.StartAsync();
    }

    public void Dispose()
    {
        try
        {
            // SqliteConnection pooling can hold the file open; clear pools before
            // deleting so the temp DB (and its -wal/-shm sidecars) are removed.
            Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
            foreach (var suffix in new[] { "", "-wal", "-shm" })
            {
                var path = _sqlitePath + suffix;
                if (File.Exists(path))
                {
                    File.Delete(path);
                }
            }
        }
        catch
        {
            // Best-effort cleanup; a leaked temp file is harmless.
        }
    }
}
