using System.Globalization;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;

namespace ClawdDotNet.Tools.RocketChat;

/// <summary>
/// Anbindung an eine selbstgehostete Rocket.Chat-Instanz. Jeder Agent tritt mit einem
/// eigenen Benutzer auf — dadurch funktionieren Direktnachrichten und Erwähnungen echt
/// und nicht als Alias eines Sammelkontos.
///
/// Der Rückkanal läuft über <see cref="IToolJobProvider"/>: Ein Takt prüft ohne Modell
/// (und damit kostenlos), ob etwas anliegt, und weckt den Agenten nur dann.
/// </summary>
public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
    public string Name => "RocketChat";

    public string Description => """
        Kommunikation über Rocket.Chat: Nachrichten in Kanälen, privaten Gruppen und
        Direktnachrichten lesen und schreiben.

        Räume werden per Id ("GENERAL") oder Name ("#team") angegeben; list_rooms zeigt,
        wo dieser Agent Mitglied ist. Mit "@benutzername" wird eine Direktnachricht
        geschrieben — auch an jemanden, mit dem noch kein Gespräch besteht.

        Aktionen: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
        """;

    public JsonElement InputSchema { get; } = JsonDocument.Parse("""
        {
            "type": "object",
            "required": ["action"],
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["send_message", "reply", "send_file", "download_file", "list_rooms", "read_room", "mark_read"],
                    "description": "send_message: Nachricht in einen Raum senden. reply: Antwort im Thread einer Nachricht. send_file: eine Datei aus dem Workspace in einen Raum schicken. download_file: eine empfangene Datei in den Workspace holen. list_rooms: eigene Räume mit Ungelesen-Zählern auflisten. read_room: letzte Nachrichten eines Raums lesen. mark_read: Raum als gelesen markieren."
                },
                "room": {
                    "type": "string",
                    "description": "Raum-Id oder -Name (z. B. \"GENERAL\", \"#team\", \"@person\"). Entfällt bei list_rooms; ohne Angabe gilt der konfigurierte Standardraum."
                },
                "text": {
                    "type": "string",
                    "description": "Nachrichtentext (für send_message und reply)."
                },
                "threadId": {
                    "type": "string",
                    "description": "Id der Nachricht, auf die geantwortet wird (nur für reply)."
                },
                "localPath": {
                    "type": "string",
                    "description": "Pfad der zu sendenden Datei im Workspace (nur für send_file): \"bericht.pdf\" oder \"personal:bericht.pdf\" im eigenen Workspace, \"shared:auswertung.csv\" im geteilten."
                },
                "filename": {
                    "type": "string",
                    "description": "Abweichender Dateiname für den Empfänger (nur für send_file). Ohne Angabe gilt der Name im Workspace."
                },
                "fileId": {
                    "type": "string",
                    "description": "Id einer empfangenen Datei (nur für download_file). Steht in der Nachricht als \"fileId: …\"."
                },
                "limit": {
                    "type": "integer",
                    "description": "Anzahl der zu lesenden Nachrichten bei read_room (Standard 20, höchstens 100)."
                }
            }
        }
        """).RootElement.Clone();

    // ─── IAgentTool ───

    public async Task<ToolResult> ExecuteAsync(
        JsonElement input, AgentToolContext context, CancellationToken ct)
    {
        if (!input.TryGetProperty("action", out var actionElement)
            || actionElement.GetString() is not { } action)
            return ToolResult.Fail(
                "'action' ist erforderlich. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.");

        var (config, configError) = RocketChatConfig.Read(context.ToolConfig);
        if (config is null)
            return ToolResult.Fail(configError!);

        var client = new RocketChatClient(config);
        context.Logger.LogInformation("RocketChat: Aktion {Action}", action);

        try
        {
            return action switch
            {
                "send_message" => await SendMessageAsync(client, config, input, threadAllowed: false, ct),
                "reply" => await SendMessageAsync(client, config, input, threadAllowed: true, ct),
                "send_file" => await SendFileAsync(client, config, input, context, ct),
                "download_file" => await DownloadFileAsync(client, config, input, context, ct),
                "list_rooms" => await ListRoomsAsync(client, ct),
                "read_room" => await ReadRoomAsync(client, config, input, ct),
                "mark_read" => await MarkReadAsync(client, config, input, ct),
                _ => ToolResult.Fail(
                    $"Unbekannte Aktion '{action}'. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.")
            };
        }
        catch (RocketChatException ex)
        {
            context.Logger.LogWarning("RocketChat meldet einen Fehler bei {Action}: {Message}", action, ex.Message);
            return ToolResult.Fail($"Rocket.Chat: {ex.Message}");
        }
        catch (HttpRequestException ex)
        {
            context.Logger.LogWarning(ex, "Rocket.Chat ist nicht erreichbar");
            return ToolResult.Fail($"Rocket.Chat ist nicht erreichbar: {ex.Message}");
        }
        catch (TaskCanceledException) when (!ct.IsCancellationRequested)
        {
            return ToolResult.Fail("Rocket.Chat hat nicht rechtzeitig geantwortet (Zeitüberschreitung).");
        }
    }

    private static async Task<ToolResult> SendMessageAsync(
        RocketChatClient client, RocketChatConfig config, JsonElement input,
        bool threadAllowed, CancellationToken ct)
    {
        var text = GetString(input, "text");
        if (string.IsNullOrWhiteSpace(text))
            return ToolResult.Fail("'text' ist erforderlich.");

        var threadId = threadAllowed ? GetString(input, "threadId") : null;
        if (threadAllowed && string.IsNullOrWhiteSpace(threadId))
            return ToolResult.Fail("'threadId' ist für reply erforderlich (Id der Nachricht, auf die geantwortet wird).");

        var rooms = await client.GetRoomsAsync(ct);
        var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
        if (room is null)
            return ToolResult.Fail(error!);

        var messageId = await client.PostMessageAsync(room.RoomId, text, threadId, ct);

        return ToolResult.Ok(JsonSerializer.Serialize(new
        {
            sent = true,
            room = room.Label,
            roomId = room.RoomId,
            messageId,
            inThread = threadId is not null
        }));
    }

    /// <summary>
    /// Schickt eine Datei aus dem Workspace des Agenten in einen Raum — der kurze Weg für
    /// „stell mir das zusammen und schick es rüber", ohne Umweg über die Cloud.
    /// </summary>
    private static async Task<ToolResult> SendFileAsync(
        RocketChatClient client, RocketChatConfig config, JsonElement input,
        AgentToolContext context, CancellationToken ct)
    {
        var localPath = GetString(input, "localPath");
        if (string.IsNullOrWhiteSpace(localPath))
            return ToolResult.Fail(
                "'localPath' ist erforderlich — z. B. \"bericht.pdf\" (persönlicher Workspace) "
                + "oder \"shared:auswertung.csv\" (geteilter Workspace).");

        var (file, pathError) = WorkspaceFile.Resolve(
            localPath, context.WorkspacePath, context.SharedWorkspacePath);
        if (file is null)
            return ToolResult.Fail(pathError!);

        if (!File.Exists(file.FullPath))
            return ToolResult.Fail($"Datei nicht gefunden: {localPath}");

        var info = new FileInfo(file.FullPath);
        if (info.Length == 0)
            return ToolResult.Fail($"Die Datei {localPath} ist leer.");

        if (info.Length > config.MaxUploadBytes)
            return ToolResult.Fail(
                $"Die Datei ist {info.Length / 1024 / 1024} MB groß und überschreitet die Grenze von "
                + $"{config.MaxUploadBytes / 1024 / 1024} MB. Für Größeres eignet sich die Cloud-Ablage.");

        var rooms = await client.GetRoomsAsync(ct);
        var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
        if (room is null)
            return ToolResult.Fail(error!);

        var fileName = GetString(input, "filename") ?? Path.GetFileName(file.FullPath);

        await using var stream = File.OpenRead(file.FullPath);
        var messageId = await client.UploadFileAsync(
            room.RoomId, stream, fileName,
            WorkspaceFile.GuessContentType(fileName),
            GetString(input, "text"),
            GetString(input, "threadId"),
            ct);

        return ToolResult.Ok(JsonSerializer.Serialize(new
        {
            sent = true,
            room = room.Label,
            roomId = room.RoomId,
            fileName,
            bytes = info.Length,
            workspace = file.Label,
            messageId
        }));
    }

    /// <summary>
    /// Holt eine angehängte Datei in den Workspace des Agenten — das Gegenstück zu
    /// <c>send_file</c>. Die <c>fileId</c> steht in der Weckmeldung bzw. in read_room.
    /// </summary>
    private static async Task<ToolResult> DownloadFileAsync(
        RocketChatClient client, RocketChatConfig config, JsonElement input,
        AgentToolContext context, CancellationToken ct)
    {
        var fileId = GetString(input, "fileId");
        if (string.IsNullOrWhiteSpace(fileId))
            return ToolResult.Fail(
                "'fileId' ist erforderlich — sie steht bei jeder Datei in der Nachricht "
                + "(\"Datei: … — fileId: …\").");

        // Zielname: Angabe des Modells, sonst der Name aus der Nachricht. Beides ist
        // fremdbestimmt, deshalb wird der reine Dateiname genommen und jede Pfadangabe
        // darin verworfen, bevor überhaupt aufgelöst wird.
        var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId;

        // Den Workspace-Präfix zuerst abtrennen: Auf Windows ist ':' kein Pfadtrenner,
        // sondern der Marker für NTFS-Alternativdatenströme. Bliebe "shared:" im Namen,
        // schriebe Path.GetFileName ihn unverändert durch — und die Datei landete als
        // Datenstrom am Verzeichnis statt als eigene Datei.
        var prefix = "";
        var rest = wanted;
        if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
        {
            prefix = "shared:";
            rest = rest[7..];
        }
        else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
        {
            rest = rest[9..];
        }

        var targetName = Path.GetFileName(rest.Replace('\\', '/'));
        if (string.IsNullOrWhiteSpace(targetName))
            return ToolResult.Fail($"'{wanted}' ergibt keinen gültigen Dateinamen.");

        var extension = Path.GetExtension(targetName);
        if (!config.AllowDangerousDownloads && DangerousExtensions.Contains(extension))
            return ToolResult.Fail(
                $"Dateien vom Typ '{extension}' werden nicht heruntergeladen. "
                + "Bei Bedarf 'allowDangerousDownloads' in der Tool-Konfiguration setzen.");

        // Das Verzeichnis darf das Modell wählen (personal:/shared:), den Dateinamen nicht
        // frei zusammensetzen — deshalb Präfix und bereinigter Name getrennt.
        var (file, pathError) = WorkspaceFile.Resolve(
            prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
        if (file is null)
            return ToolResult.Fail(pathError!);

        Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);

        var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);

        return ToolResult.Ok(JsonSerializer.Serialize(new
        {
            downloaded = true,
            fileId,
            savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}",
            workspace = file.Label,
            bytes
        }));
    }

    /// <summary>
    /// Endungen, die nicht ungefragt im Workspace landen sollen. Die Datei kommt von
    /// außen — der Dateiname ist eine Behauptung des Absenders, kein Befund.
    /// </summary>
    private static readonly HashSet<string> DangerousExtensions =
        new(StringComparer.OrdinalIgnoreCase)
        {
            ".exe", ".dll", ".bat", ".cmd", ".com", ".scr", ".msi", ".ps1",
            ".vbs", ".jar", ".lnk", ".reg", ".sh"
        };

    private static async Task<ToolResult> ListRoomsAsync(RocketChatClient client, CancellationToken ct)
    {
        var rooms = await client.GetRoomsAsync(ct);

        return ToolResult.Ok(JsonSerializer.Serialize(new
        {
            fetchedAt = DateTime.UtcNow,
            source = "rocketchat",
            data = new
            {
                count = rooms.Count,
                rooms = rooms.Select(r => new
                {
                    roomId = r.RoomId,
                    name = r.Name,
                    type = r.Type switch { "c" => "channel", "p" => "private", "d" => "direct", _ => r.Type },
                    unread = r.Unread,
                    mentions = r.UserMentions
                })
            }
        }));
    }

    private static async Task<ToolResult> ReadRoomAsync(
        RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
    {
        var rooms = await client.GetRoomsAsync(ct);
        var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
        if (room is null)
            return ToolResult.Fail(error!);

        var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 100);
        var messages = await client.GetHistoryAsync(room, since: null, count: limit, ct);

        var payload = JsonSerializer.Serialize(new
        {
            fetchedAt = DateTime.UtcNow,
            source = $"rocketchat_{room.RoomId}",
            data = new
            {
                room = room.Label,
                roomId = room.RoomId,
                count = messages.Count(m => !m.IsSystem),
                messages = messages.Where(m => !m.IsSystem).Select(m => new
                {
                    messageId = m.Id,
                    at = m.Timestamp,
                    from = m.Username,
                    text = m.Text,
                    threadId = m.ThreadId,
                    files = m.Files.Count == 0 ? null : m.Files.Select(f => new
                    {
                        fileId = f.FileId,
                        name = f.Name,
                        contentType = f.ContentType,
                        size = f.Size
                    }),
                    links = m.Links.Count == 0 ? null : m.Links.Select(l => new
                    {
                        url = l.Url,
                        title = l.Title,
                        description = l.Description is null ? null : Shorten(l.Description, 300)
                    })
                })
            }
        });

        // Fremder Text — als Daten gerahmt, nicht als Anweisung (K2).
        return ToolResult.Ok(Untrusted(payload));
    }

    private static async Task<ToolResult> MarkReadAsync(
        RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
    {
        var rooms = await client.GetRoomsAsync(ct);
        var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
        if (room is null)
            return ToolResult.Fail(error!);

        await client.MarkReadAsync(room.RoomId, ct);
        return ToolResult.Ok($"Raum {room.Label} als gelesen markiert.");
    }

    /// <summary>
    /// Bestimmt den Zielraum aus Eingabe oder Standardwert und prüft die Allowlist.
    /// Schlägt die Auflösung fehl, nennt die Meldung die tatsächlich verfügbaren Räume —
    /// das Modell soll sich korrigieren können, statt zu raten.
    /// </summary>
    private static async Task<(RoomSubscription? Room, string? Error)> ResolveRoomAsync(
        RocketChatClient client, IReadOnlyList<RoomSubscription> rooms,
        RocketChatConfig config, JsonElement input, CancellationToken ct)
    {
        var wanted = GetString(input, "room") ?? config.DefaultRoom;
        if (string.IsNullOrWhiteSpace(wanted))
            return (null, "'room' ist erforderlich (kein Standardraum konfiguriert). "
                          + Available(rooms));

        var room = RocketChatClient.Resolve(rooms, wanted);

        // Noch kein Gespräch mit dieser Person: Ein DM-Raum entsteht erst, wenn ihn jemand
        // eröffnet. Nur bei ausdrücklicher @-Schreibweise — sonst würde ein vertippter
        // Kanalname stillschweigend zu einer Direktnachricht.
        if (room is null && wanted.StartsWith('@'))
        {
            var username = wanted[1..];
            if (!config.IsRoomAllowed(username))
                return (null, $"Direktnachrichten an '{username}' sind für diesen Agenten nicht "
                              + "freigegeben (siehe 'allowedRooms' in der Tool-Konfiguration).");

            try
            {
                room = await client.OpenDirectMessageAsync(username, ct);
            }
            catch (RocketChatException ex)
            {
                return (null, $"Direktnachricht an '{username}' nicht möglich: {ex.Message}");
            }

            if (room is null)
                return (null, $"Direktnachricht an '{username}' konnte nicht geöffnet werden.");

            return (room, null);
        }

        if (room is null)
            return (null, $"Kein Raum '{wanted}' — dieser Agent ist dort kein Mitglied. " + Available(rooms)
                          + " Für eine neue Direktnachricht '@benutzername' angeben.");

        // Gegen beide Schreibweisen prüfen: Die Allowlist darf Ids oder Namen enthalten.
        if (!config.IsRoomAllowed(room.RoomId) && !config.IsRoomAllowed(room.Name))
            return (null, $"Zugriff auf Raum '{wanted}' ist für diesen Agenten nicht freigegeben "
                          + "(siehe 'allowedRooms' in der Tool-Konfiguration).");

        return (room, null);
    }

    private static string Available(IReadOnlyList<RoomSubscription> rooms)
        => rooms.Count == 0
            ? "Der Agent ist in keinem Raum Mitglied."
            : "Verfügbar: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})"));

    // ─── IToolJobProvider ───

    public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
    [
        new("rocketchat_poll", "Rocket.Chat Abruf",
            "Prüft auf neue Erwähnungen und Direktnachrichten und weckt den Agenten nur, wenn etwas anliegt.")
    ];

    public async Task<ToolJobResult> ExecuteJobAsync(
        string jobTypeId,
        IReadOnlyDictionary<string, object?> toolConfig,
        IStateStore stateStore,
        ILogger logger,
        CancellationToken ct,
        string? agentId = null,
        string? workspacePath = null)
    {
        if (jobTypeId != "rocketchat_poll")
            return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");

        var (config, configError) = RocketChatConfig.Read(toolConfig);
        if (config is null)
            return ToolJobResult.NoAction(configError);

        var client = new RocketChatClient(config);
        var agent = agentId ?? "default";

        try
        {
            var rooms = await client.GetRoomsAsync(ct);

            // Vorfilter aus dem Sammelabruf: Nur Räume mit etwas Ungelesenem sind
            // überhaupt einen zweiten Aufruf wert.
            var candidates = rooms.Where(r => r.Unread > 0 || r.Alert).ToList();

            logger.LogDebug(
                "RocketChat: mentionOnly={MentionOnly}; Räume: {Rooms}",
                config.MentionOnly,
                string.Join(" | ", rooms.Select(r =>
                    $"{r.Label} t={r.Type} unread={r.Unread} mentions={r.UserMentions}/{r.GroupMentions} alert={r.Alert}")));

            if (config.MentionOnly)
            {
                // Ohne diese Regel antworten in einem Raum mit drei Agenten alle drei auf
                // jede Nachricht. Direktnachrichten sind immer gemeint.
                candidates = candidates
                    .Where(r => r.IsDirect || r.UserMentions > 0 || r.GroupMentions > 0)
                    .ToList();
            }

            candidates = candidates
                .Where(r => config.IsRoomAllowed(r.RoomId) || config.IsRoomAllowed(r.Name))
                .ToList();

            if (candidates.Count == 0)
                return ToolJobResult.NoAction("Nichts Neues");

            var blocks = new List<string>();
            var total = 0;

            foreach (var room in candidates)
            {
                var messages = await CollectNewMessagesAsync(client, config, stateStore, agent, room, logger, ct);

                if (messages.Count > 0
                    && await TryConsumeWakeBudgetAsync(stateStore, agent, room, config, logger, ct))
                {
                    total += messages.Count;
                    blocks.Add(FormatRoom(room, messages));
                }

                // Jeden geprüften Raum als gelesen markieren — auch wenn nichts zu wecken
                // war. Sonst bliebe eine alte, nie gelesene Erwähnung stehen und der Raum
                // wäre bei jedem Takt erneut ein Kandidat.
                try
                {
                    await client.MarkReadAsync(room.RoomId, ct);
                }
                catch (RocketChatException ex)
                {
                    logger.LogWarning("RocketChat: Raum {Room} konnte nicht als gelesen markiert werden: {Message}",
                        room.Label, ex.Message);
                }
            }

            if (blocks.Count == 0)
                return ToolJobResult.NoAction("Nichts Neues (nach Filterung)");

            logger.LogInformation("RocketChat: {Count} neue Nachricht(en) in {Rooms} Raum/Räumen",
                total, blocks.Count);

            return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} neue Nachricht(en)");
        }
        catch (RocketChatException ex)
        {
            logger.LogWarning("RocketChat-Abruf fehlgeschlagen: {Message}", ex.Message);
            return ToolJobResult.NoAction($"Abruf-Fehler: {ex.Message}");
        }
        catch (HttpRequestException ex)
        {
            logger.LogWarning("Rocket.Chat ist nicht erreichbar: {Message}", ex.Message);
            return ToolJobResult.NoAction($"Nicht erreichbar: {ex.Message}");
        }
        catch (TaskCanceledException) when (!ct.IsCancellationRequested)
        {
            return ToolJobResult.NoAction("Zeitüberschreitung beim Abruf");
        }
    }

    /// <summary>
    /// Holt die neuen Nachrichten eines Raums und filtert alles heraus, was keinen Agenten
    /// wecken darf: eigene Beiträge, Systemmeldungen und — sofern nicht namentlich
    /// erwähnt — Beiträge anderer Agenten. Der letzte Punkt ist die Bremse gegen
    /// Agentenschleifen: Ohne ihn antwortet A auf B auf A.
    /// </summary>
    private static async Task<IReadOnlyList<RoomMessage>> CollectNewMessagesAsync(
        RocketChatClient client, RocketChatConfig config, IStateStore stateStore,
        string agent, RoomSubscription room, ILogger logger, CancellationToken ct)
    {
        var markerKey = $"rocketchat:{agent}:{room.RoomId}:lastTs";
        var markerRaw = await stateStore.GetAsync(markerKey, ct);

        DateTime since;
        if (string.IsNullOrWhiteSpace(markerRaw))
        {
            // Erster Abruf für diesen Raum. Nicht überspringen — ein Raum wird erst dann
            // zum Kandidaten, wenn Verkehr da ist, und genau die auslösende Nachricht
            // würde sonst verschluckt. Stattdessen begrenzt zurückschauen: neu genug, um
            // das Aktuelle zu erwischen, kurz genug, um beim Einrichten nicht die halbe
            // Raumgeschichte in den Kontext zu kippen.
            since = DateTime.UtcNow - config.InitialLookback;
            logger.LogInformation(
                "RocketChat: erster Abruf für {Room} — {Minutes} Minuten Rückschau",
                room.Label, config.InitialLookback.TotalMinutes);
        }
        else
        {
            since = DateTime.TryParse(markerRaw, null,
                DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
                ? parsed
                : DateTime.UtcNow - config.InitialLookback;
        }

        var history = await client.GetHistoryAsync(room, since, config.MaxMessagesPerRoom, ct);
        if (history.Count == 0)
            return [];

        // Die Marke wandert auf die jüngste *gesehene* Nachricht — auch auf gefilterte.
        // Sonst würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
        await stateStore.SetAsync(markerKey, history[^1].Timestamp.ToString("O"), ct);

        return history
            .Where(m => !m.IsSystem)
            .Where(m => m.UserId != config.UserId)
            // Die Erwähnungsprüfung gehört an die einzelne Nachricht, nicht an den Raum:
            // Der Zähler aus subscriptions.get zählt *ungelesene* Erwähnungen und bleibt
            // stehen, solange nichts gelesen wurde. Eine einzige alte Erwähnung würde den
            // Raum sonst dauerhaft „heiß" halten und jede weitere Nachricht ausliefern.
            .Where(m => !config.MentionOnly || room.IsDirect || IsMentioned(m, config))
            .Where(m => !config.IsAgentUser(m.Username) || IsMentioned(m, config))
            .ToList();
    }

    /// <summary>
    /// Erwähnungen liefert Rocket.Chat als Benutzernamen — verglichen wird deshalb gegen
    /// <c>username</c>, nicht gegen die Benutzer-Id.
    ///
    /// Ist kein Benutzername konfiguriert, gilt eine Nachricht als nicht erwähnt. Das ist
    /// die vorsichtige Richtung: Im Zweifel weckt die Nachricht eines anderen Agenten
    /// diesen hier nicht, statt eine Antwortschleife zu riskieren.
    /// </summary>
    private static bool IsMentioned(RoomMessage message, RocketChatConfig config)
        => message.Mentions.Any(m =>
               // @all und @here meinen ausdrücklich auch diesen Agenten.
               string.Equals(m, "all", StringComparison.OrdinalIgnoreCase)
               || string.Equals(m, "here", StringComparison.OrdinalIgnoreCase)
               || (!string.IsNullOrWhiteSpace(config.Username)
                   && string.Equals(m, config.Username, StringComparison.OrdinalIgnoreCase)));

    /// <summary>
    /// Deckelt, wie oft ein Raum den Agenten je Stunde wecken darf. Jeder Weckvorgang ist
    /// ein bezahlter Lauf; ohne Deckel wird ein lebhafter Raum zur Kostenfalle.
    /// </summary>
    private static async Task<bool> TryConsumeWakeBudgetAsync(
        IStateStore stateStore, string agent, RoomSubscription room,
        RocketChatConfig config, ILogger logger, CancellationToken ct)
    {
        if (config.MaxWakesPerRoomPerHour <= 0)
            return true;

        var bucket = DateTime.UtcNow.ToString("yyyyMMddHH");
        var key = $"rocketchat:{agent}:{room.RoomId}:wakes:{bucket}";

        var used = int.TryParse(await stateStore.GetAsync(key, ct), out var n) ? n : 0;
        if (used >= config.MaxWakesPerRoomPerHour)
        {
            logger.LogWarning(
                "RocketChat: Weckgrenze für {Room} erreicht ({Max}/Stunde) — Nachrichten bleiben ungelesen",
                room.Label, config.MaxWakesPerRoomPerHour);
            return false;
        }

        await stateStore.SetAsync(key, (used + 1).ToString(), ct);
        return true;
    }

    private static string FormatRoom(RoomSubscription room, IReadOnlyList<RoomMessage> messages)
    {
        var sb = new StringBuilder();
        sb.AppendLine($"Raum: {room.Label} (roomId: {room.RoomId})");

        foreach (var m in messages)
        {
            sb.AppendLine($"[{m.Timestamp:HH:mm}] @{m.Username} (messageId: {m.Id}): {m.Text}");

            // Ohne diese Zeilen sähe der Agent bei einer reinen Dateisendung nur einen
            // leeren Beitrag — die Datei liegt nicht im Text, sondern daneben.
            foreach (var f in m.Files)
                sb.AppendLine($"    Datei: {f.Describe()}");

            foreach (var l in m.Links)
                sb.AppendLine($"    Link: {l.Url}"
                    + (l.Title is null ? "" : $" — {Shorten(l.Title, 120)}"));
        }

        return sb.ToString().TrimEnd();
    }

    private static string Shorten(string value, int max)
    {
        var flat = value.ReplaceLineEndings(" ").Trim();
        return flat.Length <= max ? flat : flat[..max] + "…";
    }

    private static string BuildWakeMessage(IReadOnlyList<string> blocks)
        => $"""
            Neue Nachrichten in Rocket.Chat.

            {Untrusted(string.Join("\n\n", blocks))}

            Wenn du antworten möchtest, nutze das Tool RocketChat mit der roomId aus dem
            jeweiligen Block (send_message), oder reply mit der messageId als threadId.
            """;

    private static string Untrusted(string content)
        => $"<untrusted_content>\n{content}\n</untrusted_content>";

    // ─── Eingabe-Helfer ───

    private static string? GetString(JsonElement input, string name)
        => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
            ? v.GetString()
            : null;

    private static int? GetInt(JsonElement input, string name)
        => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
           && v.TryGetInt32(out var i)
            ? i
            : null;
}
