using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Search;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Controllers;

[TestFixture]
public class SearchControllerTests
{
    private SearchController _controller = null!;
    private IMediator _mediator = null!;

    [SetUp]
    public void SetUp()
    {
        _mediator = Substitute.For<IMediator>();
        _controller = new SearchController(_mediator);
    }

    [Test]
    public void Controller_Should_Expose_Search_Route_With_Stable_Operation_Name()
    {
        MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.Search))
            ?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}");

        var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
        attribute.Template.ShouldBe("/api/v1/search");
        attribute.Name.ShouldBe("Search");
    }

    [Test]
    public async Task Search_Should_Return_422_For_Empty_Query()
    {
        IActionResult result = await _controller.Search("   ", 0, 50, CancellationToken.None);

        var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
        unprocessable.StatusCode.ShouldBe(422);
        await _mediator.DidNotReceive().Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Search_Should_Clamp_PageSize_And_Send_Query()
    {
        _mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
            .Returns(EmptyResults());

        IActionResult result = await _controller.Search("star", 0, 500, CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>();
        await _mediator.Received(1).Send(
            Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageSize == 100),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Search_Should_Thread_PageNum_And_Clamp_Negative()
    {
        _mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
            .Returns(EmptyResults());

        // A valid page number is threaded through; a negative one clamps to 0.
        await _controller.Search("star", 3, 50, CancellationToken.None);
        await _mediator.Received(1).Send(
            Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 3 && q.PageSize == 50),
            Arg.Any<CancellationToken>());

        await _controller.Search("star", -7, 50, CancellationToken.None);
        await _mediator.Received(1).Send(
            Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 50),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Search_Should_Return_Grouped_Results()
    {
        SearchResultsResponseModel results = EmptyResults();
        _mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>()).Returns(results);

        IActionResult result = await _controller.Search("star", 0, 50, CancellationToken.None);

        var ok = result.ShouldBeOfType<OkObjectResult>();
        ok.Value.ShouldBe(results);
    }

    [Test]
    public void Controller_Should_Expose_SearchAllItems_Route_With_Stable_Operation_Name()
    {
        MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.SearchAllItems))
            ?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}");

        var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
        attribute.Template.ShouldBe("/api/v1/search/all-items");
        attribute.Name.ShouldBe("SearchAllItems");
    }

    [Test]
    public async Task SearchAllItems_Should_Return_422_For_Empty_Query()
    {
        IActionResult result = await _controller.SearchAllItems("   ", 0, 500, CancellationToken.None);

        var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
        unprocessable.StatusCode.ShouldBe(422);
        await _mediator.DidNotReceive().Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task SearchAllItems_Should_Clamp_Paging_And_Thread_PageNum()
    {
        _mediator.Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>())
            .Returns(EmptyAllItems());

        // A valid pageNum threads through; a pageSize above the max clamps to 1000 (issue #293).
        await _controller.SearchAllItems("star", 2, 50_000, CancellationToken.None);
        await _mediator.Received(1).Send(
            Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star" && q.PageNum == 2 && q.PageSize == 1000),
            Arg.Any<CancellationToken>());

        // A negative pageNum clamps to 0; a pageSize below 1 clamps to 1.
        await _controller.SearchAllItems("star", -3, 0, CancellationToken.None);
        await _mediator.Received(1).Send(
            Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 1),
            Arg.Any<CancellationToken>());

        // An absurd pageNum clamps to the max so pageNum * pageSize can't overflow int (issue #293 hardening).
        await _controller.SearchAllItems("star", 9_999_999, 1000, CancellationToken.None);
        await _mediator.Received(1).Send(
            Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star" && q.PageNum == 2_000_000 && q.PageSize == 1000),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task SearchAllItems_Should_Map_Id_Lists_And_Totals()
    {
        _mediator.Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>())
            .Returns(new SearchResultAllItemsViewModel(
                [1, 2],
                [3],
                [],
                [],
                [],
                [],
                [],
                [],
                [],
                [],
                new SearchResultAllItemsTotals(5, 1, 0, 0, 0, 0, 0, 0, 0, 0)));

        IActionResult result = await _controller.SearchAllItems("star", 0, 500, CancellationToken.None);

        var body = result.ShouldBeOfType<OkObjectResult>().Value
            .ShouldBeOfType<SearchResultAllItemsResponseModel>();
        body.MovieIds.ShouldBe(new List<int> { 1, 2 });
        body.ShowIds.ShouldBe(new List<int> { 3 });
        body.Totals.MovieCount.ShouldBe(5);
        body.Totals.ShowCount.ShouldBe(1);
        await _mediator.Received(1).Send(
            Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star"),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task SearchCollections_Should_Map_To_Picker_Options()
    {
        _mediator.Send(Arg.Any<SearchCollections>(), Arg.Any<CancellationToken>())
            .Returns(new List<MediaCollectionViewModel>
            {
                new(CollectionType.Collection, 3, "Movies", false, new MediaItemState())
            });

        List<SchedulingPickerOptionResponseModel> result =
            await _controller.SearchCollections("mov", CancellationToken.None);

        result.ShouldHaveSingleItem();
        result[0].Id.ShouldBe(3);
        result[0].Name.ShouldBe("Movies");
        await _mediator.Received(1).Send(
            Arg.Is<SearchCollections>(q => q.Query == "mov"),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task SearchTelevisionShows_Should_Map_MediaItemId_To_Option_Id()
    {
        _mediator.Send(Arg.Any<SearchTelevisionShows>(), Arg.Any<CancellationToken>())
            .Returns(new List<NamedMediaItemViewModel> { new(42, "The Show") });

        List<SchedulingPickerOptionResponseModel> result =
            await _controller.SearchTelevisionShows("show", CancellationToken.None);

        result.ShouldHaveSingleItem();
        result[0].Id.ShouldBe(42);
        result[0].Name.ShouldBe("The Show");
    }

    [Test]
    public async Task SearchSmartCollections_Should_Map_To_Picker_Options()
    {
        _mediator.Send(Arg.Any<SearchSmartCollections>(), Arg.Any<CancellationToken>())
            .Returns(new List<SmartCollectionViewModel> { new(9, "Recent", "query") });

        List<SchedulingPickerOptionResponseModel> result =
            await _controller.SearchSmartCollections("rec", CancellationToken.None);

        result.ShouldHaveSingleItem();
        result[0].Id.ShouldBe(9);
    }

    private static SearchResultsResponseModel EmptyResults()
    {
        var empty = new SearchResultGroupResponseModel(0, []);
        return new SearchResultsResponseModel(empty, empty, empty, empty, empty, empty, empty, empty, empty, empty);
    }

    private static SearchResultAllItemsViewModel EmptyAllItems() =>
        new(
            [],
            [],
            [],
            [],
            [],
            [],
            [],
            [],
            [],
            [],
            new SearchResultAllItemsTotals(0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
}
