using System.Text.RegularExpressions;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Channels;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;

namespace Ikar.Server.Controllers;

[ApiController]
[AllowAnonymous]
[Route("api/bot/{token}")]
public sealed class BotApiController(
    IkarDbContext dbContext,
    PresenceTracker presenceTracker,
    BotTokenService botTokenService,
    BotUpdateService botUpdateService,
    IHubContext<MessengerHub> hubContext,
    PushDispatchQueue pushDispatchQueue,
    AttachmentStorageService attachmentStorageService) : ControllerBase
{
    private const long MaxBotAttachmentSizeBytes = 25 * 1024 * 1024;
    private const long MaxBotMediaGroupSizeBytes = 100 * 1024 * 1024;
    private const int MaxBotMediaGroupAttachments = 10;

    [HttpGet("getMe")]
    public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        return Ok(bot.User.ToDto(presenceTracker));
    }

    [HttpGet("getUpdates")]
    public async Task<ActionResult<BotUpdatesResponseDto>> GetUpdates(
        string token,
        [FromQuery] long offset = 0,
        [FromQuery] int limit = 100,
        [FromQuery] int timeoutSeconds = 20,
        CancellationToken cancellationToken = default)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        limit = Math.Clamp(limit, 1, 100);
        timeoutSeconds = Math.Clamp(timeoutSeconds, 0, 50);

        var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds);
        while (true)
        {
            var updates = await LoadUpdatesAsync(bot, offset, limit, cancellationToken);
            if (updates.Count > 0 || timeoutSeconds == 0 || DateTimeOffset.UtcNow >= timeoutAt)
            {
                var messages = updates
                    .Select(x => x.Message)
                    .Where(x => x is not null)
                    .Cast<Message>()
                    .ToList();
                if (messages.Count > 0)
                {
                    await botUpdateService.MarkMessagesReadByBotAsync(bot.UserId, messages, cancellationToken);
                }

                var payload = updates
                    .Select(update => new BotUpdateDto(
                        update.Id,
                        BotUpdateKind.Message,
                        update.Message?.ToDto(presenceTracker, bot.UserId)))
                    .ToList();

                var nextOffset = payload.Count == 0 ? offset : payload[^1].Id + 1;
                return Ok(new BotUpdatesResponseDto(nextOffset, payload));
            }

            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        }
    }

    [HttpGet("getMyCommands")]
    public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> GetMyCommands(string token, CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        return Ok(bot.Commands
            .OrderBy(x => x.SortOrder)
            .ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase)
            .Select(x => new BotCommandDto(x.Command, x.Description))
            .ToList());
    }

    [HttpPost("setMyCommands")]
    public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> SetMyCommands(
        string token,
        SetBotCommandsRequest request,
        CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        var commands = NormalizeCommands(request.Commands);
        var existingCommands = await dbContext.BotCommands
            .Where(x => x.BotId == bot.Id)
            .ToListAsync(cancellationToken);
        dbContext.BotCommands.RemoveRange(existingCommands);
        await dbContext.SaveChangesAsync(cancellationToken);

        for (var index = 0; index < commands.Count; index++)
        {
            dbContext.BotCommands.Add(new BotCommand
            {
                BotId = bot.Id,
                Command = commands[index].Command,
                Description = commands[index].Description,
                SortOrder = index
            });
        }

        bot.UpdatedAt = DateTimeOffset.UtcNow;
        await dbContext.SaveChangesAsync(cancellationToken);
        return Ok(commands);
    }

    [HttpPost("sendMessage")]
    public async Task<ActionResult<MessageDto>> SendMessage(
        string token,
        BotSendMessageRequest request,
        CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        var text = request.Text?.Trim() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(text))
        {
            return ValidationProblem("Message text is required.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(
                x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
                cancellationToken);
        if (chat is null)
        {
            return NotFound("Chat not found.");
        }

        if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
        }

        var message = new Message
        {
            ChatId = chat.Id,
            Chat = chat,
            SenderId = bot.UserId,
            Sender = bot.User,
            PostedAsChannel = chat.Type == ChatType.Channel,
            AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
                ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
                : null,
            Text = text,
            SentAt = DateTimeOffset.UtcNow
        };

        chat.LastActivityAt = message.SentAt;
        dbContext.Messages.Add(message);
        var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
            dbContext,
            attachmentStorageService,
            chat,
            message,
            bot.User,
            message.SentAt,
            cancellationToken);
        if (discussionRootMessage is not null)
        {
            message.DiscussionMessageId = discussionRootMessage.Id;
        }

        pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
        await dbContext.SaveChangesAsync(cancellationToken);

        await NotifyMessageCreatedAsync(chat, message, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
        if (discussionRootMessage is not null)
        {
            await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
            await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
        }

        return Ok(message.ToDto(presenceTracker, bot.UserId));
    }

    [HttpPost("sendDocument")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
    [RequestSizeLimit(MaxBotAttachmentSizeBytes)]
    public Task<ActionResult<MessageDto>> SendDocument(
        string token,
        [FromForm] BotSendAttachmentRequest request,
        CancellationToken cancellationToken) =>
        SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: null, cancellationToken: cancellationToken);

    [HttpPost("sendPhoto")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
    [RequestSizeLimit(MaxBotAttachmentSizeBytes)]
    public Task<ActionResult<MessageDto>> SendPhoto(
        string token,
        [FromForm] BotSendAttachmentRequest request,
        CancellationToken cancellationToken) =>
        SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Photo, cancellationToken: cancellationToken);

    [HttpPost("sendVideo")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
    [RequestSizeLimit(MaxBotAttachmentSizeBytes)]
    public Task<ActionResult<MessageDto>> SendVideo(
        string token,
        [FromForm] BotSendAttachmentRequest request,
        CancellationToken cancellationToken) =>
        SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Video, cancellationToken: cancellationToken);

    [HttpPost("sendMediaGroup")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxBotMediaGroupSizeBytes)]
    [RequestSizeLimit(MaxBotMediaGroupSizeBytes)]
    public async Task<ActionResult<MessageDto>> SendMediaGroup(
        string token,
        [FromForm] BotSendMediaGroupRequest request,
        CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        if (request.ChatId == Guid.Empty)
        {
            return ValidationProblem("ChatId is required.");
        }

        var files = request.Files
            .Where(file => file is not null)
            .ToList();

        if (files.Count < 2)
        {
            return BadRequest("Media group must contain at least 2 files.");
        }

        if (files.Count > MaxBotMediaGroupAttachments)
        {
            return BadRequest($"Media group can contain up to {MaxBotMediaGroupAttachments} files.");
        }

        if (files.Any(file => file.Length <= 0))
        {
            return BadRequest("Media group contains an empty file.");
        }

        if (files.Any(file => file.Length > MaxBotAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxBotMediaGroupSizeBytes)
        {
            return BadRequest($"Media group exceeds {MaxBotMediaGroupSizeBytes} bytes or contains a file over {MaxBotAttachmentSizeBytes} bytes.");
        }

        if (files.Any(file =>
            !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Photo) &&
            !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Video)))
        {
            return BadRequest("sendMediaGroup accepts only image and video files.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(
                x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
                cancellationToken);
        if (chat is null)
        {
            return NotFound("Chat not found.");
        }

        if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
        }

        var sentAt = DateTimeOffset.UtcNow;
        var message = new Message
        {
            ChatId = chat.Id,
            Chat = chat,
            SenderId = bot.UserId,
            Sender = bot.User,
            PostedAsChannel = chat.Type == ChatType.Channel,
            AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
                ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
                : null,
            Text = request.Text?.Trim() ?? string.Empty,
            MediaAlbumId = Guid.NewGuid(),
            SentAt = sentAt
        };

        var savedAttachments = new List<MessageAttachment>();
        Message? discussionRootMessage = null;
        try
        {
            for (var index = 0; index < files.Count; index++)
            {
                var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index);
                savedAttachments.Add(attachment);
                message.Attachments.Add(attachment);
            }

            chat.LastActivityAt = message.SentAt;
            dbContext.Messages.Add(message);
            discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
                dbContext,
                attachmentStorageService,
                chat,
                message,
                bot.User,
                sentAt,
                cancellationToken);
            if (discussionRootMessage is not null)
            {
                message.DiscussionMessageId = discussionRootMessage.Id;
            }

            pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch
        {
            foreach (var attachment in savedAttachments)
            {
                attachmentStorageService.DeleteIfExists(attachment.StoragePath);
            }

            if (discussionRootMessage is not null)
            {
                DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
            }

            throw;
        }

        await NotifyMessageCreatedAsync(chat, message, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
        if (discussionRootMessage is not null)
        {
            await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
            await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
        }

        return Ok(message.ToDto(presenceTracker, bot.UserId));
    }

    [HttpPost("sendVoice")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
    [RequestSizeLimit(MaxBotAttachmentSizeBytes)]
    public Task<ActionResult<MessageDto>> SendVoice(
        string token,
        [FromForm] BotSendAttachmentRequest request,
        CancellationToken cancellationToken) =>
        SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken);

    private async Task<ActionResult<MessageDto>> SendAttachmentAsync(
        string token,
        BotSendAttachmentRequest request,
        bool forceVoiceNote,
        BotAttachmentMediaKind? requiredMediaKind,
        CancellationToken cancellationToken)
    {
        var bot = await LoadBotByTokenAsync(token, cancellationToken);
        if (bot is null)
        {
            return Unauthorized();
        }

        if (!bot.IsEnabled)
        {
            return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
        }

        if (request.ChatId == Guid.Empty)
        {
            return ValidationProblem("ChatId is required.");
        }

        if (request.File is null)
        {
            return BadRequest("File is required.");
        }

        if (request.File.Length <= 0)
        {
            return BadRequest("File is empty.");
        }

        if (request.File.Length > MaxBotAttachmentSizeBytes)
        {
            return BadRequest($"File exceeds {MaxBotAttachmentSizeBytes} bytes.");
        }

        if (requiredMediaKind is not null && !MatchesRequiredMediaKind(request.File, requiredMediaKind.Value))
        {
            return BadRequest(requiredMediaKind == BotAttachmentMediaKind.Photo
                ? "sendPhoto accepts only image files."
                : "sendVideo accepts only video files.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(
                x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
                cancellationToken);
        if (chat is null)
        {
            return NotFound("Chat not found.");
        }

        if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
        }

        var sentAt = DateTimeOffset.UtcNow;
        var message = new Message
        {
            ChatId = chat.Id,
            Chat = chat,
            SenderId = bot.UserId,
            Sender = bot.User,
            PostedAsChannel = chat.Type == ChatType.Channel,
            AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
                ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
                : null,
            Text = request.Text?.Trim() ?? string.Empty,
            SentAt = sentAt
        };

        MessageAttachment? attachment = null;
        Message? discussionRootMessage = null;
        try
        {
            attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
            if (forceVoiceNote || IsVoiceLikeAttachment(attachment))
            {
                attachment.ContentType = ResolveVoiceContentType(attachment);
                attachment.Kind = AttachmentKind.VoiceNote;
            }

            message.Attachments.Add(attachment);
            chat.LastActivityAt = message.SentAt;
            dbContext.Messages.Add(message);
            discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
                dbContext,
                attachmentStorageService,
                chat,
                message,
                bot.User,
                sentAt,
                cancellationToken);
            if (discussionRootMessage is not null)
            {
                message.DiscussionMessageId = discussionRootMessage.Id;
            }

            pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch
        {
            if (attachment is not null)
            {
                attachmentStorageService.DeleteIfExists(attachment.StoragePath);
            }

            if (discussionRootMessage is not null)
            {
                DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
            }

            throw;
        }

        await NotifyMessageCreatedAsync(chat, message, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
        if (discussionRootMessage is not null)
        {
            await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
            await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
        }

        return Ok(message.ToDto(presenceTracker, bot.UserId));
    }

    private async Task<Bot?> LoadBotByTokenAsync(string token, CancellationToken cancellationToken)
    {
        var tokenHash = botTokenService.HashToken(token);
        return await dbContext.Bots
            .Include(x => x.User)
                .ThenInclude(x => x.Sessions)
            .Include(x => x.Commands)
            .SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
    }

    private async Task<List<BotUpdate>> LoadUpdatesAsync(Bot bot, long offset, int limit, CancellationToken cancellationToken)
    {
        return await dbContext.BotUpdates
            .AsNoTracking()
            .Where(x => x.BotId == bot.Id && x.Id >= offset)
            .OrderBy(x => x.Id)
            .Take(limit)
            .Include(x => x.Message)
                .ThenInclude(x => x.Sender)
                    .ThenInclude(x => x.Sessions)
            .Include(x => x.Message)
                .ThenInclude(x => x.Attachments)
            .Include(x => x.Message)
                .ThenInclude(x => x.Reactions)
            .Include(x => x.Message)
                .ThenInclude(x => x.Views)
            .Include(x => x.Message)
                .ThenInclude(x => x.Chat)
                    .ThenInclude(x => x.Members)
            .ToListAsync(cancellationToken);
    }

    private Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken)
    {
        var tasks = chat.Members.Select(async member =>
        {
            var dto = message.ToDto(presenceTracker, member.UserId);
            await hubContext.Clients.User(member.UserId.ToString())
                .SendAsync("MessageCreated", dto, cancellationToken);
        });

        return Task.WhenAll(tasks);
    }

    private void DeleteFiles(IEnumerable<string> attachmentPaths)
    {
        foreach (var path in attachmentPaths)
        {
            attachmentStorageService.DeleteIfExists(path);
        }
    }

    private static string ResolveVoiceContentType(MessageAttachment attachment)
    {
        var normalized = attachment.ContentType
            .Split(';', 2, StringSplitOptions.TrimEntries)
            .FirstOrDefault();
        if (!string.IsNullOrWhiteSpace(normalized) &&
            normalized.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
        {
            return attachment.ContentType;
        }

        var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(extension))
        {
            var lastToken = attachment.OriginalFileName
                .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                .LastOrDefault()
                ?.ToLowerInvariant();
            extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
        }

        return extension switch
        {
            ".aac" => "audio/aac",
            ".flac" => "audio/flac",
            ".m4a" => "audio/mp4",
            ".mp3" => "audio/mpeg",
            ".oga" or ".ogg" => "audio/ogg",
            ".opus" => "audio/opus",
            ".wav" => "audio/wav",
            ".webm" => "audio/webm",
            _ => "audio/ogg"
        };
    }

    private static bool IsVoiceLikeAttachment(MessageAttachment attachment)
    {
        var normalized = attachment.ContentType
            .Split(';', 2, StringSplitOptions.TrimEntries)
            .FirstOrDefault()
            ?.Trim()
            .ToLowerInvariant();
        if (normalized is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg")
        {
            return true;
        }

        var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(extension))
        {
            var lastToken = attachment.OriginalFileName
                .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                .LastOrDefault()
                ?.ToLowerInvariant();
            extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
        }

        return extension is ".ogg" or ".oga" or ".opus";
    }

    private static bool MatchesRequiredMediaKind(IFormFile file, BotAttachmentMediaKind requiredMediaKind)
    {
        var normalizedContentType = file.ContentType
            ?.Split(';', 2, StringSplitOptions.TrimEntries)
            .FirstOrDefault()
            ?.Trim()
            .ToLowerInvariant();

        if (!string.IsNullOrWhiteSpace(normalizedContentType))
        {
            if (requiredMediaKind == BotAttachmentMediaKind.Photo &&
                normalizedContentType.StartsWith("image/", StringComparison.Ordinal))
            {
                return true;
            }

            if (requiredMediaKind == BotAttachmentMediaKind.Video &&
                normalizedContentType.StartsWith("video/", StringComparison.Ordinal))
            {
                return true;
            }
        }

        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        return requiredMediaKind switch
        {
            BotAttachmentMediaKind.Photo => extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp",
            BotAttachmentMediaKind.Video => extension is ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv",
            _ => false
        };
    }

    private enum BotAttachmentMediaKind
    {
        Photo,
        Video
    }

    private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
    {
        var normalized = new List<BotCommandDto>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var command in commands ?? Array.Empty<BotCommandDto>())
        {
            var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/');
            var normalizedDescription = (command.Description ?? string.Empty).Trim();
            if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription))
            {
                continue;
            }

            if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase))
            {
                continue;
            }

            if (!seen.Add(normalizedCommand))
            {
                continue;
            }

            normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription));
        }

        return normalized;
    }
}
