using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;

namespace ErsatzTV.Tests.Controllers;

[TestFixture]
public class CollectionControllerTests
{
    private CollectionController _controller = null!;
    private IMediator _mediator = null!;

    [SetUp]
    public void SetUp()
    {
        _mediator = Substitute.For<IMediator>();
        _controller = new CollectionController(_mediator)
        {
            // Real HttpContext so the #253 ETag/If-Match concurrency headers can be read/written.
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };
    }

    [Test]
    public void Controller_Should_Expose_Idiomatic_Rest_Routes()
    {
        ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/v1/collections");
        ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/v1/collections/{id:int}");
        ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/v1/collections/{id:int}/items");
        ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/v1/collections");
        ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/v1/collections/{id:int}");
        ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/v1/collections/{id:int}");
        ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/v1/collections/{id:int}/items");
        ShouldHaveActionRoute(
            nameof(CollectionController.RemoveItem),
            "DELETE",
            "/api/v1/collections/{id:int}/items/{mediaItemId:int}");
        ShouldHaveActionRoute(
            nameof(CollectionController.UpdateCustomOrder),
            "PUT",
            "/api/v1/collections/{id:int}/custom-order");
    }

    [Test]
    public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing()
    {
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.None);

        IActionResult result = await _controller.UpdateCustomOrder(
            99,
            new UpdateCollectionCustomOrderRequest([10, 20]),
            CancellationToken.None);

        var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
        notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
        await _mediator.DidNotReceive().Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order()
    {
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
        _mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, Unit>(Unit.Default));

        IActionResult result = await _controller.UpdateCustomOrder(
            7,
            new UpdateCollectionCustomOrderRequest([30, 10, 20]),
            CancellationToken.None);

        result.ShouldBeOfType<NoContentResult>();
        await _mediator.Received(1).Send(
            Arg.Is<UpdateCollectionCustomOrder>(c =>
                c.CollectionId == 7 &&
                c.MediaItemCustomOrders.Count == 3 &&
                c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 &&
                c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 &&
                c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error()
    {
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
        _mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, Unit>(BaseError.New("bad")));

        IActionResult result = await _controller.UpdateCustomOrder(
            7,
            new UpdateCollectionCustomOrderRequest([10]),
            CancellationToken.None);

        result.ShouldBeOfType<UnprocessableEntityObjectResult>();
    }

    [Test]
    public async Task Create_Should_Return_201_With_Location_And_Body()
    {
        MediaCollectionViewModel vm = MakeVm(5, "Movies");
        _mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, MediaCollectionViewModel>(vm));

        IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);

        var created = result.ShouldBeOfType<CreatedResult>();
        created.StatusCode.ShouldBe(201);
        created.Location.ShouldBe("/api/v1/collections/5");
        created.Value.ShouldBe(new MediaCollectionResponseModel(5, "Movies", CollectionType.Collection, false));
    }

    [Test]
    public async Task Create_Should_Return_422_On_Validation_Error()
    {
        _mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, MediaCollectionViewModel>(BaseError.New("bad")));

        IActionResult result = await _controller.Create(new CreateCollectionRequest(string.Empty), CancellationToken.None);

        result.ShouldBeOfType<UnprocessableEntityObjectResult>();
    }

    [Test]
    public async Task Create_Should_Map_Request_To_Command()
    {
        _mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, MediaCollectionViewModel>(MakeVm(5, "Movies")));

        await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);

        await _mediator.Received(1).Send(
            Arg.Is<CreateCollection>(c => c.Name == "Movies"),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Update_Should_Return_200_And_Map_Route_Id()
    {
        _mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, Unit>(Unit.Default));
        MediaCollectionViewModel vm = MakeVm(7, "Updated");
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.Some(vm));

        IActionResult result = await _controller.Update(
            7,
            new UpdateCollectionRequest("Updated", true),
            CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>().Value
            .ShouldBe(new MediaCollectionResponseModel(7, "Updated", CollectionType.Collection, false));
        await _mediator.Received(1).Send(
            Arg.Is<UpdateCollection>(c =>
                c.CollectionId == 7 &&
                c.Name == "Updated" &&
                c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Update_Should_Return_404_For_NotFoundError()
    {
        _mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, Unit>(new NotFoundError("missing")));

        IActionResult result = await _controller.Update(
            99,
            new UpdateCollectionRequest("Missing", false),
            CancellationToken.None);

        result.ShouldBeOfType<NotFoundObjectResult>();
    }

    [Test]
    public async Task Delete_Should_Return_204_On_Success()
    {
        _mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, Unit>(Unit.Default));

        IActionResult result = await _controller.Delete(3, CancellationToken.None);

        result.ShouldBeOfType<NoContentResult>();
    }

    [Test]
    public async Task Delete_Should_Return_404_For_NotFoundError()
    {
        _mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, Unit>(new NotFoundError("missing")));

        IActionResult result = await _controller.Delete(99, CancellationToken.None);

        result.ShouldBeOfType<NotFoundObjectResult>();
    }

    [Test]
    public async Task AddItems_Should_Return_204_And_Map_Route_Id()
    {
        _mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, Unit>(Unit.Default));

        IActionResult result = await _controller.AddItems(
            3,
            new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null),
            CancellationToken.None);

        result.ShouldBeOfType<NoContentResult>();
        await _mediator.Received(1).Send(
            Arg.Is<AddItemsToCollection>(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task AddItems_Should_Return_422_On_Validation_Error()
    {
        _mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, Unit>(BaseError.New("bad")));

        IActionResult result = await _controller.AddItems(
            3,
            new AddItemsToCollectionRequest(null, null, null, null, null, null, null, null, null, [999]),
            CancellationToken.None);

        result.ShouldBeOfType<UnprocessableEntityObjectResult>();
    }

    [Test]
    public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids()
    {
        _mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, Unit>(Unit.Default));

        IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);

        result.ShouldBeOfType<NoContentResult>();
        await _mediator.Received(1).Send(
            Arg.Is<RemoveItemsFromCollection>(c =>
                c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task RemoveItem_Should_Return_404_For_NotFoundError()
    {
        _mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, Unit>(new NotFoundError("missing")));

        IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);

        result.ShouldBeOfType<NotFoundObjectResult>();
    }

    [Test]
    public async Task GetAll_Should_Return_Collections()
    {
        List<MediaCollectionViewModel> vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")];
        _mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
            .Returns(vms);

        List<MediaCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);

        result.ShouldBe(
            [
                new MediaCollectionResponseModel(1, "Movies", CollectionType.Collection, false),
                new MediaCollectionResponseModel(2, "Shows", CollectionType.Collection, false)
            ]);
    }

    [Test]
    public async Task GetById_Should_Return_200_For_Some()
    {
        MediaCollectionViewModel vm = MakeVm(4, "Movies");
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.Some(vm));

        IActionResult result = await _controller.GetById(4, CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>().Value
            .ShouldBe(new MediaCollectionResponseModel(4, "Movies", CollectionType.Collection, false));
    }

    [Test]
    public async Task GetById_Should_Return_404_For_None()
    {
        _mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
            .Returns(Option<MediaCollectionViewModel>.None);

        IActionResult result = await _controller.GetById(4, CancellationToken.None);

        var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
        var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
        problemDetails.Status.ShouldBe(404);
        problemDetails.Title.ShouldBe("Resource not found");
    }

    [Test]
    public async Task GetItems_Should_Return_200_With_Page_And_Clamp_Paging()
    {
        var page = new PagedLibraryBrowseItemsResponseModel(0, []);
        _mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, PagedLibraryBrowseItemsResponseModel>(page));

        IActionResult result = await _controller.GetItems(7, -5, 999, CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(page);
        await _mediator.Received(1).Send(
            Arg.Is<GetCollectionItems>(q => q.Id == 7 && q.PageNum == 0 && q.PageSize == 100),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task GetItems_Should_Return_404_For_NotFoundError()
    {
        _mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, PagedLibraryBrowseItemsResponseModel>(new NotFoundError("missing")));

        IActionResult result = await _controller.GetItems(99, 0, 100, CancellationToken.None);

        var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
        notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
    }

    private static MediaCollectionViewModel MakeVm(int id, string name) =>
        new(CollectionType.Collection, id, name, false, MediaItemState.Normal);

    private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
    {
        MethodInfo action = typeof(CollectionController).GetMethod(actionName)
            ?? throw new AssertionException($"Missing action {actionName}");

        HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
        attribute.HttpMethods.ShouldContain(httpMethod);
        attribute.Template.ShouldBe(route);
    }
}
