using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI;

/// <summary>
/// Review N1: log-category marker for the inbound API endpoint. Used as the type
/// argument to <see cref="ILogger{TCategoryName}"/> so inbound-API auth/authz log
/// lines are categorized under this ScadaBridge.InboundAPI type rather than under
/// the shared <c>ZB.MOM.WW.Auth</c> library's <c>IApiKeyVerifier</c>. Exists only
/// to name the log category (<see cref="EndpointExtensions"/> is static and cannot
/// be a generic type argument); it is never instantiated.
/// </summary>
public sealed class InboundApiEndpoint
{
    private InboundApiEndpoint() { }
}

/// <summary>
/// POST /api/{methodName} endpoint registration.
/// Method routing and parameter validation.
/// Script execution on central.
/// Error handling — 401, 403, 400, 500.
/// </summary>
public static class EndpointExtensions
{
    /// <summary>
    /// Auth re-arch (A+B), successor: the single message used for
    /// BOTH "method not found" and "key not in scope for this method" so the two
    /// outcomes are indistinguishable to the caller. A caller holding any valid key
    /// must not be able to enumerate which method names exist by observing a
    /// status/message difference, so both branches return 403 with this identical
    /// body and the caller-supplied method name is never echoed back.
    /// </summary>
    private const string NotApprovedMessage = "API key not approved for this method";

    /// <summary>
    /// Auth re-arch (A+B): the generic 401 message. Every verifier failure reason
    /// (missing/malformed token, unknown key, revoked key, pepper unavailable,
    /// secret mismatch) maps to this one message so the auth stage is never leaked
    /// to the caller.
    /// </summary>
    private const string UnauthorizedMessage = "Invalid or missing API key";

    /// <summary>
    /// C3: builds the canonical inbound-API error body — <c>{ "error": message, "code":
    /// code }</c> — at the given HTTP status. Every error return in
    /// <see cref="HandleInboundApiRequest"/> routes through this helper so the
    /// machine-readable <c>code</c> field (the audience is external integrators) is
    /// present and consistent across all failure branches. The <c>error</c> field is
    /// unchanged, so callers that only read it are unaffected (additive field).
    /// </summary>
    /// <param name="code">The machine-readable error code (e.g. <c>UNAUTHORIZED</c>, <c>SITE_UNREACHABLE</c>).</param>
    /// <param name="message">The human-readable, caller-safe error message.</param>
    /// <param name="status">The HTTP status code for the response.</param>
    /// <returns>A JSON result carrying the error + code at the given status.</returns>
    private static IResult Error(string code, string message, int status) =>
        Results.Json(new { error = message, code }, statusCode: status);

    /// <summary>Registers the <c>POST /api/{methodName}</c> inbound API endpoint with the active-node gate and body-size filter applied.</summary>
    /// <param name="endpoints">The route builder to add the endpoint to.</param>
    /// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
    public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest)
            // Active-node gating + request body
            // size cap are enforced by the endpoint filter before the handler runs.
            .AddEndpointFilter<InboundApiEndpointFilter>();
        return endpoints;
    }

    private static async Task<IResult> HandleInboundApiRequest(
        HttpContext httpContext,
        string methodName)
    {
        // Review N1: log under an endpoint-owned category (a marker type in the
        // ScadaBridge.InboundAPI namespace) rather than ILogger<IApiKeyVerifier>,
        // so inbound-API auth log lines land under ScadaBridge — not the shared
        // ZB.MOM.WW.Auth library namespace — when operators filter by category.
        // (EndpointExtensions is static and cannot be an ILogger type argument, so
        // the namespace-local InboundApiEndpoint marker carries the category.)
        var logger = httpContext.RequestServices.GetRequiredService<ILogger<InboundApiEndpoint>>();
        var verifier = httpContext.RequestServices.GetRequiredService<IApiKeyVerifier>();
        var repository = httpContext.RequestServices.GetRequiredService<IInboundApiRepository>();
        var methodCache = httpContext.RequestServices.GetRequiredService<ApiMethodCache>();
        var executor = httpContext.RequestServices.GetRequiredService<InboundScriptExecutor>();
        var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
        var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;

        // Auth re-arch (A+B) + X-API-Key restore: the inbound credential is accepted
        // from EITHER the Authorization header ("Bearer sbk_<keyId>_<secret>") OR the
        // alternate "X-API-Key: sbk_<keyId>_<secret>" header (raw token). Both are passed
        // to the SAME shared ZB.MOM.WW.Auth.ApiKeys verifier — the parser strips an
        // optional "Bearer " prefix and otherwise accepts a bare token, so the
        // peppered-HMAC constant-time secret compare is identical for both transports.
        // Authorization takes precedence when both headers are present.
        var authorizationHeader = httpContext.Request.Headers.Authorization.ToString();
        var credential = !string.IsNullOrWhiteSpace(authorizationHeader)
            ? authorizationHeader
            : httpContext.Request.Headers["X-API-Key"].ToString();
        var verification = await verifier.VerifyAsync(
            credential, httpContext.RequestAborted);

        if (!verification.Succeeded)
        {
            // 401 for any verifier failure. The failure reason is
            // discriminated for our own logs/telemetry but NEVER surfaced to the
            // caller — every reason maps to the one generic message so the auth
            // stage (missing vs unknown-key vs revoked vs secret-mismatch) is not
            // leaked.
            ScadaBridgeTelemetry.RecordInboundApiRequest("<unauthorized>");

            // Failures-only logging.
            logger.LogWarning(
                "Inbound API auth failure for method {Method}: {Failure} (status 401)",
                methodName, verification.Failure);

            return Error("UNAUTHORIZED", UnauthorizedMessage, StatusCodes.Status401Unauthorized);
        }

        var identity = verification.Identity!;

        // Auth re-arch (A+B), enumeration-safety: "method not found" and "key not
        // in scope for this method" MUST produce an indistinguishable response.
        // Both the not-found and not-in-scope branches return 403 with the SAME
        // body; folding both negatives into one branch keeps the two cases
        // byte-identical (status + message), so a caller holding a valid key
        // cannot probe which method names exist.
        //
        // The in-memory scope check runs
        // FIRST and the DB GetMethodByNameAsync only runs when the caller is in
        // scope. This removes a timing oracle — a not-in-scope caller no longer
        // pays a DB round-trip whose latency could distinguish "valid scope but no
        // such method" from "not in scope" — and saves the round-trip entirely on
        // the reject path. The combined `method == null || !inScope` guard still
        // emits the single identical 403 body, preserving enumeration-safety.
        //
        // Scope strings ARE the method names, and
        // identity.Scopes.Contains(methodName) is an ordinal, case-SENSITIVE
        // comparison (HashSet<string> with the default StringComparer.Ordinal). A
        // scope must therefore match the registered method name's casing EXACTLY —
        // "Echo" does not grant "echo". This is the intended invariant: method
        // names are case-sensitive identifiers, and a key's granted scopes must be
        // provisioned with the exact casing of the methods they authorize.
        // WP2.6b: the per-request repository round-trip is fronted by a short-TTL
        // ApiMethodCache (invalidated by name on an ApiMethod ScriptArtifactsChanged
        // notification, and self-healing via TTL expiry otherwise — see ApiMethodCache
        // and ScriptArtifactChangeSubscriber). Only reached after the in-scope check
        // passes, preserving the existing enumeration-safety ordering below.
        var inScope = identity.Scopes.Contains(methodName);
        var method = inScope
            ? await methodCache.GetOrFetchAsync(
                methodName,
                ct => repository.GetMethodByNameAsync(methodName, ct),
                httpContext.RequestAborted)
            : null;

        if (method == null || !inScope)
        {
            ScadaBridgeTelemetry.RecordInboundApiRequest("<forbidden>");

            logger.LogWarning(
                "Inbound API authz failure for method {Method}: not approved (status 403)",
                methodName);

            return Error("NOT_APPROVED", NotApprovedMessage, StatusCodes.Status403Forbidden);
        }

        // Telemetry follow-on: count this inbound request against the resolved,
        // registered method name. method.Name comes from the repository's method
        // catalogue (an exact-name lookup), so the `method` tag is bounded to the
        // set of configured API methods — never the raw caller-supplied route value.
        ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name);

        // Publish the verified key's display name so
        // AuditWriteMiddleware can populate AuditEvent.Actor in its finally
        // block. Done AFTER auth+authz succeeded — auth failures leave the
        // slot empty and the middleware records the row with Actor=null.
        httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
            identity.DisplayName;

        // N5: a body is present when Content-Length says so, OR when the request is
        // chunked — Transfer-Encoding present and Content-Length (necessarily) absent.
        // Computed once and used by BOTH the 415 guard and the JSON parse condition so
        // chunked and fixed-length bodies behave identically.
        var requestHeaders = httpContext.Request.Headers;
        var hasBody = httpContext.Request.ContentLength > 0
            || (httpContext.Request.ContentLength is null && requestHeaders.TransferEncoding.Count > 0);

        // S9: a body sent with an explicit non-JSON Content-Type is a media-type
        // mismatch, not malformed JSON — reject it with 415 rather than a misleading
        // 400 "invalid JSON". A body with NO Content-Type header is still parsed
        // leniently as JSON below (preserving existing callers). The sniff is
        // ordinal-ignore-case so "application/JSON" etc. are treated as JSON. N5: the
        // body signal is `hasBody`, so a chunked (no Content-Length) non-JSON body is
        // also caught here instead of slipping through to a misleading 400.
        if (hasBody
            && !string.IsNullOrEmpty(httpContext.Request.ContentType)
            && httpContext.Request.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) == false)
        {
            return Error(
                "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json",
                StatusCodes.Status415UnsupportedMediaType);
        }

        // Deserialize and validate parameters
        JsonElement? body = null;
        try
        {
            // The content-type sniff must be case-insensitive — a
            // request with `application/JSON` or `Application/Json` is still JSON
            // and must enter the body-parsing path. The previous case-sensitive
            // `Contains("json")` silently skipped JSON deserialization for any
            // capitalised value, leaving `body = null` and surfacing required
            // parameters as 400 "missing" even though the caller sent a valid body.
            if (hasBody
                || httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
            {
                using var doc = await JsonDocument.ParseAsync(
                    httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
                body = doc.RootElement.Clone();
            }
        }
        catch (JsonException)
        {
            return Error("INVALID_JSON", "Invalid JSON in request body", StatusCodes.Status400BadRequest);
        }

        // Thread the JSON-Schema $ref resolution seam into parameter
        // validation so a method whose ParameterDefinitions use a {"$ref":"lib:Name"}
        // resolves the reference at RUNTIME (not just at deploy time). The shared-schema
        // library is pre-loaded ONCE per request into an in-memory map (the seam the
        // validator consumes is synchronous), and ONLY when the definition actually uses
        // a $ref — a $ref-free method skips the repository round-trip entirely. A dangling
        // ref surfaces as a clear, descriptive 400 naming the missing reference rather
        // than an opaque parse-error 400 (the deploy-passes/runtime-breaks defect).
        var sharedSchemaRepo =
            httpContext.RequestServices.GetService<ISharedSchemaRepository>();
        var resolveRef = await SchemaRefResolver.BuildAsync(
            sharedSchemaRepo, [method.ParameterDefinitions], httpContext.RequestAborted);

        var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions, resolveRef);
        if (!paramResult.IsValid)
        {
            return Error(
                "VALIDATION_FAILED", paramResult.ErrorMessage ?? "Parameter validation failed",
                StatusCodes.Status400BadRequest);
        }

        // Execute the method's script
        var timeout = method.TimeoutSeconds > 0
            ? TimeSpan.FromSeconds(method.TimeoutSeconds)
            : options.DefaultMethodTimeout;

        // The inbound request's per-request
        // ExecutionId was minted early by AuditWriteMiddleware and stashed on
        // HttpContext.Items. Thread it into the executor so a routed
        // Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
        // — the spawned site script execution points back at this inbound request.
        var parentExecutionId =
            httpContext.Items.TryGetValue(
                AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId)
            && stashedExecutionId is Guid inboundExecutionId
                ? inboundExecutionId
                : (Guid?)null;

        var scriptResult = await executor.ExecuteAsync(
            method, paramResult.Parameters, routeHelper, timeout,
            httpContext.RequestAborted, parentExecutionId);

        if (!scriptResult.Success)
        {
            // A client-aborted request is not a script failure.
            // Do not pollute the failure log (reserved for genuine script errors)
            // and do not attempt to write a 500 body to an already-gone connection.
            if (httpContext.RequestAborted.IsCancellationRequested)
            {
                return Results.Empty;
            }

            // 500 for script failures, safe error message
            logger.LogWarning(
                "Inbound API script failure for method {Method}: {Error}",
                methodName, scriptResult.ErrorMessage);

            // C3: propagate the executor's machine-readable code (TIMEOUT,
            // SITE_UNREACHABLE, SCRIPT_COMPILE_FAILED, …); an unclassified failure
            // falls back to the SCRIPT_ERROR catch-all.
            return Error(
                scriptResult.ErrorCode ?? "SCRIPT_ERROR",
                scriptResult.ErrorMessage ?? "Internal server error",
                StatusCodes.Status500InternalServerError);
        }

        // Return the script result as JSON
        if (scriptResult.ResultJson != null)
        {
            return Results.Text(scriptResult.ResultJson, "application/json", statusCode: 200);
        }

        return Results.Ok();
    }
}
