using System.Linq;
using System.Reflection;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
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 ScheduleControllerTests
{
    private ScheduleController _controller = null!;
    private IMediator _mediator = null!;

    [SetUp]
    public void SetUp()
    {
        _mediator = Substitute.For<IMediator>();
        _controller = new ScheduleController(_mediator)
        {
            // Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
            // read from Request and written to Response.
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };
    }

    [Test]
    public void Controller_Should_Expose_Idiomatic_Rest_Routes()
    {
        ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/v1/schedules");
        ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/v1/schedules/{id:int}");
        ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/v1/schedules");
        ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/v1/schedules/{id:int}");
        ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/v1/schedules/{id:int}");
        ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/v1/schedules/{id:int}/items");
        ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/v1/schedules/{id:int}/items");
        ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/v1/schedules/{id:int}/items");
        ShouldHaveActionRoute(
            nameof(ScheduleController.DeleteItem),
            "DELETE",
            "/api/v1/schedules/{id:int}/items/{itemId:int}");
    }

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

        action.GetParameters()[1].ParameterType.ShouldBe(typeof(UpdateScheduleRequest));
    }

    [Test]
    public async Task Create_Should_Return_201_With_Location_And_Body()
    {
        _mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
        ProgramScheduleViewModel vm = MakeSchedule(5, "Daily");
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(vm));

        IActionResult result = await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);

        var created = result.ShouldBeOfType<CreatedResult>();
        created.StatusCode.ShouldBe(201);
        created.Location.ShouldBe("/api/v1/schedules/5");
        created.Value.ShouldBe(
            new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
    }

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

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

        var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
        var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
        problem.Status.ShouldBe(422);
        problem.Title.ShouldBe("Validation failed");
    }

    [Test]
    public async Task Create_Should_Map_Request_To_Command()
    {
        _mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(5, "Daily")));

        await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);

        await _mediator.Received(1).Send(
            Arg.Is<CreateProgramSchedule>(c =>
                c.Name == "Daily" &&
                c.KeepMultiPartEpisodesTogether &&
                c.TreatCollectionsAsShows &&
                c.ShuffleScheduleItems &&
                c.RandomStartPoint &&
                c.FixedStartTimeBehavior == FixedStartTimeBehavior.Flexible),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Update_Should_Return_200_And_Map_Route_Id()
    {
        _mediator.Send(Arg.Any<UpdateProgramSchedule>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, UpdateProgramScheduleResult>(new UpdateProgramScheduleResult(7)));
        ProgramScheduleViewModel vm = MakeSchedule(7, "Updated");
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(vm));

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

        result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
            new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
        await _mediator.Received(1).Send(
            Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
            Arg.Any<CancellationToken>());
    }

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

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

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

    [Test]
    public async Task Delete_Should_Return_204_On_Success()
    {
        _mediator.Send(Arg.Any<DeleteProgramSchedule>(), 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<DeleteProgramSchedule>(), 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 GetById_Should_Return_200_For_Some()
    {
        ProgramScheduleViewModel vm = MakeSchedule(4, "Daily");
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(vm));

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

        result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
            new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
    }

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

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

        result.ShouldBeOfType<NotFoundObjectResult>();
    }

    [Test]
    public async Task GetItems_Should_Return_200_With_Items()
    {
        List<ProgramScheduleItemViewModel> items = [MakeOneItem(11)];
        var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25));
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
        _mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
            .Returns(response);

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

        var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
        envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
        envelope.Items.Count.ShouldBe(1);
        envelope.Items[0].Id.ShouldBe(11);
        await _mediator.Received(1).Send(
            Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task GetItems_Should_Return_404_When_Schedule_Missing()
    {
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.None);

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

        result.ShouldBeOfType<NotFoundObjectResult>();
    }

    [Test]
    public async Task AddItem_Should_Return_201_With_Location_And_Map_Route_Id()
    {
        ProgramScheduleItemViewModel item = MakeOneItem(12);
        _mediator.Send(Arg.Any<AddProgramScheduleItem>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, ProgramScheduleItemViewModel>(item));

        IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None);

        var created = result.ShouldBeOfType<CreatedResult>();
        created.Location.ShouldBe("/api/v1/schedules/4/items/12");
        created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
        await _mediator.Received(1).Send(
            Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
            Arg.Any<CancellationToken>());
    }

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

        IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.Duration), CancellationToken.None);

        result.ShouldBeOfType<UnprocessableEntityObjectResult>();
    }

    [Test]
    public async Task ReplaceItems_Should_Return_200_With_Items_And_Map_Route_Id()
    {
        List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
        _mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
        _mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(items);

        IActionResult result = await _controller.ReplaceItems(
            4,
            new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
            CancellationToken.None);

        var replaced = result.ShouldBeOfType<OkObjectResult>().Value
            .ShouldBeOfType<List<ScheduleItemResponseModel>>();
        replaced.Select(i => i.Id).ShouldBe([21, 22]);
        await _mediator.Received(1).Send(
            Arg.Is<ReplaceProgramScheduleItems>(c =>
                c.ProgramScheduleId == 4 &&
                c.Items.Count == 2 &&
                c.Items[0].Index == 0 &&
                c.Items[1].PlayoutMode == PlayoutMode.Multiple),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task GetItems_Should_Set_ETag_From_Schedule_Version()
    {
        var response = new ProgramScheduleItemsWithDurationViewModel([], null);
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 9)));
        _mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
            .Returns(response);

        await _controller.GetItems(4, CancellationToken.None);

        _controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
    }

    [Test]
    public async Task ReplaceItems_Should_Return_400_On_Malformed_If_Match()
    {
        _controller.Request.Headers.IfMatch = "not-an-etag";

        IActionResult result = await _controller.ReplaceItems(
            4,
            new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
            CancellationToken.None);

        result.ShouldBeOfType<BadRequestObjectResult>();
        await _mediator.DidNotReceive().Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task ReplaceItems_Should_Thread_If_Match_Version_Into_Command_And_Set_New_ETag()
    {
        _controller.Request.Headers.IfMatch = "\"3\"";
        _mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
        _mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns([MakeOneItem(21)]);

        IActionResult result = await _controller.ReplaceItems(
            4,
            new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
            CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>();
        // On success the response carries the refreshed schedule's ETag.
        _controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
        await _mediator.Received(1).Send(
            Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersions == Option<Seq<int>>.Some(new[] { 3 }.ToSeq())),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task ReplaceItems_Without_If_Match_Should_Force_Write()
    {
        _mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
        _mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
            .Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
        _mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns([MakeOneItem(21)]);

        await _controller.ReplaceItems(
            4,
            new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
            CancellationToken.None);

        await _mediator.Received(1).Send(
            Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersions == Option<Seq<int>>.None),
            Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task ReplaceItems_Should_Return_412_On_Precondition_Failed()
    {
        _controller.Request.Headers.IfMatch = "\"2\"";
        _mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(new PreconditionFailedError("stale")));

        IActionResult result = await _controller.ReplaceItems(
            4,
            new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
            CancellationToken.None);

        var objectResult = result.ShouldBeOfType<ObjectResult>();
        objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
    }

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

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

        result.ShouldBeOfType<NoContentResult>();
        await _mediator.Received(1).Send(
            Arg.Is<DeleteProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.ProgramScheduleItemId == 12),
            Arg.Any<CancellationToken>());
    }

    private static CreateScheduleRequest MakeScheduleRequest(string name) =>
        new(
            name,
            KeepMultiPartEpisodesTogether: true,
            TreatCollectionsAsShows: true,
            ShuffleScheduleItems: true,
            RandomStartPoint: true,
            FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible,
            PadToNearestMinute: null);

    private static UpdateScheduleRequest MakeUpdateScheduleRequest(string name) =>
        new(
            name,
            KeepMultiPartEpisodesTogether: true,
            TreatCollectionsAsShows: true,
            ShuffleScheduleItems: true,
            RandomStartPoint: true,
            FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible,
            PadToNearestMinute: null);

    private static ScheduleItemRequest MakeItemRequest(PlayoutMode playoutMode) =>
        new(
            Id: null,
            StartType.Dynamic,
            StartTime: null,
            FixedStartTimeBehavior: null,
            playoutMode,
            CollectionType.SearchQuery,
            CollectionId: null,
            MultiCollectionId: null,
            SmartCollectionId: null,
            RerunCollectionId: null,
            MediaItemId: null,
            PlaylistId: null,
            SearchTitle: "News",
            SearchQuery: "news",
            PlaybackOrder: PlaybackOrder.Shuffle,
            MarathonGroupBy: MarathonGroupBy.None,
            MarathonShuffleGroups: false,
            MarathonShuffleItems: false,
            MarathonBatchSize: null,
            FillWithGroupMode: FillWithGroupMode.None,
            MultipleMode: MultipleMode.Count,
            MultipleCount: "2",
            PlayoutDuration: TimeSpan.FromMinutes(30),
            TailMode: TailMode.None,
            DiscardToFillAttempts: 3,
            CustomTitle: null,
            GuideMode: GuideMode.Normal,
            PreRollFillerId: null,
            MidRollFillerId: null,
            PostRollFillerId: null,
            TailFillerId: null,
            FallbackFillerId: null,
            WatermarkIds: [],
            GraphicsElementIds: [],
            PreferredAudioLanguageCode: null,
            PreferredAudioTitle: null,
            PreferredSubtitleLanguageCode: null,
            SubtitleMode: null);

    private static ProgramScheduleViewModel MakeSchedule(int id, string name, int version = 0) =>
        new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, null, version);

    private static ProgramScheduleItemOneViewModel MakeOneItem(int id) =>
        new(
            id,
            0,
            StartType.Dynamic,
            null,
            null,
            CollectionType.SearchQuery,
            null,
            null,
            null,
            null,
            null,
            null,
            "News",
            "news",
            PlaybackOrder.Shuffle,
            MarathonGroupBy.None,
            false,
            false,
            null,
            FillWithGroupMode.None,
            null,
            GuideMode.Normal,
            null,
            null,
            null,
            null,
            null,
            [],
            [],
            null,
            null,
            null,
            null);

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

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