using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Mcp.Tests;

[TestFixture]
public class ErsatzTvApiClientTests
{
    private static ToolDefinition GetChannel() => new(
        "ersatztv_get_channel",
        "Get channel",
        HttpMethod.Get,
        "/api/v1/channels/{id}",
        ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));

    [Test]
    public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
    {
        CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));

        ToolCallResult result = await client.CallToolAsync(
            GetChannel(),
            JsonDocument.Parse("""{"id":12}""").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeFalse();
        result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
        handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/v1/channels/12"));
        handler.ApiKey.ShouldBe("secret");
    }

    [Test]
    public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
    {
        CapturingHandler handler = new("""{"id":1}""");
        var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_get_resolution_by_name",
                "Get resolution",
                HttpMethod.Get,
                "/api/v1/ffmpeg/resolution/by-name/{name}",
                ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
            JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
            CancellationToken.None);

        handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/ffmpeg/resolution/by-name/1920%20x%201080"));
    }

    [Test]
    public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
    {
        CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
        var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));

        ToolCallResult result = await client.CallToolAsync(
            GetChannel(),
            JsonDocument.Parse("""{"id":404}""").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeTrue();
        result.Text.ShouldContain("404");
        result.Text.ShouldContain("Resource not found");
    }

    [Test]
    public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
    {
        CapturingHandler handler = new("{}");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_delete_channel",
                "Delete channel",
                HttpMethod.Delete,
                "/api/v1/channels/{id}",
                ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
            JsonDocument.Parse("""{"id":12}""").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeTrue();
        result.Text.ShouldContain("read-only");
        // The request must never reach the API.
        handler.RequestUri.ShouldBeNull();
    }

    [Test]
    public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
    {
        CapturingHandler handler = new("", HttpStatusCode.NoContent);
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_delete_channel",
                "Delete channel",
                HttpMethod.Delete,
                "/api/v1/channels/{id}",
                ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
            JsonDocument.Parse("""{"id":12}""").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeFalse();
        handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/channels/12"));
    }

    [Test]
    public async Task CallToolAsync_Should_Serialize_Non_Path_Args_As_Json_Body_For_Post()
    {
        CapturingHandler handler = new("""{"id":9,"name":"Kids"}""", HttpStatusCode.Created);
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_create_collection",
                "Create collection",
                HttpMethod.Post,
                "/api/v1/collections",
                ToolInputSchemas.Object(new SchemaProperty("name", "string", "Name", Required: true))),
            JsonDocument.Parse("""{"name":"Kids"}""").RootElement,
            CancellationToken.None);

        handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections"));
        handler.ContentType.ShouldBe("application/json");
        handler.Body.ShouldBe("""{"name":"Kids"}""");
    }

    [Test]
    public async Task CallToolAsync_Should_Keep_Path_Args_Out_Of_The_Body_And_Preserve_Array_Values()
    {
        CapturingHandler handler = new("", HttpStatusCode.NoContent);
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_add_collection_items",
                "Add items",
                HttpMethod.Post,
                "/api/v1/collections/{id}/items",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Collection id", Required: true),
                    new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer"))),
            JsonDocument.Parse("""{"id":20,"artistIds":[1,2,3]}""").RootElement,
            CancellationToken.None);

        handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections/20/items"));
        // The path id must not leak into the body; array values are preserved verbatim.
        handler.Body.ShouldBe("""{"artistIds":[1,2,3]}""");
    }

    [Test]
    public async Task CallToolAsync_Should_Send_IfMatch_Header_And_Keep_It_Out_Of_The_Body()
    {
        CapturingHandler handler = new("[]");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_update_collection_custom_order",
                "Reorder",
                HttpMethod.Put,
                "/api/v1/collections/{id}/custom-order",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Collection id", Required: true),
                    new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
                    new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
            JsonDocument.Parse("""{"id":20,"mediaItemIds":[3,1,2],"ifMatch":"\"5\""}""").RootElement,
            CancellationToken.None);

        handler.IfMatch.ShouldBe("\"5\"");
        // ifMatch is a header, not a body field; the path id is also excluded.
        handler.Body.ShouldBe("""{"mediaItemIds":[3,1,2]}""");
    }

    [Test]
    public async Task CallToolAsync_Should_Pass_Through_IfMatch_Wildcard_Force_Write()
    {
        CapturingHandler handler = new("[]");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_update_collection_custom_order",
                "Reorder",
                HttpMethod.Put,
                "/api/v1/collections/{id}/custom-order",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Collection id", Required: true),
                    new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
                    new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
            JsonDocument.Parse("""{"id":20,"mediaItemIds":[1],"ifMatch":"*"}""").RootElement,
            CancellationToken.None);

        handler.IfMatch.ShouldBe("*");
    }

    [Test]
    public async Task CallToolAsync_Should_Reject_IfMatch_With_Control_Characters()
    {
        CapturingHandler handler = new("[]");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        // A CR/LF in ifMatch would smuggle a second header via TryAddWithoutValidation — must be
        // rejected before the request is sent (SocketsHttpHandler does not strip it).
        await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_update_collection_custom_order",
                "Reorder",
                HttpMethod.Put,
                "/api/v1/collections/{id}/custom-order",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Collection id", Required: true),
                    new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
                    new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
            JsonDocument.Parse("{\"id\":20,\"mediaItemIds\":[1],\"ifMatch\":\"\\\"5\\\"\\r\\nX-Evil: 1\"}").RootElement,
            CancellationToken.None));

        handler.RequestUri.ShouldBeNull();
    }

    [Test]
    public async Task CallToolAsync_Should_Serialize_Explicit_Null_Body_Field()
    {
        CapturingHandler handler = new("{}");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        // An explicit null on an optional field is preserved in the body (clears a nullable API field).
        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_update_playout",
                "Update playout",
                HttpMethod.Put,
                "/api/v1/playouts/{id}",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Playout id", Required: true),
                    new SchemaProperty("dailyRebuildTime", "string", "Daily rebuild time; null clears", Required: false))),
            JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement,
            CancellationToken.None);

        handler.Body.ShouldBe("""{"dailyRebuildTime":null}""");
    }

    [Test]
    public async Task CallToolAsync_Should_Append_Declared_Query_Parameters_On_Get()
    {
        CapturingHandler handler = new("[]");
        var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_search_all_items",
                "Search",
                HttpMethod.Get,
                "/api/v1/search/all-items",
                ToolInputSchemas.Object(
                    new SchemaProperty("query", "string", "Query", Required: false),
                    new SchemaProperty("pageNum", "integer", "Page", Required: false)),
                new HashSet<string>(StringComparer.Ordinal) { "query", "pageNum" }),
            JsonDocument.Parse("""{"query":"genre:jazz","pageNum":2}""").RootElement,
            CancellationToken.None);

        handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/search/all-items?query=genre%3Ajazz&pageNum=2");
    }

    [Test]
    public async Task CallToolAsync_Should_Put_Query_Params_In_Query_Not_Body_For_Post()
    {
        CapturingHandler handler = new("", HttpStatusCode.Accepted);
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));

        await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_scan_library",
                "Scan",
                HttpMethod.Post,
                "/api/v1/libraries/{id}/scan",
                ToolInputSchemas.Object(
                    new SchemaProperty("id", "integer", "Library id", Required: true),
                    new SchemaProperty("deep", "boolean", "Deep", Required: false)),
                new HashSet<string>(StringComparer.Ordinal) { "deep" }),
            JsonDocument.Parse("""{"id":3,"deep":true}""").RootElement,
            CancellationToken.None);

        handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/libraries/3/scan?deep=true");
        // deep is a query param; there is no JSON body.
        handler.Body.ShouldBeNull();
    }

    [Test]
    public async Task CallToolAsync_Should_Surface_Response_ETag()
    {
        CapturingHandler handler = new("""{"items":[]}""", HttpStatusCode.OK, etag: "\"3\"");
        var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_get_collection_items",
                "Items",
                HttpMethod.Get,
                "/api/v1/collections/{id}/items",
                ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Collection id", Required: true))),
            JsonDocument.Parse("""{"id":20}""").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeFalse();
        result.Text.ShouldContain("[etag: \"3\"]");
    }

    [Test]
    public async Task CallToolAsync_Should_Truncate_Oversized_Response()
    {
        CapturingHandler handler = new(new string('x', 500));
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
            JsonDocument.Parse("{}").RootElement,
            CancellationToken.None);

        result.Text.ShouldStartWith(new string('x', 16));
        result.Text.ShouldContain("truncated");
        result.Text.Length.ShouldBeLessThan(500);
    }

    [Test]
    public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
    {
        CapturingHandler handler = new("""{"id":12}""");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));

        await client.CallToolAsync(
            GetChannel(),
            JsonDocument.Parse("""{"id":12}""").RootElement,
            CancellationToken.None);

        handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/v1/channels/12"));
    }

    [Test]
    public async Task CallToolAsync_Should_Reject_Unknown_Argument()
    {
        CapturingHandler handler = new("{}");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));

        await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
            GetChannel(),
            JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
            CancellationToken.None));

        handler.RequestUri.ShouldBeNull();
    }

    [Test]
    public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
    {
        CapturingHandler handler = new("{}");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));

        // ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
        await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
            new ToolDefinition(
                "ersatztv_get_resolution_by_name",
                "Get resolution",
                HttpMethod.Get,
                "/api/v1/ffmpeg/resolution/by-name/{name}",
                ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
            JsonDocument.Parse("""{"name":".."}""").RootElement,
            CancellationToken.None));

        handler.RequestUri.ShouldBeNull();
    }

    [Test]
    public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
    {
        CapturingHandler handler = new("""{"ok":true}""");
        // int.MaxValue would overflow `cap + 1` to a negative array length; the client must
        // clamp to the default instead of crashing.
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
            JsonDocument.Parse("{}").RootElement,
            CancellationToken.None);

        result.IsError.ShouldBeFalse();
        result.Text.ShouldBe("""{"ok":true}""");
    }

    [Test]
    public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
    {
        // "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
        // mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
        CapturingHandler handler = new("ab\U0001F600");
        var client = new ErsatzTvApiClient(
            new HttpClient(handler),
            new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));

        ToolCallResult result = await client.CallToolAsync(
            new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
            JsonDocument.Parse("{}").RootElement,
            CancellationToken.None);

        result.Text.ShouldStartWith("ab");
        result.Text.ShouldNotContain("�");
        result.Text.ShouldContain("truncated");
    }

    private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK, string? etag = null)
        : HttpMessageHandler
    {
        public Uri? RequestUri { get; private set; }
        public string? ApiKey { get; private set; }
        public string? Body { get; private set; }
        public string? ContentType { get; private set; }
        public string? IfMatch { get; private set; }

        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            RequestUri = request.RequestUri;
            ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
                ? values.Single()
                : null;
            IfMatch = request.Headers.TryGetValues("If-Match", out IEnumerable<string>? ifMatch)
                ? ifMatch.Single()
                : null;
            if (request.Content is not null)
            {
                Body = request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
                ContentType = request.Content.Headers.ContentType?.MediaType;
            }

            var message = new HttpResponseMessage(statusCode)
            {
                Content = new StringContent(response)
            };
            if (etag is not null)
            {
                message.Headers.ETag = new EntityTagHeaderValue(etag);
            }

            return Task.FromResult(message);
        }
    }
}
