using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
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 System.Net;
using System.Net.Http.Headers;
using System.Text;

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

/// <summary>
/// InboundAPI-020: the inbound API handler must accept JSON content types
/// case-insensitively. A request with <c>application/JSON</c>,
/// <c>Application/Json</c>, or <c>application/json</c> must all enter the
/// JSON-deserialization path — the previous <c>Contains("json")</c> check
/// was case-sensitive so a capitalised value silently skipped body parsing
/// and any required parameters surfaced as a 400 even though the caller
/// sent a valid JSON body.
///
/// <para>
/// Auth re-arch (A+B): the request carries a Bearer token verified by the shared
/// ZB.MOM.WW.Auth.ApiKeys verifier (scope == method name), not the legacy X-API-Key
/// header. The content-type behaviour under test is downstream of auth and unchanged.
/// </para>
/// </summary>
public sealed class EndpointContentTypeTests : IDisposable
{
    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";

    private readonly string _sqlitePath =
        Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite");

    [Theory]
    [InlineData("application/json")]
    [InlineData("application/JSON")]
    [InlineData("Application/Json")]
    [InlineData("APPLICATION/JSON")]
    public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
    {
        const string methodName = "echoParam";

        var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
        {
            Id = 1,
            TimeoutSeconds = 10,
            // One Integer parameter, required — proves the body was actually
            // parsed: if the case-sensitive bug returns, body parsing is
            // skipped and the validator reports the missing field as a 400.
            ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
        };

        var repo = Substitute.For<IInboundApiRepository>();
        repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
            .Returns(method);

        using var host = await BuildHostAsync(repo);
        var token = await SeedKeyAsync(host, methodName);
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            // Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
            // setting the header through MediaTypeHeaderValue.Parse — we need the
            // exact casing to reach the server intact.
            Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
        };
        request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.True(
            response.StatusCode == HttpStatusCode.OK,
            $"Expected 200 for content-type '{contentType}' but got {(int)response.StatusCode}: {body}");
        Assert.Contains("42", body);
    }

    [Fact]
    public async Task NonJsonContentType_WithBody_Returns415()
    {
        // Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 +
        // UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body
        // with NO Content-Type header is still parsed leniently as JSON (preserving
        // existing callers).
        const string methodName = "echo415";

        var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
        {
            Id = 1,
            TimeoutSeconds = 10,
            ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
        };

        var repo = Substitute.For<IInboundApiRepository>();
        repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
            .Returns(method);

        using var host = await BuildHostAsync(repo);
        var token = await SeedKeyAsync(host, methodName);
        var client = host.GetTestClient();

        // (a) text/plain body → 415 UNSUPPORTED_MEDIA_TYPE
        var nonJson = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new ByteArrayContent(Encoding.UTF8.GetBytes("hello")),
        };
        nonJson.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
        nonJson.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var nonJsonResponse = await client.SendAsync(nonJson);
        var nonJsonBody = await nonJsonResponse.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.UnsupportedMediaType, nonJsonResponse.StatusCode);
        Assert.Contains("UNSUPPORTED_MEDIA_TYPE", nonJsonBody);

        // (b) body with NO Content-Type header → still parses as JSON (lenient), 200.
        var noContentType = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
        };
        noContentType.Content.Headers.ContentType = null;
        noContentType.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var lenientResponse = await client.SendAsync(noContentType);
        var lenientBody = await lenientResponse.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.OK, lenientResponse.StatusCode);
        Assert.Contains("42", lenientBody);
    }

    /// <summary>Seeds a key scoped for <paramref name="methodName"/> and returns its Bearer token.</summary>
    private static async Task<string> SeedKeyAsync(IHost host, string methodName)
    {
        var services = host.Services;
        var commands = new ApiKeyAdminCommands(
            services.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
            services.GetRequiredService<IApiKeyAdminStore>(),
            services.GetRequiredService<IApiKeyAuditStore>(),
            services.GetRequiredService<IApiKeyPepperProvider>(),
            services.GetRequiredService<SqliteAuthStoreMigrator>());

        var result = await commands.CreateKeyAsync(
            "key1", "ct-caller", new HashSet<string> { methodName },
            constraintsJson: null, remoteAddress: null, CancellationToken.None);
        return result.Token!;
    }

    private async Task<IHost> BuildHostAsync(IInboundApiRepository repo)
    {
        // The pepper provider reads the HOST's IConfiguration (AddZbApiKeyAuth only
        // TryAdds its own), so the api-key settings — pepper included — must live in
        // the host configuration.
        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);
                        // RouteHelper depends on IInstanceLocator + IInstanceRouter
                        // (InboundAPI-017). Tests for content-type handling never
                        // route, so both can be no-op stubs.
                        services.AddSingleton(Substitute.For<IInstanceLocator>());
                        services.Configure<InboundApiOptions>(_ => { });
                        services.AddInboundAPI();
                        services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
                        services.RemoveAll<IInstanceRouter>();
                        services.AddSingleton(Substitute.For<IInstanceRouter>());
                        services.AddLogging();
                    })
                    .Configure(app =>
                    {
                        app.UseRouting();
                        app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
                    });
            });

        return await hostBuilder.StartAsync();
    }

    public void Dispose()
    {
        try
        {
            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.
        }
    }

    [Fact]
    public async Task ChunkedNonJsonBody_Returns415()
    {
        // Arch-review R2 N5: a chunked body has NULL ContentLength — it must not
        // slip past the 415 guard into a misleading VALIDATION_FAILED.
        const string methodName = "echoChunked";
        var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
        {
            Id = 1,
            TimeoutSeconds = 10,
            ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
        };
        var repo = Substitute.For<IInboundApiRepository>();
        repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);

        using var host = await BuildHostAsync(repo);
        var token = await SeedKeyAsync(host, methodName);
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("hello")),
        };
        request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
        request.Headers.TransferEncodingChunked = true;
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
        Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body);
    }

    [Fact]
    public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently()
    {
        // The parse condition must also learn about chunked bodies: a chunked JSON
        // body with no Content-Type previously skipped parsing entirely (body=null →
        // misleading "missing required parameter").
        const string methodName = "echoChunkedJson";
        var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
        {
            Id = 1,
            TimeoutSeconds = 10,
            ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
        };
        var repo = Substitute.For<IInboundApiRepository>();
        repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);

        using var host = await BuildHostAsync(repo);
        var token = await SeedKeyAsync(host, methodName);
        var client = host.GetTestClient();

        var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
        {
            Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
        };
        request.Content.Headers.ContentType = null;
        request.Headers.TransferEncodingChunked = true;
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await client.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

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

    /// <summary>
    /// Content whose length is never computable, forcing HttpClient to transmit it
    /// chunked (no Content-Length header ever reaches the server).
    /// </summary>
    private sealed class ChunkedByteContent(byte[] bytes) : HttpContent
    {
        protected override Task SerializeToStreamAsync(
            Stream stream, System.Net.TransportContext? context) =>
            stream.WriteAsync(bytes, 0, bytes.Length);

        protected override bool TryComputeLength(out long length)
        {
            length = -1;
            return false;
        }
    }
}
