#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Reviews;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Reviews.Commands.ModerateReview;
using Baya.Application.Features.Reviews.Commands.SubmitReview;
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Reviews;
using Baya.Domain.Entities.SupportAlerts;
using NSubstitute;

namespace Baya.Test.Foundation.Reviews;

public class ReviewHandlerTests
{
    private static SubmitReviewCommandHandler SubmitHandler(
        ReviewsTestHost host, ISupportAlertService alerts, ModerationDecision decision = ModerationDecision.Flag)
        => new(
            host.AsCustomer(), host.UnitOfWork, host.Config(), host.Moderation(decision),
            alerts, Substitute.For<ISearchIndexMaintainer>(), Substitute.For<ICacheService>(), host.Clock());

    private static ModerateReviewCommandHandler ModerateHandler(ReviewsTestHost host)
        => new(
            host.AsAdmin(), host.UnitOfWork, Substitute.For<ISearchIndexMaintainer>(),
            Substitute.For<ICacheService>(), host.Clock(), Substitute.For<INotificationDispatcher>());

    [Fact]
    public async Task Submit_on_completed_booking_creates_pending_and_not_public()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);

        var result = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.Equal(ReviewModerationStatus.PendingModeration, result.Result.ModerationStatus);

        // Publish gate: a pending review is not in the public list and not counted in the aggregate.
        var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache())
            .Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None);
        Assert.Empty(list.Result.Reviews.Items);
        Assert.Equal(0, list.Result.Aggregate.PublishedCount);
    }

    [Fact]
    public async Task Submit_on_cancelled_booking_fails_and_duplicate_is_conflict()
    {
        using var host = new ReviewsTestHost();

        var cancelled = host.SeedBooking(BookingStatus.Cancelled);
        var onCancelled = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(cancelled, 5, null, null), CancellationToken.None);
        Assert.False(onCancelled.IsSuccess);
        Assert.False(onCancelled.IsException);

        var completed = host.SeedBooking(BookingStatus.Completed);
        var first = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(completed, 4, null, null), CancellationToken.None);
        Assert.True(first.IsSuccess);

        var second = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(completed, 3, null, null), CancellationToken.None);
        Assert.False(second.IsSuccess);
        Assert.True(second.IsConflict);
    }

    [Fact]
    public async Task Submit_by_non_owner_is_not_found()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);

        // A different user with a customer role but not the booking owner (no matching customer profile).
        var stranger = new SubmitReviewCommandHandler(
            host.AsUser(4242, Domain.Entities.User.RoleNames.Customer), host.UnitOfWork, host.Config(),
            host.Moderation(), Substitute.For<ISupportAlertService>(), Substitute.For<ISearchIndexMaintainer>(),
            Substitute.For<ICacheService>(), host.Clock());

        var result = await stranger.Handle(new SubmitReviewCommand(bookingId, 5, null, null), CancellationToken.None);
        Assert.False(result.IsSuccess);
        Assert.True(result.IsForbidden || result.IsNotFound);
    }

    [Fact]
    public async Task Moderation_recomputes_aggregate_from_source_on_publish_and_hide()
    {
        using var host = new ReviewsTestHost();
        var high = host.SeedBooking(BookingStatus.Completed);
        var low = host.SeedBooking(BookingStatus.Completed);

        var r5 = (await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(high, 5, "excellent", null), CancellationToken.None)).Result.Id;
        var r1 = (await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(low, 1, "poor", null), CancellationToken.None)).Result.Id;

        // Publish both → count 2, avg 3.0.
        await ModerateHandler(host).Handle(new ModerateReviewCommand(r5, ReviewModerationAction.Publish, null), CancellationToken.None);
        var afterBoth = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Publish, null), CancellationToken.None);
        Assert.Equal(2, afterBoth.Result.TotalReviews);
        Assert.Equal(3.0m, afterBoth.Result.AverageRating);
        Assert.Equal(2, host.TotalReviewsOf(host.NurseId));

        // Hide the 1★ → count 1, avg 5.0 (re-derived from source, not stale).
        var afterHide = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Hide, "off_topic"), CancellationToken.None);
        Assert.Equal(1, afterHide.Result.TotalReviews);
        Assert.Equal(5.0m, afterHide.Result.AverageRating);
        Assert.Equal(1, host.TotalReviewsOf(host.NurseId));
        Assert.Equal(5.0m, host.AverageRatingOf(host.NurseId));
    }

    [Fact]
    public async Task Low_rating_raises_support_alert()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);
        var alerts = Substitute.For<ISupportAlertService>();

        var result = await SubmitHandler(host, alerts)
            .Handle(new SubmitReviewCommand(bookingId, 1, "unhappy", null), CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.True(result.Result.LowRatingAlertRaised);
        await alerts.Received(1).RaiseAsync(
            SupportAlertType.LowRating, "review", Arg.Any<string>(), SupportAlertSeverity.High,
            bookingId, result.Result.Id, Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task High_rating_does_not_raise_alert()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);
        var alerts = Substitute.For<ISupportAlertService>();

        var result = await SubmitHandler(host, alerts)
            .Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.False(result.Result.LowRatingAlertRaised);
        await alerts.DidNotReceive().RaiseAsync(
            Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
            Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Submit_with_tags_persists_links_and_surfaces_on_publish()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);

        var submit = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(bookingId, 5, "great", [ReviewTagCodes.Punctual, ReviewTagCodes.Kind]), CancellationToken.None);
        Assert.True(submit.IsSuccess);

        await ModerateHandler(host).Handle(new ModerateReviewCommand(submit.Result.Id, ReviewModerationAction.Publish, null), CancellationToken.None);

        var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache())
            .Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None);
        var item = Assert.Single(list.Result.Reviews.Items);
        Assert.Contains(ReviewTagCodes.Punctual, item.TagCodes);
        Assert.Contains(ReviewTagCodes.Kind, item.TagCodes);
    }

    [Fact]
    public async Task Submit_with_unknown_tag_fails()
    {
        using var host = new ReviewsTestHost();
        var bookingId = host.SeedBooking(BookingStatus.Completed);

        var result = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
            .Handle(new SubmitReviewCommand(bookingId, 5, null, ["not_a_real_tag"]), CancellationToken.None);

        Assert.False(result.IsSuccess);
        Assert.False(result.IsException);
    }

    /// <summary>A cache that always runs the factory — exercises the real aggregate read without caching.</summary>
    private sealed class PassThroughCache : ICacheService
    {
        public ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
            => ValueTask.FromResult<T?>(default);

        public ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;

        public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;

        public ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
            => factory(cancellationToken);
    }
}
