using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;

namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;

/// <summary>
/// HTTP/REST client that invokes external APIs.
/// Dual call modes — Call (synchronous) and CachedCall (S&amp;F on transient failure).
/// Error classification applied to HTTP responses and exceptions.
/// </summary>
public class ExternalSystemClient : IExternalSystemClient
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IExternalSystemRepository _repository;
    private readonly StoreAndForwardService? _storeAndForward;
    private readonly ILogger<ExternalSystemClient> _logger;
    private readonly ExternalSystemGatewayOptions _options;

    /// <summary>
    /// Shared options for the per-call AuthConfiguration JSON parse in
    /// <see cref="TryParseJsonAuth"/> (perf remediation, arch-review WP1.5) — avoids
    /// allocating a new <see cref="JsonSerializerOptions"/> (and its internal cached
    /// metadata) on every outbound call. Settings preserved exactly.
    /// </summary>
    private static readonly JsonSerializerOptions AuthJsonOptions =
        new() { PropertyNameCaseInsensitive = true };

    /// <summary>
    /// Initializes a new instance of the ExternalSystemClient.
    /// </summary>
    /// <param name="httpClientFactory">HTTP client factory for creating typed clients.</param>
    /// <param name="repository">External system repository for loading definitions.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="storeAndForward">Store-and-forward service for buffering transient failures, or null if not available.</param>
    /// <param name="options">Configuration options, or null for defaults.</param>
    public ExternalSystemClient(
        IHttpClientFactory httpClientFactory,
        IExternalSystemRepository repository,
        ILogger<ExternalSystemClient> logger,
        StoreAndForwardService? storeAndForward = null,
        IOptions<ExternalSystemGatewayOptions>? options = null)
    {
        _httpClientFactory = httpClientFactory;
        _repository = repository;
        _logger = logger;
        _storeAndForward = storeAndForward;
        _options = options?.Value ?? new ExternalSystemGatewayOptions();
    }

    /// <inheritdoc />
    public async Task<ExternalCallResult> CallAsync(
        string systemName,
        string methodName,
        IReadOnlyDictionary<string, object?>? parameters = null,
        CancellationToken cancellationToken = default)
    {
        var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
        if (system == null || method == null)
        {
            return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
        }

        try
        {
            var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return new ExternalCallResult(true, response, null);
        }
        catch (TransientExternalSystemException ex)
        {
            return new ExternalCallResult(false, null, $"Transient error: {ex.Message}");
        }
        catch (PermanentExternalSystemException ex)
        {
            return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
        }
    }

    /// <inheritdoc />
    public async Task<ExternalCallResult> CachedCallAsync(
        string systemName,
        string methodName,
        IReadOnlyDictionary<string, object?>? parameters = null,
        string? originInstanceName = null,
        CancellationToken cancellationToken = default,
        TrackedOperationId? trackedOperationId = null,
        Guid? executionId = null,
        string? sourceScript = null,
        Guid? parentExecutionId = null)
    {
        var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
        if (system == null || method == null)
        {
            return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
        }

        try
        {
            var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return new ExternalCallResult(true, response, null);
        }
        catch (PermanentExternalSystemException ex)
        {
            // Permanent failures returned to script, never buffered
            return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
        }
        catch (TransientExternalSystemException)
        {
            // Transient failure — hand to S&F
            if (_storeAndForward == null)
            {
                return new ExternalCallResult(false, null, "Transient error and store-and-forward not available");
            }

            var payload = JsonSerializer.Serialize(new
            {
                SystemName = systemName,
                MethodName = methodName,
                Parameters = parameters
            });

            // attemptImmediateDelivery: false — this method already made the HTTP
            // attempt above; letting EnqueueAsync re-invoke the handler would
            // dispatch the same request a second time.
            //
            // The entity's MaxRetries is a non-nullable
            // int whose default is 0, and the Store-and-Forward engine interprets a
            // stored MaxRetries of 0 as "no limit" (retry forever) — see
            // StoreAndForwardMessage.MaxRetries ("0 = no limit") and the retry-sweep
            // guard `MaxRetries > 0 && ...`. Passing 0 verbatim would therefore turn
            // every unconfigured cached call into an unbounded retry loop. A 0 is
            // treated as "unset" and passed as null so the bounded S&F default
            // applies; the RetryDelay default of TimeSpan.Zero is likewise unset.
            await _storeAndForward.EnqueueAsync(
                StoreAndForwardCategory.ExternalSystem,
                systemName,
                payload,
                originInstanceName,
                system.MaxRetries > 0 ? system.MaxRetries : null,
                system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null,
                attemptImmediateDelivery: false,
                // Pin the S&F message id to the
                // TrackedOperationId so the retry loop can read it back via
                // StoreAndForwardMessage.Id and emit per-attempt + terminal
                // cached-call telemetry. Null -> S&F
                // mints its own GUID (legacy behaviour).
                messageId: trackedOperationId?.ToString(),
                // Thread the originating
                // script execution's ExecutionId + SourceScript onto the
                // buffered row so the retry-loop cached-call audit rows carry
                // the same provenance the script-side cached rows do.
                executionId: executionId,
                sourceScript: sourceScript,
                // Thread the spawning
                // inbound-API request's ExecutionId onto the buffered row so
                // the retry-loop cached-call audit rows correlate back to the
                // cross-execution chain. Null for a non-routed run.
                parentExecutionId: parentExecutionId);

            return new ExternalCallResult(true, null, null, WasBuffered: true);
        }
    }

    /// <summary>
    /// Delivers a buffered ExternalSystem call during a store-and-forward
    /// retry sweep. Returns true on success, false on permanent failure (the message
    /// is parked); throws <see cref="TransientExternalSystemException"/> on a
    /// transient failure so the engine retries. Deterministic-poison failures against
    /// the CURRENT method definition — malformed JSON, or an
    /// <see cref="ArgumentException"/> from an unresolvable <c>{param}</c> path
    /// template or an unsupported HTTP verb — return false (park immediately) rather
    /// than retry, because re-running the same stored payload can never succeed.
    /// </summary>
    /// <param name="message">The buffered message to deliver.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>True if delivered successfully, false if a permanent error occurred.</returns>
    public async Task<bool> DeliverBufferedAsync(
        StoreAndForwardMessage message, CancellationToken cancellationToken = default)
    {
        // A malformed (not just empty/null-fielded)
        // PayloadJson would otherwise throw `JsonException` here, which the S&F
        // engine treats as a transient failure and retries forever (poison
        // message). Re-running the same deserialization against the same payload
        // will throw deterministically, so JsonException is permanent — log,
        // and return false so the S&F engine parks the message instead.
        CachedCallPayload? payload;
        try
        {
            payload = JsonSerializer.Deserialize<CachedCallPayload>(message.PayloadJson);
        }
        catch (JsonException ex)
        {
            _logger.LogError(
                ex,
                "Buffered ExternalSystem message {Id} has malformed JSON payload; parking.",
                message.Id);
            return false;
        }

        if (payload == null || string.IsNullOrEmpty(payload.SystemName) || string.IsNullOrEmpty(payload.MethodName))
        {
            _logger.LogError("Buffered ExternalSystem message {Id} has an unreadable payload; parking.", message.Id);
            return false;
        }

        var (system, method) = await ResolveSystemAndMethodAsync(
            payload.SystemName, payload.MethodName, cancellationToken);
        if (system == null || method == null)
        {
            _logger.LogError(
                "Buffered call to '{System}'/'{Method}' cannot be delivered — the system or method no longer exists; parking.",
                payload.SystemName, payload.MethodName);
            return false;
        }

        var parameters = payload.Parameters?.ToDictionary(kv => kv.Key, kv => (object?)kv.Value);
        try
        {
            await InvokeHttpAsync(system, method, parameters, cancellationToken);
            return true;
        }
        catch (PermanentExternalSystemException ex)
        {
            _logger.LogError(ex, "Buffered call to '{System}' failed permanently; parking.", payload.SystemName);
            return false;
        }
        catch (ArgumentException ex)
        {
            // N2: same poison-message shape as the JsonException catch above. BuildUrl's
            // {param} path-template substitution (placeholder with no matching/non-null
            // parameter) and ValidateHttpMethod both throw ArgumentException
            // deterministically for the SAME stored payload — the method definition
            // changed underneath the buffered call, and retrying can never succeed.
            // Return false so the S&F engine parks the message instead of counting the
            // throw as transient and burning MaxRetries.
            _logger.LogError(
                ex,
                "Buffered call to '{System}'/'{Method}' fails deterministically against the current method definition; parking.",
                payload.SystemName, payload.MethodName);
            return false;
        }
        // TransientExternalSystemException propagates — the S&F engine retries.
    }

    private sealed record CachedCallPayload(
        string SystemName,
        string MethodName,
        Dictionary<string, JsonElement>? Parameters);

    /// <summary>
    /// Executes the HTTP request against the external system.
    /// </summary>
    /// <param name="system">The external system definition.</param>
    /// <param name="method">The external system method to invoke.</param>
    /// <param name="parameters">Method parameters as a dictionary, or null if none.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The response string, or null if no response body.</returns>
    internal async Task<string?> InvokeHttpAsync(
        ExternalSystemDefinition system,
        ExternalSystemMethod method,
        IReadOnlyDictionary<string, object?>? parameters,
        CancellationToken cancellationToken)
    {
        // Validate the verb against the documented set
        // (GET/POST/PUT/PATCH/DELETE)
        // BEFORE constructing the request. `new HttpMethod(string)` accepts any
        // token-character string (e.g. "FOO", "DLETE"), and the body-vs-query
        // branch below only knows POST/PUT/PATCH and GET/DELETE — so an
        // unsupported verb would dispatch silently with parameters sent to
        // neither body nor query, and the script would only see a remote 4xx.
        // Rejecting at the gateway entry surfaces the misconfiguration with a
        // clear ArgumentException naming the offending verb. Case-insensitive
        // match: the entity column carries free-form strings.
        ValidateHttpMethod(method.HttpMethod);

        var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");

        // HttpClient.Timeout defaults to 100 seconds
        // and is enforced internally by SendAsync via its own private CTS — a
        // TaskCanceledException raised by that internal CTS does not trip
        // either the caller's token or the gateway's timeout CTS, so it falls
        // through the ordered catch filters below into the generic "connection
        // error" branch and is misclassified. Any operator-configured
        // DefaultHttpTimeout greater than 100 s would therefore be silently
        // clipped to 100 s, breaking the design's "timeout applies to the HTTP
        // request round-trip" guarantee. Disable the framework default so the
        // linked CancellationTokenSource(DefaultHttpTimeout) below is the sole
        // timeout source — DefaultHttpTimeout is then honoured verbatim for
        // every value, including ones well above 100 s. Setting this on the
        // factory-supplied HttpClient before any request is the safe time:
        // IHttpClientFactory rents typed clients backed by pooled message
        // handlers, but the HttpClient instance itself is per-call and the
        // Timeout property is per-instance.
        client.Timeout = Timeout.InfiniteTimeSpan;

        var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod, out var consumedParams);

        // The request and response own IDisposable resources (StringContent, the
        // response content stream). Dispose both, including on the exception paths.
        using var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url);

        // Apply authentication
        ApplyAuth(request, system);

        // For POST/PUT/PATCH, send parameters as JSON body
        if (method.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
            method.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
            method.HttpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase))
        {
            // Parameters consumed by `{param}` path-template substitution are
            // already carried in the URL — exclude them from the JSON body so a
            // path parameter is not duplicated as a body field.
            var bodyParameters = consumedParams.Count == 0
                ? parameters
                : parameters?.Where(p => !consumedParams.Contains(p.Key))
                    .ToDictionary(p => p.Key, p => p.Value);

            if (bodyParameters != null && bodyParameters.Count > 0)
            {
                request.Content = new StringContent(
                    JsonSerializer.Serialize(bodyParameters),
                    Encoding.UTF8,
                    "application/json");
            }
        }

        // Enforce the per-call timeout (the design's "timeout applies to the HTTP
        // request round-trip" guarantee). Resolution order: a positive per-system
        // ExternalSystemDefinition.TimeoutSeconds wins; otherwise the configured
        // DefaultHttpTimeout is the effective round-trip limit. A linked CTS lets us
        // distinguish a timeout from a caller-initiated cancellation: only the
        // timeout is reclassified as transient.
        var effectiveTimeout = system.TimeoutSeconds > 0
            ? TimeSpan.FromSeconds(system.TimeoutSeconds)
            : _options.DefaultHttpTimeout;
        using var timeoutCts = new CancellationTokenSource(effectiveTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, timeoutCts.Token);

        HttpResponseMessage response;
        try
        {
            // ResponseHeadersRead returns as soon as the headers are in, so the
            // body is not eagerly buffered — the bounded read below streams it and
            // aborts once MaxResponseBodyBytes is exceeded, keeping a hostile or
            // misbehaving endpoint from inflating the active node's memory.
            response = await client.SendAsync(
                request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The caller asked to abandon the work — do not reclassify as transient.
            throw;
        }
        catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
        {
            // Our own timeout elapsed — a transient failure per the design.
            throw ErrorClassifier.AsTransient(
                $"Timeout calling {system.Name} after {effectiveTimeout.TotalSeconds:0.##}s", ex);
        }
        catch (Exception ex) when (ErrorClassifier.IsTransient(ex, cancellationToken))
        {
            throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex);
        }

        using (response)
        {
            // The timeout also covers reading the response body (the design's
            // "round-trip" guarantee), so the linked token is used for the read too.
            string body;
            try
            {
                body = await ReadBodyBoundedAsync(
                    response.Content, system.Name, _options.MaxResponseBodyBytes, linkedCts.Token);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
            {
                throw ErrorClassifier.AsTransient(
                    $"Timeout reading response from {system.Name} after {effectiveTimeout.TotalSeconds:0.##}s", ex);
            }

            if (response.IsSuccessStatusCode)
            {
                return body;
            }

            // Bound the external error body before embedding it into a
            // script-visible message / event-log entry — a misbehaving or hostile
            // endpoint must not be able to inflate every error string.
            var errorBody = Truncate(body, MaxErrorBodyChars);

            if (ErrorClassifier.IsTransient(response.StatusCode))
            {
                // Transient failures are normal operation (handled by retry / S&F) —
                // record at debug level only so the event log is not noisy.
                _logger.LogDebug(
                    "Transient HTTP {StatusCode} from external system {System} calling {Method}.",
                    (int)response.StatusCode, system.Name, method.Name);
                throw ErrorClassifier.AsTransient(
                    $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}");
            }

            // The design requires permanent failures to be visible in Site Event
            // Logging — emit a warning so the gateway is not silent on a permanent
            // failure.
            _logger.LogWarning(
                "Permanent HTTP {StatusCode} from external system {System} calling {Method}: {Error}",
                (int)response.StatusCode, system.Name, method.Name, errorBody);
            throw new PermanentExternalSystemException(
                $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}",
                (int)response.StatusCode);
        }
    }

    /// <summary>
    /// Upper bound (characters) on an external error response body echoed into a
    /// script-visible error message.
    /// </summary>
    private const int MaxErrorBodyChars = 2048;

    /// <summary>
    /// Documented HTTP-verb allowlist. Matches the
    /// design doc's enumerated set (GET/POST/PUT/PATCH/DELETE) and
    /// the body-vs-query branching above; any addition here must update both.
    /// </summary>
    private static readonly HashSet<string> SupportedHttpMethods = new(StringComparer.OrdinalIgnoreCase)
    {
        "GET", "POST", "PUT", "PATCH", "DELETE",
    };

    /// <summary>
    /// Rejects HTTP verbs the gateway does not support. Throws
    /// <see cref="ArgumentException"/> for null/empty input or any string outside
    /// the documented allowlist. Case-insensitive — the entity column carries
    /// operator-authored strings.
    /// </summary>
    private static void ValidateHttpMethod(string httpMethod)
    {
        if (string.IsNullOrWhiteSpace(httpMethod))
        {
            throw new ArgumentException(
                "HTTP method must be one of GET/POST/PUT/PATCH/DELETE; got null or empty.",
                nameof(httpMethod));
        }

        if (!SupportedHttpMethods.Contains(httpMethod))
        {
            throw new ArgumentException(
                $"HTTP method '{httpMethod}' is not supported. Allowed verbs: GET, POST, PUT, PATCH, DELETE.",
                nameof(httpMethod));
        }
    }

    /// <summary>
    /// Reads an HTTP response body into a string while enforcing an upper size
    /// bound. When the <c>Content-Length</c> header is present it fails fast;
    /// otherwise the body is streamed in 16 KiB chunks and the read is aborted the
    /// moment the accumulated length exceeds <paramref name="maxBytes"/> (cap+1
    /// pattern), so the whole oversized body is never buffered. Oversize is a
    /// permanent failure — retrying will not shrink the response, and buffering it
    /// into store-and-forward would defeat the cap. Cancellation
    /// (<see cref="OperationCanceledException"/>) is allowed to propagate so the
    /// caller's ordered timeout/cancellation catch filters classify it.
    /// <para>
    /// N4: the accumulated bytes are decoded using the response's declared charset
    /// (<c>Content-Type: …; charset=…</c>) via <see cref="ResolveEncoding"/> — parity
    /// with the pre-bounded-read <c>ReadAsStringAsync</c>. A null, empty, or
    /// unrecognized charset falls back to UTF-8.
    /// </para>
    /// </summary>
    private static async Task<string> ReadBodyBoundedAsync(
        HttpContent content, string systemName, long maxBytes, CancellationToken token)
    {
        // Fast path: a declared Content-Length above the cap is rejected before a
        // single body byte is read.
        var declaredLength = content.Headers.ContentLength;
        if (declaredLength.HasValue && declaredLength.Value > maxBytes)
        {
            throw new PermanentExternalSystemException(
                $"Response from {systemName} exceeded the {maxBytes}-byte limit " +
                $"(declared Content-Length {declaredLength.Value}).");
        }

        await using var stream = await content.ReadAsStreamAsync(token);

        using var buffer = new MemoryStream();
        var chunk = new byte[16 * 1024];
        int read;
        while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), token)) > 0)
        {
            buffer.Write(chunk, 0, read);

            // cap+1: as soon as we hold more than maxBytes we know it is oversized,
            // without reading the remainder of the stream.
            if (buffer.Length > maxBytes)
            {
                throw new PermanentExternalSystemException(
                    $"Response from {systemName} exceeded the {maxBytes}-byte limit.");
            }
        }

        // N4: honor the response's declared charset (parity with the pre-bounded-read
        // ReadAsStringAsync). Unknown/invalid charset → UTF-8 fallback: mojibake beats
        // failing the call over a cosmetic header.
        return ResolveEncoding(content.Headers.ContentType?.CharSet)
            .GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
    }

    /// <summary>
    /// Resolves the declared response charset to an <see cref="Encoding"/>, tolerating
    /// the quoted form some servers emit (<c>charset="iso-8859-1"</c>). Null, empty,
    /// or unrecognized values fall back to UTF-8.
    /// </summary>
    private static Encoding ResolveEncoding(string? charset)
    {
        if (string.IsNullOrWhiteSpace(charset))
        {
            return Encoding.UTF8;
        }

        try
        {
            return Encoding.GetEncoding(charset.Trim().Trim('"'));
        }
        catch (ArgumentException)
        {
            return Encoding.UTF8;
        }
    }

    private static string Truncate(string value, int maxChars)
    {
        if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
        {
            return value;
        }

        return value.Substring(0, maxChars) + $"… [truncated, {value.Length} chars total]";
    }

    /// <summary>
    /// Matches a `{param}` path-template placeholder. The name must be a valid
    /// identifier (letter/underscore start, then letters/digits/underscores) so the
    /// pattern cannot accidentally swallow JSON-ish braces in a hand-authored path.
    /// </summary>
    private static readonly Regex PathParamRegex = new(
        @"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled);

    private static string BuildUrl(
        string baseUrl,
        string path,
        IReadOnlyDictionary<string, object?>? parameters,
        string httpMethod,
        out ISet<string> consumedParams)
    {
        consumedParams = new HashSet<string>(StringComparer.Ordinal);

        // A method that targets the base URL itself has an empty (or "/") path.
        // Appending a trailing "/" in that case yields ".../api/" which some
        // servers treat as a distinct resource — only append a segment when the
        // method actually defines a non-empty relative path.
        var trimmedBase = baseUrl.TrimEnd('/');
        var trimmedPath = path.Trim().TrimStart('/');

        // Substitute `{param}` placeholders in the path with escaped parameter
        // values. Each substituted name is recorded so it is excluded from BOTH
        // the query string (GET/DELETE) and the JSON body (POST/PUT/PATCH) — a
        // path parameter must not be duplicated. A placeholder with no matching
        // parameter (or a null value) is an authoring error: throw a clear
        // ArgumentException naming it, mirroring ValidateHttpMethod.
        if (trimmedPath.Length > 0 && trimmedPath.Contains('{'))
        {
            var consumed = consumedParams;
            trimmedPath = PathParamRegex.Replace(trimmedPath, match =>
            {
                var name = match.Groups[1].Value;
                if (parameters == null ||
                    !parameters.TryGetValue(name, out var value) ||
                    value == null)
                {
                    throw new ArgumentException(
                        $"Path template parameter '{{{name}}}' has no value — supply a non-null '{name}' parameter for this method.",
                        nameof(path));
                }

                consumed.Add(name);
                return Uri.EscapeDataString(value.ToString() ?? string.Empty);
            });
        }

        var url = string.IsNullOrEmpty(trimmedPath)
            ? trimmedBase
            : trimmedBase + "/" + trimmedPath;

        // For GET/DELETE, append parameters as query string
        if ((httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) ||
             httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) &&
            parameters != null && parameters.Count > 0)
        {
            var consumed = consumedParams;
            var queryString = string.Join("&",
                parameters.Where(p => p.Value != null && !consumed.Contains(p.Key))
                    .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value?.ToString() ?? "")}"));

            // Only append "?" when the effective query string is non-empty — a method
            // whose parameter values are all null produces no query string, and the
            // URL must then be identical to the no-parameters case rather than ending
            // in a bare "?".
            if (queryString.Length > 0)
            {
                url += "?" + queryString;
            }
        }

        return url;
    }

    private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
    {
        // Distinguish "intentionally unauthenticated" (AuthType = none)
        // from "AuthConfiguration is missing or empty for a type that requires it"
        // (deployment glitch, decryption failure, operator typo). The unauthenticated
        // case is silent; the requires-creds-but-empty case logs a Warning so an
        // operator debugging a recurring 401 sees the cause inside ScadaBridge instead
        // of having to read the remote system's logs. The value of AuthConfiguration
        // is NEVER logged.
        var authType = system.AuthType?.Trim().ToLowerInvariant() ?? string.Empty;

        if (string.IsNullOrEmpty(system.AuthConfiguration))
        {
            if (authType is "apikey" or "basic")
            {
                _logger.LogWarning(
                    "ApplyAuth: External system '{System}' has AuthType '{AuthType}' but AuthConfiguration is empty; request will be sent without an auth header.",
                    system.Name, system.AuthType);
            }
            return;
        }

        // A config whose trimmed value starts with '{' is the structured JSON form
        // promised by the entity doc-comment and the Central UI placeholders. Parse
        // it first; only a non-JSON value falls through to the legacy colon-split.
        var isJson = system.AuthConfiguration.TrimStart().StartsWith('{');
        Dictionary<string, string?>? jsonFields = null;
        var jsonParsed = isJson && TryParseJsonAuth(system.AuthConfiguration, out jsonFields);

        switch (authType)
        {
            case "apikey":
                // JSON form: {"header"?: string, "key": string} (default header X-API-Key).
                if (isJson)
                {
                    if (jsonParsed &&
                        jsonFields!.TryGetValue("key", out var jsonKey) &&
                        !string.IsNullOrEmpty(jsonKey))
                    {
                        var header = jsonFields.TryGetValue("header", out var h) && !string.IsNullOrEmpty(h)
                            ? h!
                            : "X-API-Key";
                        request.Headers.TryAddWithoutValidation(header, jsonKey);
                    }
                    else
                    {
                        // Malformed JSON, or JSON with no usable "key": send without a
                        // header and warn (the value is never logged). Never throw.
                        _logger.LogWarning(
                            "ApplyAuth: External system '{System}' AuthType 'apikey' AuthConfiguration is malformed JSON (expected {{\"header\"?:...,\"key\":...}}); request will be sent without an auth header.",
                            system.Name);
                    }
                    break;
                }

                // Legacy config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
                var parts = system.AuthConfiguration.Split(':', 2);
                if (parts.Length == 2)
                {
                    request.Headers.TryAddWithoutValidation(parts[0], parts[1]);
                }
                else
                {
                    request.Headers.TryAddWithoutValidation("X-API-Key", system.AuthConfiguration);
                }
                break;

            case "basic":
                // JSON form: {"username": string, "password": string}.
                if (isJson)
                {
                    if (jsonParsed &&
                        jsonFields!.TryGetValue("username", out var jsonUser) &&
                        !string.IsNullOrEmpty(jsonUser))
                    {
                        var password = jsonFields.TryGetValue("password", out var p) ? p ?? string.Empty : string.Empty;
                        var jsonEncoded = Convert.ToBase64String(
                            Encoding.UTF8.GetBytes($"{jsonUser}:{password}"));
                        request.Headers.Authorization = new AuthenticationHeaderValue("Basic", jsonEncoded);
                    }
                    else
                    {
                        _logger.LogWarning(
                            "ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed JSON (expected {{\"username\":...,\"password\":...}}); request will be sent without an Authorization header.",
                            system.Name);
                    }
                    break;
                }

                // Legacy config format: "username:password"
                var basicParts = system.AuthConfiguration.Split(':', 2);
                if (basicParts.Length == 2)
                {
                    var encoded = Convert.ToBase64String(
                        Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
                    request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
                }
                else
                {
                    // Malformed Basic config (no ':' separator) means the
                    // request goes out with no Authorization header. Warn so the
                    // failure mode is visible inside ZB.MOM.WW.ScadaBridge.
                    _logger.LogWarning(
                        "ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed (expected 'username:password'); request will be sent without an Authorization header.",
                        system.Name);
                }
                break;

            case "none":
                // Documented sentinel for unauthenticated systems — silent by design.
                break;

            default:
                // Unknown AuthType silently fell through here before. Warn.
                _logger.LogWarning(
                    "ApplyAuth: External system '{System}' has unknown AuthType '{AuthType}'; request will be sent without an auth header. Allowed values: apikey, basic, none.",
                    system.Name, system.AuthType);
                break;
        }
    }

    /// <summary>
    /// Parses a structured JSON <c>AuthConfiguration</c> into a case-insensitive
    /// field map (e.g. <c>{"header":"X-Auth","key":"..."}</c> or
    /// <c>{"username":"u","password":"p"}</c>). Returns <c>false</c> on malformed
    /// JSON (<see cref="JsonException"/>) instead of throwing, so the caller can
    /// fall through to the "send without header" warning path. Values are never
    /// logged.
    /// </summary>
    private static bool TryParseJsonAuth(string config, out Dictionary<string, string?> fields)
    {
        try
        {
            fields = JsonSerializer.Deserialize<Dictionary<string, string?>>(
                config,
                AuthJsonOptions)
                ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);

            // Normalize to case-insensitive so "Header"/"header" both resolve.
            if (!ReferenceEquals(fields.Comparer, StringComparer.OrdinalIgnoreCase))
            {
                fields = new Dictionary<string, string?>(fields, StringComparer.OrdinalIgnoreCase);
            }
            return true;
        }
        catch (JsonException)
        {
            fields = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
            return false;
        }
    }

    private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
        string systemName,
        string methodName,
        CancellationToken cancellationToken)
    {
        // Name-keyed repository lookups instead of
        // fetch-all-then-filter — definitions are resolved on every hot-path call
        // (a script's ExternalSystem.Call()), so the repository performs an indexed
        // query rather than loading every system / every method into memory.
        var system = await _repository.GetExternalSystemByNameAsync(systemName, cancellationToken);
        if (system == null)
            return (null, null);

        var method = await _repository.GetMethodByNameAsync(system.Id, methodName, cancellationToken);

        return (system, method);
    }
}
