using Ikar.Server.Controllers;
using Ikar.Server.Data.Entities;
using Ikar.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;

namespace Ikar.Server.Tests;

public sealed class ChannelMembersControllerTests
{
    [Fact]
    public async Task GetPublicChannelByUsername_ReturnsPreviewForNonMember()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var viewer = CreateUser("viewer");
        var channel = CreateChannel(owner);
        channel.PublicUsername = "newsroom";
        dbContext.AddRange(owner, viewer, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
        var response = await controller.GetPublicChannelByUsername("@newsroom", CancellationToken.None);

        var details = ControllerTestFixture.ValueOf(response);
        Assert.Equal(channel.Id, details.Id);
        Assert.Equal(ChatType.Channel, details.Type);
        Assert.False(details.IsSubscribed);
        Assert.True(details.CanSubscribe);
        Assert.Equal("newsroom", details.PublicUsername);
    }

    [Fact]
    public async Task SearchPage_ReturnsPublicChannelForNonMember()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var viewer = CreateUser("viewer");
        var channel = CreateChannel(owner);
        channel.Title = "City News";
        channel.PublicUsername = "city_news";
        channel.Description = "Daily city updates";
        dbContext.AddRange(owner, viewer, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
        var response = await controller.SearchPage(
            q: "city",
            filter: null,
            type: null,
            page: 1,
            limit: 20,
            date: null,
            dateFrom: null,
            dateTo: null,
            authorId: null,
            author: null,
            CancellationToken.None);

        var page = ControllerTestFixture.ValueOf(response);
        var result = Assert.Single(page.Results, x => x.Chat.Id == channel.Id);
        Assert.Null(result.Message);
        Assert.Equal(ChatType.Channel, result.Chat.Type);
        Assert.Equal("city_news", result.Chat.PublicUsername);
        Assert.True(result.Chat.CanSubscribe);
        Assert.False(result.Chat.IsSubscribed);
    }

    [Fact]
    public async Task AddMembers_CanAddBotAsChannelAdmin()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var bot = CreateUser("news_bot", isBot: true);
        var channel = CreateChannel(owner);
        dbContext.AddRange(owner, bot, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.AddMembers(
            channel.Id,
            new AddChatMembersRequest([bot.Id], ChatMemberRole.Admin),
            CancellationToken.None);

        var details = ControllerTestFixture.ValueOf(response);
        var members = details.Members ?? throw new InvalidOperationException("Chat members were not returned.");
        var botMember = Assert.Single(members, member => member.User.Id == bot.Id);
        Assert.True(botMember.User.IsBot);
        Assert.Equal(ChatMemberRole.Admin, botMember.Role);
        Assert.True(botMember.Permissions.CanManageMembers);
        Assert.True(botMember.Permissions.CanPostMessages);
        Assert.True(botMember.Permissions.CanPinMessages);
        Assert.True(botMember.Permissions.CanDeleteMessages);
        Assert.True(botMember.Permissions.CanInviteUsers);
        Assert.Equal(2, details.SubscriberCount);
        Assert.Equal(2, details.AdminCount);
    }

    [Fact]
    public async Task AddMembers_CanAddMemberToGroup()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var group = CreateGroup(owner);
        dbContext.AddRange(owner, member, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.AddMembers(
            group.Id,
            new AddChatMembersRequest([member.Id]),
            CancellationToken.None);

        var details = ControllerTestFixture.ValueOf(response);
        var addedMember = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id);
        Assert.Equal(ChatMemberRole.Member, addedMember.Role);
        Assert.False(addedMember.IsOwner);
        Assert.True(addedMember.Permissions.CanPostMessages);
        Assert.Equal(2, details.SubscriberCount);
    }

    [Fact]
    public async Task AddMembers_DeniesWhenCandidateBlockedAdmin()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var group = CreateGroup(owner);
        dbContext.AddRange(
            owner,
            member,
            group,
            new UserPrivacySettings
            {
                UserId = member.Id,
                User = member,
                BlockedUserIds = owner.Id.ToString()
            });
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.AddMembers(
            group.Id,
            new AddChatMembersRequest([member.Id]),
            CancellationToken.None);

        var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
        Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
        Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == group.Id && x.UserId == member.Id);
    }

    [Fact]
    public async Task SendMessage_DeniesRestrictedGroupMember()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var group = CreateGroup(owner);
        group.Members.Add(new ChatMember
        {
            ChatId = group.Id,
            Chat = group,
            UserId = member.Id,
            User = member,
            Role = ChatMemberRole.Member,
            CanPostMessages = false
        });
        dbContext.AddRange(owner, member, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id);
        var response = await controller.SendMessage(
            group.Id,
            new SendMessageRequest("test"),
            CancellationToken.None);

        var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
        Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
    }

    [Fact]
    public async Task RemoveMember_CanRemoveRegularChannelMember()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.RemoveMember(channel.Id, member.Id, CancellationToken.None);

        var details = ControllerTestFixture.ValueOf(response);
        Assert.DoesNotContain(details.Members ?? [], x => x.User.Id == member.Id);
        Assert.False(dbContext.ChatMembers.Any(x => x.ChatId == channel.Id && x.UserId == member.Id));
        Assert.Equal(1, details.SubscriberCount);
    }

    [Fact]
    public async Task JoinByInviteLink_ReturnsPendingApprovalResultForModeratedInvite()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var requester = CreateUser("requester");
        var channel = CreateChannel(owner);
        var invite = new ChatInviteLink
        {
            ChatId = channel.Id,
            Chat = channel,
            CreatedById = owner.Id,
            CreatedBy = owner,
            Token = "moderated-token",
            CreatesJoinRequest = true
        };
        channel.InviteLinks.Add(invite);
        dbContext.AddRange(owner, requester, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, requester.Id);
        var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None);

        var accepted = Assert.IsType<Microsoft.AspNetCore.Mvc.AcceptedResult>(response.Result);
        var result = Assert.IsType<JoinChatResultDto>(accepted.Value);
        Assert.Equal(JoinChatResultStatus.PendingApproval, result.Status);
        Assert.NotNull(result.JoinRequestId);
        Assert.Equal(ChatJoinRequestStatus.Pending, result.JoinRequestStatus);
        Assert.Null(result.Chat);
        Assert.Single(dbContext.ChatJoinRequests, x => x.ChatId == channel.Id && x.UserId == requester.Id);
    }

    [Fact]
    public async Task CreateInviteLink_ReturnsExpirationAndUsageLimits()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        dbContext.AddRange(owner, channel);
        await dbContext.SaveChangesAsync();
        var expiresAt = DateTimeOffset.UtcNow.AddHours(2);

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.CreateInviteLink(
            channel.Id,
            new CreateChatInviteLinkRequest(
                Title: "limited",
                CreatesJoinRequest: false,
                ExpiresAt: expiresAt,
                MaxUses: 3,
                MemberLimit: 10),
            CancellationToken.None);

        var link = ControllerTestFixture.ValueOf(response);
        Assert.Equal("limited", link.Title);
        Assert.False(link.CreatesJoinRequest);
        Assert.Equal(expiresAt, link.ExpiresAt);
        Assert.Equal(3, link.MaxUses);
        Assert.Equal(0, link.UsesCount);
        Assert.Equal(10, link.MemberLimit);

        var stored = Assert.Single(dbContext.ChatInviteLinks, x => x.ChatId == channel.Id);
        Assert.Equal(expiresAt, stored.ExpiresAt);
        Assert.Equal(3, stored.MaxUses);
        Assert.Equal(0, stored.UsesCount);
        Assert.Equal(10, stored.MemberLimit);
    }

    [Fact]
    public async Task JoinByInviteLink_RejectsExpiredLink()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var viewer = CreateUser("viewer");
        var channel = CreateChannel(owner);
        var invite = new ChatInviteLink
        {
            ChatId = channel.Id,
            Chat = channel,
            CreatedById = owner.Id,
            CreatedBy = owner,
            Token = "expired-token",
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1)
        };
        channel.InviteLinks.Add(invite);
        dbContext.AddRange(owner, viewer, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
        var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Invite link has expired.", badRequest.Value);
        Assert.Equal(0, invite.UsesCount);
        Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id);
    }

    [Fact]
    public async Task JoinByInviteLink_IncrementsUsesAndRejectsAfterMaxUses()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var firstViewer = CreateUser("first");
        var secondViewer = CreateUser("second");
        var channel = CreateChannel(owner);
        var invite = new ChatInviteLink
        {
            ChatId = channel.Id,
            Chat = channel,
            CreatedById = owner.Id,
            CreatedBy = owner,
            Token = "single-use-token",
            MaxUses = 1
        };
        channel.InviteLinks.Add(invite);
        dbContext.AddRange(owner, firstViewer, secondViewer, channel);
        await dbContext.SaveChangesAsync();

        var firstController = ControllerTestFixture.CreateChatsController(dbContext, firstViewer.Id);
        var firstResponse = await firstController.JoinByInviteLink(invite.Token, CancellationToken.None);

        var firstResult = ControllerTestFixture.ValueOf(firstResponse);
        Assert.Equal(JoinChatResultStatus.Joined, firstResult.Status);
        Assert.Equal(1, invite.UsesCount);
        Assert.Contains(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == firstViewer.Id);

        var secondController = ControllerTestFixture.CreateChatsController(dbContext, secondViewer.Id);
        var secondResponse = await secondController.JoinByInviteLink(invite.Token, CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(secondResponse.Result);
        Assert.Equal("Invite link usage limit reached.", badRequest.Value);
        Assert.Equal(1, invite.UsesCount);
        Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == secondViewer.Id);
    }

    [Fact]
    public async Task JoinByInviteLink_RejectsWhenMemberLimitReached()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var viewer = CreateUser("viewer");
        var channel = CreateChannel(owner);
        var invite = new ChatInviteLink
        {
            ChatId = channel.Id,
            Chat = channel,
            CreatedById = owner.Id,
            CreatedBy = owner,
            Token = "full-token",
            MemberLimit = 1
        };
        channel.InviteLinks.Add(invite);
        dbContext.AddRange(owner, viewer, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
        var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Chat member limit reached.", badRequest.Value);
        Assert.Equal(0, invite.UsesCount);
        Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id);
    }

    [Fact]
    public async Task UpdateMemberPermissions_CannotPromoteMemberToOwner()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            IsOwner = false,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UpdateMemberPermissions(
            channel.Id,
            member.Id,
            new UpdateChatMemberPermissionsRequest(
                ChatMemberRole.Owner,
                CanPinMessages: true,
                CanDeleteMessages: true,
                CanInviteUsers: true,
                CanPostMessages: true),
            CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Ownership can only be transferred by a dedicated owner transfer action.", badRequest.Value);

        var members = dbContext.ChatMembers.Where(x => x.ChatId == channel.Id).ToList();
        Assert.Single(members, x => x.IsOwner || x.Role == ChatMemberRole.Owner);
        Assert.Equal(ChatMemberRole.Member, Assert.Single(members, x => x.UserId == member.Id).Role);
    }

    [Fact]
    public async Task UpdateChatState_CanLinkAndClearChannelDiscussionGroup()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var group = CreateGroup(owner);
        dbContext.AddRange(owner, channel, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);

        var linkResponse = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(LinkedDiscussionChatId: group.Id),
            CancellationToken.None);

        var linked = ControllerTestFixture.ValueOf(linkResponse);
        Assert.Equal(group.Id, linked.LinkedDiscussionChatId);
        Assert.Equal(group.Id, dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId);

        var clearResponse = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(ClearLinkedDiscussionChat: true),
            CancellationToken.None);

        var cleared = ControllerTestFixture.ValueOf(clearResponse);
        Assert.Null(cleared.LinkedDiscussionChatId);
        Assert.Null(dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId);
    }

    [Fact]
    public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRootAndCountsNestedReplies()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var group = CreateGroup(owner);
        channel.LinkedDiscussionChatId = group.Id;
        dbContext.AddRange(owner, channel, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var postResponse = await controller.SendMessage(
            channel.Id,
            new SendMessageRequest("Breaking news"),
            CancellationToken.None);

        var postDto = ControllerTestFixture.ValueOf(postResponse);
        Assert.NotNull(postDto.DiscussionMessageId);
        Assert.Equal(0, postDto.CommentCount);

        var rootMessage = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId);
        Assert.Equal(group.Id, rootMessage.ChatId);
        Assert.Equal("Breaking news", rootMessage.Text);
        Assert.Equal(channel.Title, rootMessage.ForwardedFromDisplayName);

        var firstReplyResponse = await controller.SendMessage(
            group.Id,
            new SendMessageRequest("First comment", ReplyToMessageId: rootMessage.Id),
            CancellationToken.None);
        var firstReply = ControllerTestFixture.ValueOf(firstReplyResponse);

        await controller.SendMessage(
            group.Id,
            new SendMessageRequest("Nested comment", ReplyToMessageId: firstReply.Id),
            CancellationToken.None);

        var storedPost = dbContext.Messages.Single(x => x.Id == postDto.Id);
        Assert.Equal(2, storedPost.CommentCount);
    }

    [Fact]
    public async Task UploadAttachment_ChannelWithLinkedDiscussion_MirrorsAttachmentToDiscussionRoot()
    {
        var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-upload-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempRoot);
        try
        {
            await using var database = new SqliteTestDatabase();
            await using var dbContext = await database.CreateContextAsync();
            var owner = CreateUser("owner");
            var channel = CreateChannel(owner);
            var group = CreateGroup(owner);
            channel.LinkedDiscussionChatId = group.Id;
            dbContext.AddRange(owner, channel, group);
            await dbContext.SaveChangesAsync();

            var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot);
            var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage);
            var response = await controller.UploadAttachment(
                channel.Id,
                new UploadAttachmentRequest
                {
                    Text = "Photo post",
                    File = CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")
                },
                CancellationToken.None);

            var postDto = ControllerTestFixture.ValueOf(response);
            Assert.NotNull(postDto.DiscussionMessageId);

            var discussionRoot = dbContext.Messages
                .Include(x => x.Attachments)
                .Single(x => x.Id == postDto.DiscussionMessageId);
            var mirroredAttachment = Assert.Single(discussionRoot.Attachments);
            Assert.Equal(group.Id, discussionRoot.ChatId);
            Assert.Equal("Photo post", discussionRoot.Text);
            Assert.Equal("photo.jpg", mirroredAttachment.OriginalFileName);
            Assert.Equal("image/jpeg", mirroredAttachment.ContentType);
            Assert.True(File.Exists(mirroredAttachment.StoragePath));
        }
        finally
        {
            if (Directory.Exists(tempRoot))
            {
                Directory.Delete(tempRoot, recursive: true);
            }
        }
    }

    [Fact]
    public async Task UploadAttachmentAlbum_ChannelWithLinkedDiscussion_MirrorsAlbumToDiscussionRoot()
    {
        var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-album-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempRoot);
        try
        {
            await using var database = new SqliteTestDatabase();
            await using var dbContext = await database.CreateContextAsync();
            var owner = CreateUser("owner");
            var channel = CreateChannel(owner);
            var group = CreateGroup(owner);
            channel.LinkedDiscussionChatId = group.Id;
            dbContext.AddRange(owner, channel, group);
            await dbContext.SaveChangesAsync();

            var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot);
            var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage);
            var response = await controller.UploadAttachmentAlbum(
                channel.Id,
                new UploadAttachmentAlbumRequest
                {
                    Text = "Gallery",
                    Files =
                    [
                        CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"),
                        CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg")
                    ]
                },
                CancellationToken.None);

            var postDto = ControllerTestFixture.ValueOf(response);
            Assert.NotNull(postDto.DiscussionMessageId);

            var channelPost = dbContext.Messages.Single(x => x.Id == postDto.Id);
            var discussionRoot = dbContext.Messages
                .Include(x => x.Attachments)
                .Single(x => x.Id == postDto.DiscussionMessageId);
            Assert.Equal(channelPost.MediaAlbumId, discussionRoot.MediaAlbumId);
            Assert.Equal(
                new[] { "first.jpg", "second.jpg" },
                discussionRoot.Attachments.OrderBy(x => x.SortOrder).Select(x => x.OriginalFileName));
            Assert.All(discussionRoot.Attachments, attachment => Assert.True(File.Exists(attachment.StoragePath)));
        }
        finally
        {
            if (Directory.Exists(tempRoot))
            {
                Directory.Delete(tempRoot, recursive: true);
            }
        }
    }

    [Fact]
    public async Task UpdateMessage_ChannelPostWithLinkedDiscussion_UpdatesDiscussionRootText()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var group = CreateGroup(owner);
        channel.LinkedDiscussionChatId = group.Id;
        dbContext.AddRange(owner, channel, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var postResponse = await controller.SendMessage(
            channel.Id,
            new SendMessageRequest("Old text"),
            CancellationToken.None);
        var postDto = ControllerTestFixture.ValueOf(postResponse);

        var updateResponse = await controller.UpdateMessage(
            channel.Id,
            postDto.Id,
            new UpdateMessageRequest("New text"),
            CancellationToken.None);

        var updatedDto = ControllerTestFixture.ValueOf(updateResponse);
        var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId);
        Assert.Equal("New text", updatedDto.Text);
        Assert.Equal("New text", discussionRoot.Text);
        Assert.NotNull(discussionRoot.EditedAt);
    }

    [Fact]
    public async Task DeleteMessage_ChannelPostWithLinkedDiscussion_DeletesDiscussionRoot()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var group = CreateGroup(owner);
        channel.LinkedDiscussionChatId = group.Id;
        dbContext.AddRange(owner, channel, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var postResponse = await controller.SendMessage(
            channel.Id,
            new SendMessageRequest("Post to delete"),
            CancellationToken.None);
        var postDto = ControllerTestFixture.ValueOf(postResponse);

        var deleteResponse = await controller.DeleteMessage(channel.Id, postDto.Id, CancellationToken.None);

        var deletedDto = ControllerTestFixture.ValueOf(deleteResponse);
        var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId);
        Assert.NotNull(deletedDto.DeletedAt);
        Assert.NotNull(discussionRoot.DeletedAt);
        Assert.Equal(string.Empty, discussionRoot.Text);
    }

    [Fact]
    public async Task UpdateChatState_CanUpdateChannelProfile()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        dbContext.AddRange(owner, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(
                Title: "Daily News",
                Description: "Verified city updates",
                PublicUsername: "daily_news"),
            CancellationToken.None);

        var summary = ControllerTestFixture.ValueOf(response);
        var stored = dbContext.Chats.Single(x => x.Id == channel.Id);
        Assert.Equal("Daily News", summary.Title);
        Assert.Equal("Daily News", stored.Title);
        Assert.Equal("Verified city updates", stored.Description);
        Assert.Equal("daily_news", stored.PublicUsername);
        Assert.Contains(dbContext.ModerationLogEntries, x =>
            x.ChatId == channel.Id &&
            x.ActorUserId == owner.Id &&
            x.Action == "chat.profile.updated");
    }

    [Fact]
    public async Task UpdateChatState_CanClearChannelDescriptionAndPublicUsername()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        channel.Description = "Old description";
        channel.PublicUsername = "old_news";
        dbContext.AddRange(owner, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(ClearDescription: true, ClearPublicUsername: true),
            CancellationToken.None);

        ControllerTestFixture.ValueOf(response);
        var stored = dbContext.Chats.Single(x => x.Id == channel.Id);
        Assert.Null(stored.Description);
        Assert.Null(stored.PublicUsername);
    }

    [Fact]
    public async Task UpdateChatState_DeniesMemberProfileUpdate()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id);
        var response = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(Title: "Member title"),
            CancellationToken.None);

        var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
        Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
        Assert.Equal("Only admins can update chat profile.", forbidden.Value);
        Assert.Equal("News", dbContext.Chats.Single(x => x.Id == channel.Id).Title);
    }

    [Fact]
    public async Task UpdateChatState_RejectsTakenPublicUsername()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var otherOwner = CreateUser("other");
        var channel = CreateChannel(owner);
        var otherChannel = CreateChannel(otherOwner);
        otherChannel.PublicUsername = "taken_name";
        dbContext.AddRange(owner, otherOwner, channel, otherChannel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UpdateChatState(
            channel.Id,
            new ChatStateRequest(PublicUsername: "taken_name"),
            CancellationToken.None);

        var conflict = Assert.IsType<Microsoft.AspNetCore.Mvc.ConflictObjectResult>(response.Result);
        Assert.Equal("Public username is already taken.", conflict.Value);
        Assert.Equal("news", dbContext.Chats.Single(x => x.Id == channel.Id).PublicUsername);
    }

    [Fact]
    public async Task TransferOwnership_MovesOwnerRoleToChannelMember()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            IsOwner = false,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None);

        var details = ControllerTestFixture.ValueOf(response);
        Assert.Equal(member.Id, dbContext.Chats.Single(x => x.Id == channel.Id).CreatedById);
        var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id);
        var targetMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == member.Id);
        Assert.False(ownerMember.IsOwner);
        Assert.Equal(ChatMemberRole.Admin, ownerMember.Role);
        Assert.True(ownerMember.CanPostMessages);
        Assert.True(targetMember.IsOwner);
        Assert.Equal(ChatMemberRole.Owner, targetMember.Role);
        Assert.True(targetMember.CanInviteUsers);
        Assert.Single(dbContext.ChatMembers.Where(x => x.ChatId == channel.Id && (x.IsOwner || x.Role == ChatMemberRole.Owner)));
        var returnedTarget = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id);
        Assert.True(returnedTarget.IsOwner);
        Assert.Equal(ChatMemberRole.Owner, returnedTarget.Role);
        Assert.Contains(dbContext.ModerationLogEntries, x =>
            x.ChatId == channel.Id &&
            x.ActorUserId == owner.Id &&
            x.TargetId == member.Id &&
            x.Action == "member.owner.transferred");
    }

    [Fact]
    public async Task TransferOwnership_RejectsAdminActor()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var admin = CreateUser("admin");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = admin.Id,
            User = admin,
            IsOwner = false,
            Role = ChatMemberRole.Admin,
            CanPinMessages = true,
            CanDeleteMessages = true,
            CanInviteUsers = true,
            CanPostMessages = true
        });
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            IsOwner = false,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, admin, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id);
        var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None);

        var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
        Assert.Equal(Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden, forbidden.StatusCode);
        Assert.Equal("Only owner can transfer ownership.", forbidden.Value);
        var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id);
        Assert.True(ownerMember.IsOwner);
        Assert.Equal(ChatMemberRole.Owner, ownerMember.Role);
    }

    [Fact]
    public async Task TransferOwnership_RejectsBotTarget()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var bot = CreateUser("bot", isBot: true);
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = bot.Id,
            User = bot,
            IsOwner = false,
            Role = ChatMemberRole.Admin,
            CanPinMessages = true,
            CanDeleteMessages = true,
            CanInviteUsers = true,
            CanPostMessages = true
        });
        dbContext.AddRange(owner, bot, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.TransferOwnership(channel.Id, bot.Id, CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Bot cannot become chat owner.", badRequest.Value);
        var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id);
        var botMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == bot.Id);
        Assert.True(ownerMember.IsOwner);
        Assert.False(botMember.IsOwner);
        Assert.Equal(ChatMemberRole.Admin, botMember.Role);
    }

    [Fact]
    public async Task DeleteChat_BlocksOwnerLeavingChannelWithoutDeletingForEveryone()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            IsOwner = false,
            Role = ChatMemberRole.Member
        });
        dbContext.AddRange(owner, member, channel);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.DeleteChat(channel.Id, forEveryone: false, CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response);
        Assert.Equal("Owner must transfer ownership or delete the chat for everyone.", badRequest.Value);
        Assert.Equal(2, dbContext.ChatMembers.Count(x => x.ChatId == channel.Id));
        Assert.True(dbContext.Chats.Any(x => x.Id == channel.Id));
    }

    [Fact]
    public async Task UpdateMessage_AllowsChannelAdminWithPostPermissionToEditChannelPost()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var admin = CreateUser("admin");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = admin.Id,
            User = admin,
            Role = ChatMemberRole.Admin,
            CanPostMessages = true
        });
        var message = CreateTextMessage(channel, owner, "Original");
        dbContext.AddRange(owner, admin, channel, message);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id);
        var response = await controller.UpdateMessage(
            channel.Id,
            message.Id,
            new UpdateMessageRequest("Updated by admin"),
            CancellationToken.None);

        var updated = ControllerTestFixture.ValueOf(response);
        Assert.Equal("Updated by admin", updated.Text);
        Assert.NotNull(dbContext.Messages.Single(x => x.Id == message.Id).EditedAt);
    }

    [Fact]
    public async Task UpdateMessage_DeniesChannelMemberWithoutPostPermission()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var member = CreateUser("member");
        var channel = CreateChannel(owner);
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = member.Id,
            User = member,
            Role = ChatMemberRole.Member,
            CanPostMessages = false
        });
        var message = CreateTextMessage(channel, owner, "Original");
        dbContext.AddRange(owner, member, channel, message);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id);
        var response = await controller.UpdateMessage(
            channel.Id,
            message.Id,
            new UpdateMessageRequest("Updated by admin"),
            CancellationToken.None);

        var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
        Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
        Assert.Equal("You cannot edit this message.", forbidden.Value);
        Assert.Equal("Original", dbContext.Messages.Single(x => x.Id == message.Id).Text);
    }

    [Fact]
    public async Task DownloadAttachment_DoesNotServeDeletedMessageAttachment()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var message = CreateMessageWithAttachment(channel, owner, deletedAt: DateTimeOffset.UtcNow);
        dbContext.AddRange(owner, channel, message);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None);

        Assert.IsType<Microsoft.AspNetCore.Mvc.NotFoundResult>(response);
    }

    [Fact]
    public async Task UploadAttachmentAlbum_RejectsSingleFile()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var group = CreateGroup(owner);
        dbContext.AddRange(owner, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UploadAttachmentAlbum(
            group.Id,
            new UploadAttachmentAlbumRequest
            {
                Files = [CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")]
            },
            CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Albums must contain at least 2 files.", badRequest.Value);
    }

    [Fact]
    public async Task UploadAttachmentAlbum_RejectsDocumentFile()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var group = CreateGroup(owner);
        dbContext.AddRange(owner, group);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.UploadAttachmentAlbum(
            group.Id,
            new UploadAttachmentAlbumRequest
            {
                Files =
                [
                    CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg"),
                    CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf")
                ]
            },
            CancellationToken.None);

        var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
        Assert.Equal("Albums accept only image and video files.", badRequest.Value);
    }

    [Fact]
    public async Task DownloadAttachment_DoesNotServeExpiredMessageAttachment()
    {
        await using var database = new SqliteTestDatabase();
        await using var dbContext = await database.CreateContextAsync();
        var owner = CreateUser("owner");
        var channel = CreateChannel(owner);
        var message = CreateMessageWithAttachment(channel, owner, expiresAt: DateTimeOffset.UtcNow.AddSeconds(-1));
        dbContext.AddRange(owner, channel, message);
        await dbContext.SaveChangesAsync();

        var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id);
        var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None);

        Assert.IsType<Microsoft.AspNetCore.Mvc.NotFoundResult>(response);
    }

    private static User CreateUser(string username, bool isBot = false) =>
        new()
        {
            Id = Guid.NewGuid(),
            Username = username,
            NormalizedUsername = username.ToUpperInvariant(),
            DisplayName = username,
            PasswordHash = "test",
            IsBot = isBot
        };

    private static Chat CreateChannel(User owner)
    {
        var channel = new Chat
        {
            Id = Guid.NewGuid(),
            Type = ChatType.Channel,
            Title = "News",
            Description = "Public news channel",
            PublicUsername = "news",
            CreatedById = owner.Id,
            CreatedBy = owner,
            CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero),
            LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero)
        };
        channel.Members.Add(new ChatMember
        {
            ChatId = channel.Id,
            Chat = channel,
            UserId = owner.Id,
            User = owner,
            IsOwner = true,
            Role = ChatMemberRole.Owner,
            CanPinMessages = true,
            CanDeleteMessages = true,
            CanInviteUsers = true,
            CanPostMessages = true
        });

        return channel;
    }

    private static Chat CreateGroup(User owner)
    {
        var group = new Chat
        {
            Id = Guid.NewGuid(),
            Type = ChatType.Group,
            Title = "Group",
            CreatedById = owner.Id,
            CreatedBy = owner,
            CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero),
            LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero)
        };
        group.Members.Add(new ChatMember
        {
            ChatId = group.Id,
            Chat = group,
            UserId = owner.Id,
            User = owner,
            IsOwner = true,
            Role = ChatMemberRole.Owner,
            CanPinMessages = true,
            CanDeleteMessages = true,
            CanInviteUsers = true,
            CanPostMessages = true
        });

        return group;
    }

    private static FormFile CreateFormFile(byte[] content, string fileName, string contentType)
    {
        var stream = new MemoryStream(content);
        return new FormFile(stream, 0, stream.Length, "files", fileName)
        {
            Headers = new HeaderDictionary(),
            ContentType = contentType
        };
    }

    private static Message CreateTextMessage(Chat chat, User sender, string text)
    {
        var message = new Message
        {
            Id = Guid.NewGuid(),
            ChatId = chat.Id,
            Chat = chat,
            SenderId = sender.Id,
            Sender = sender,
            Text = text,
            SentAt = DateTimeOffset.UtcNow
        };
        chat.Messages.Add(message);
        return message;
    }

    private static Message CreateMessageWithAttachment(
        Chat chat,
        User sender,
        DateTimeOffset? deletedAt = null,
        DateTimeOffset? expiresAt = null)
    {
        var message = new Message
        {
            Id = Guid.NewGuid(),
            ChatId = chat.Id,
            Chat = chat,
            SenderId = sender.Id,
            Sender = sender,
            Text = string.Empty,
            SentAt = DateTimeOffset.UtcNow,
            DeletedAt = deletedAt,
            ExpiresAt = expiresAt
        };
        message.Attachments.Add(new MessageAttachment
        {
            Id = Guid.NewGuid(),
            MessageId = message.Id,
            Message = message,
            OriginalFileName = "document.pdf",
            ContentType = "application/pdf",
            StoragePath = "missing.pdf"
        });

        return message;
    }
}
