using System.Net.Http.Json;
using System.Text;
using System.Text.Json;

namespace ClawdDotNet.Tools.RocketChat;

/// <summary>Ein Raum, wie ihn das Abonnement des Agenten beschreibt.</summary>
internal sealed record RoomSubscription(
    string RoomId,
    string Name,
    string Type,          // c = Kanal, p = private Gruppe, d = Direktnachricht
    int Unread,
    int UserMentions,
    int GroupMentions,
    bool Alert)
{
    public bool IsDirect => Type == "d";

    /// <summary>Menschenlesbare Bezeichnung für Ausgaben.</summary>
    public string Label => IsDirect ? $"@{Name}" : $"#{Name}";
}

/// <summary>Eine angehängte Datei. <paramref name="FileId"/> ist der Griff zum Abholen.</summary>
internal sealed record MessageFile(string FileId, string Name, string ContentType, long Size)
{
    public string Describe()
    {
        var size = Size >= 1024 * 1024 ? $"{Size / 1024.0 / 1024.0:0.#} MB"
            : Size >= 1024 ? $"{Size / 1024.0:0.#} KB"
            : $"{Size} B";
        return $"{Name} ({ContentType}, {size}) — fileId: {FileId}";
    }
}

/// <summary>
/// Ein Link samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat.
/// Titel und Beschreibung stammen von der verlinkten Seite — also fremder Text, der
/// niemals als Anweisung gelesen werden darf.
/// </summary>
internal sealed record MessageLink(string Url, string? Title, string? Description);

/// <summary>Eine Nachricht aus der Raum-Historie.</summary>
internal sealed record RoomMessage(
    string Id,
    DateTime Timestamp,
    string Username,
    string UserId,
    string Text,
    IReadOnlyList<string> Mentions,
    string? SystemType,
    string? ThreadId,
    IReadOnlyList<MessageFile> Files,
    IReadOnlyList<MessageLink> Links)
{
    /// <summary>
    /// Systemnachrichten („Benutzer ist beigetreten", Themenänderung …) tragen ein
    /// <c>t</c>-Feld. Sie sind kein Gesprächsbeitrag und dürfen keinen Agenten wecken —
    /// sonst antwortet er auf einen Raumbeitritt.
    /// </summary>
    public bool IsSystem => !string.IsNullOrEmpty(SystemType);
}

/// <summary>Fehler der Rocket.Chat-API, mit dem Fehlerschlüssel aus der Antwort.</summary>
internal sealed class RocketChatException(string message, string? errorType = null)
    : Exception(message)
{
    public string? ErrorType { get; } = errorType;
}

/// <summary>
/// Schmaler REST-Client für Rocket.Chat. Authentifiziert wird je Aufruf über die beiden
/// Kopfzeilen <c>X-Auth-Token</c> und <c>X-User-Id</c>; woher das Token stammt
/// (Personal Access Token oder vom Administrator erzeugt), ist dem Client gleich.
///
/// Geprüft gegen Rocket.Chat 8.7.
/// </summary>
internal sealed class RocketChatClient(RocketChatConfig config)
{
    // Ein geteilter HttpClient — je Aufruf einen neuen zu erzeugen erschöpft unter Last
    // die Sockets. Die Zugangsdaten hängen deshalb an der Anfrage, nicht am Client.
    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };

    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

    public async Task<JsonElement> SendAsync(
        HttpMethod method, string path, object? body, CancellationToken ct)
    {
        using var request = new HttpRequestMessage(method, $"{config.BaseUrl}{path}");
        request.Headers.Add("X-Auth-Token", config.AuthToken);
        request.Headers.Add("X-User-Id", config.UserId);

        if (body is not null)
            request.Content = JsonContent.Create(body, options: JsonOptions);

        using var response = await Http.SendAsync(request, ct);
        var raw = await response.Content.ReadAsStringAsync(ct);

        JsonElement json;
        try
        {
            json = JsonDocument.Parse(raw).RootElement.Clone();
        }
        catch (JsonException)
        {
            throw new RocketChatException(
                $"Rocket.Chat antwortete mit {(int)response.StatusCode} und keinem JSON: " +
                Truncate(raw, 200));
        }

        var success = json.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
        if (success)
            return json;

        var errorType = json.TryGetProperty("errorType", out var et) ? et.GetString() : null;
        var message = json.TryGetProperty("error", out var e) ? e.GetString()
            : json.TryGetProperty("message", out var m) ? m.GetString()
            : $"HTTP {(int)response.StatusCode}";

        throw new RocketChatException(message ?? "Unbekannter Fehler", errorType);
    }

    /// <summary>
    /// Alle Räume des Agenten samt Ungelesen-Zählern — <b>ein</b> Aufruf, unabhängig von
    /// der Zahl der Räume. Darauf steht der gesamte Poll-Entwurf: Erst dieser Sammelabruf
    /// entscheidet, ob überhaupt eine Historie geholt werden muss.
    /// </summary>
    public async Task<IReadOnlyList<RoomSubscription>> GetRoomsAsync(CancellationToken ct)
    {
        var json = await SendAsync(HttpMethod.Get, "/api/v1/subscriptions.get", null, ct);

        if (!json.TryGetProperty("update", out var update) || update.ValueKind != JsonValueKind.Array)
            return [];

        var rooms = new List<RoomSubscription>();
        foreach (var item in update.EnumerateArray())
        {
            var roomId = GetString(item, "rid");
            if (string.IsNullOrEmpty(roomId))
                continue;

            rooms.Add(new RoomSubscription(
                RoomId: roomId,
                Name: GetString(item, "name") ?? roomId,
                Type: GetString(item, "t") ?? "c",
                Unread: GetInt(item, "unread"),
                UserMentions: GetInt(item, "userMentions"),
                GroupMentions: GetInt(item, "groupMentions"),
                Alert: item.TryGetProperty("alert", out var a) && a.ValueKind == JsonValueKind.True));
        }

        return rooms;
    }

    /// <summary>
    /// Löst einen Raumnamen oder eine Raum-Id gegen die Abonnements des Agenten auf.
    /// Was hier nicht auftaucht, ist ein Raum, in dem der Agent kein Mitglied ist — das
    /// ist eine klarere Auskunft als der API-Fehler <c>invalid-channel</c>.
    /// </summary>
    public static RoomSubscription? Resolve(IReadOnlyList<RoomSubscription> rooms, string roomIdOrName)
    {
        var needle = roomIdOrName.TrimStart('#', '@');

        return rooms.FirstOrDefault(r => string.Equals(r.RoomId, roomIdOrName, StringComparison.Ordinal))
            ?? rooms.FirstOrDefault(r => string.Equals(r.Name, needle, StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>Nachrichten eines Raums seit einem Zeitpunkt, älteste zuerst.</summary>
    public async Task<IReadOnlyList<RoomMessage>> GetHistoryAsync(
        RoomSubscription room, DateTime? since, int count, CancellationToken ct)
    {
        // Die Historie hat je Raumart einen eigenen Endpunkt.
        var endpoint = room.Type switch
        {
            "p" => "groups.history",
            "d" => "im.history",
            _ => "channels.history"
        };

        var query = new StringBuilder($"/api/v1/{endpoint}?roomId={Uri.EscapeDataString(room.RoomId)}")
            .Append("&count=").Append(count);

        if (since is { } from)
        {
            query.Append("&oldest=").Append(Uri.EscapeDataString(
                from.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
            query.Append("&inclusive=false");
        }

        var json = await SendAsync(HttpMethod.Get, query.ToString(), null, ct);

        if (!json.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
            return [];

        var result = new List<RoomMessage>();
        foreach (var m in messages.EnumerateArray())
        {
            var id = GetString(m, "_id");
            if (string.IsNullOrEmpty(id))
                continue;

            var user = m.TryGetProperty("u", out var u) ? u : default;

            result.Add(new RoomMessage(
                Id: id,
                Timestamp: GetDate(m, "ts"),
                Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unbekannt",
                UserId: (user.ValueKind == JsonValueKind.Object ? GetString(user, "_id") : null) ?? "",
                Text: GetString(m, "msg") ?? "",
                Mentions: ReadMentions(m),
                SystemType: GetString(m, "t"),
                ThreadId: GetString(m, "tmid"),
                Files: ReadFiles(m),
                Links: ReadLinks(m)));
        }

        // Rocket.Chat liefert die jüngste Nachricht zuerst; für einen Verlauf ist die
        // umgekehrte Reihenfolge die brauchbare.
        return result.OrderBy(x => x.Timestamp).ToList();
    }

    /// <summary>
    /// Öffnet (oder findet) den Direktnachrichten-Raum zu einem Benutzer. Nötig, weil ein
    /// DM-Raum erst existiert, wenn jemand ihn eröffnet — ohne das könnte ein Agent nur
    /// auf bestehende Gespräche antworten, aber keines beginnen.
    /// </summary>
    public async Task<RoomSubscription?> OpenDirectMessageAsync(string username, CancellationToken ct)
    {
        var json = await SendAsync(HttpMethod.Post, "/api/v1/im.create", new { username }, ct);

        if (!json.TryGetProperty("room", out var room) || room.ValueKind != JsonValueKind.Object)
            return null;

        var roomId = GetString(room, "_id") ?? GetString(room, "rid");

        return roomId is null
            ? null
            : new RoomSubscription(roomId, username, "d", 0, 0, 0, false);
    }

    public async Task<string> PostMessageAsync(
        string roomId, string text, string? threadId, CancellationToken ct)
    {
        object body = threadId is null
            ? new { roomId, text }
            : new { roomId, text, tmid = threadId };

        var json = await SendAsync(HttpMethod.Post, "/api/v1/chat.postMessage", body, ct);

        return json.TryGetProperty("message", out var m) ? GetString(m, "_id") ?? "" : "";
    }

    /// <summary>
    /// Lädt eine Datei in einen Raum. Das läuft in <b>zwei</b> Schritten, anders als der
    /// Rest der API:
    ///
    /// 1. <c>rooms.media</c> nimmt die Datei als <c>multipart/form-data</c> entgegen und
    ///    gibt eine Datei-Id zurück — sichtbar ist damit noch nichts.
    /// 2. <c>rooms.mediaConfirm</c> veröffentlicht sie als Nachricht im Raum.
    ///
    /// Der frühere Ein-Schritt-Endpunkt <c>rooms.upload</c> existiert in Rocket.Chat 8.7
    /// nicht mehr (er antwortet mit einem nackten 404). Diese Umsetzung setzt daher eine
    /// Version voraus, die <c>rooms.media</c> kennt (6.x und neuer).
    /// </summary>
    public async Task<string> UploadFileAsync(
        string roomId, Stream content, string fileName, string contentType,
        string? message, string? threadId, CancellationToken ct)
    {
        var room = Uri.EscapeDataString(roomId);

        // Schritt 1 — die Datei selbst.
        using var form = new MultipartFormDataContent();
        var file = new StreamContent(content);
        file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
        form.Add(file, "file", fileName);

        using var request = new HttpRequestMessage(
            HttpMethod.Post, $"{config.BaseUrl}/api/v1/rooms.media/{room}");
        request.Headers.Add("X-Auth-Token", config.AuthToken);
        request.Headers.Add("X-User-Id", config.UserId);
        request.Content = form;

        using var response = await Http.SendAsync(request, ct);
        var raw = await response.Content.ReadAsStringAsync(ct);

        JsonElement upload;
        try
        {
            upload = JsonDocument.Parse(raw).RootElement.Clone();
        }
        catch (JsonException)
        {
            throw new RocketChatException(
                $"Upload scheiterte mit {(int)response.StatusCode}: {Truncate(raw, 200)}"
                + (response.StatusCode == System.Net.HttpStatusCode.NotFound
                    ? " — kennt diese Rocket.Chat-Version den Endpunkt rooms.media?"
                    : ""));
        }

        if (!upload.TryGetProperty("success", out var ok) || ok.ValueKind != JsonValueKind.True)
            throw new RocketChatException(
                (upload.TryGetProperty("error", out var e) ? e.GetString() : null)
                ?? "Upload fehlgeschlagen");

        var fileId = upload.TryGetProperty("file", out var f) ? GetString(f, "_id") : null;
        if (string.IsNullOrEmpty(fileId))
            throw new RocketChatException("Upload lieferte keine Datei-Id zurück.");

        // Schritt 2 — ohne ihn liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
        object confirmBody = threadId is null
            ? new { msg = message ?? "" }
            : new { msg = message ?? "", tmid = threadId };

        var confirm = await SendAsync(
            HttpMethod.Post, $"/api/v1/rooms.mediaConfirm/{room}/{Uri.EscapeDataString(fileId)}",
            confirmBody, ct);

        return confirm.TryGetProperty("message", out var msg) ? GetString(msg, "_id") ?? "" : "";
    }

    public async Task MarkReadAsync(string roomId, CancellationToken ct)
        => await SendAsync(HttpMethod.Post, "/api/v1/subscriptions.read", new { rid = roomId }, ct);

    // ─── JSON-Lesehelfer ───

    /// <summary>
    /// Angehängte Dateien. Rocket.Chat führt sie in <c>files</c>; die Datei-Id daraus ist
    /// alles, was zum späteren Abholen nötig ist.
    /// </summary>
    private static IReadOnlyList<MessageFile> ReadFiles(JsonElement message)
    {
        if (!message.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
            return [];

        var result = new List<MessageFile>();
        foreach (var f in files.EnumerateArray())
        {
            var id = GetString(f, "_id");
            if (string.IsNullOrEmpty(id))
                continue;

            result.Add(new MessageFile(
                FileId: id,
                Name: GetString(f, "name") ?? id,
                ContentType: GetString(f, "type") ?? "application/octet-stream",
                Size: f.TryGetProperty("size", out var s) && s.ValueKind == JsonValueKind.Number
                      && s.TryGetInt64(out var n) ? n : 0));
        }

        return result;
    }

    /// <summary>
    /// Links samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat. Das
    /// erspart dem Agenten oft einen eigenen Abruf der Seite.
    /// </summary>
    private static IReadOnlyList<MessageLink> ReadLinks(JsonElement message)
    {
        if (!message.TryGetProperty("urls", out var urls) || urls.ValueKind != JsonValueKind.Array)
            return [];

        var result = new List<MessageLink>();
        foreach (var u in urls.EnumerateArray())
        {
            var url = GetString(u, "url");
            if (string.IsNullOrEmpty(url))
                continue;

            var meta = u.TryGetProperty("meta", out var m) && m.ValueKind == JsonValueKind.Object
                ? m
                : default;

            result.Add(new MessageLink(
                Url: url,
                Title: meta.ValueKind == JsonValueKind.Object
                    ? GetString(meta, "pageTitle") ?? GetString(meta, "ogTitle")
                    : null,
                Description: meta.ValueKind == JsonValueKind.Object
                    ? GetString(meta, "description") ?? GetString(meta, "ogDescription")
                    : null));
        }

        return result;
    }

    /// <summary>
    /// Holt eine angehängte Datei. Die Dateiablage liegt nicht unter <c>/api</c>, nimmt
    /// aber dieselben Kopfzeilen; ohne sie antwortet der Server mit 403. Der Namensteil
    /// der Adresse ist beliebig, muss aber vorhanden sein.
    /// </summary>
    public async Task<long> DownloadToFileAsync(
        string fileId, string destinationPath, long maxBytes, CancellationToken ct)
    {
        using var request = new HttpRequestMessage(
            HttpMethod.Get, $"{config.BaseUrl}/file-upload/{Uri.EscapeDataString(fileId)}/download");
        request.Headers.Add("X-Auth-Token", config.AuthToken);
        request.Headers.Add("X-User-Id", config.UserId);

        using var response = await Http.SendAsync(
            request, HttpCompletionOption.ResponseHeadersRead, ct);

        if (!response.IsSuccessStatusCode)
            throw new RocketChatException(response.StatusCode switch
            {
                System.Net.HttpStatusCode.Forbidden =>
                    "Zugriff auf die Datei verweigert — der Agent ist vermutlich kein Mitglied des Raums.",
                System.Net.HttpStatusCode.NotFound => $"Keine Datei mit der Id {fileId}.",
                _ => $"Datei konnte nicht geladen werden (HTTP {(int)response.StatusCode})."
            });

        if (response.Content.Headers.ContentLength is { } declared && declared > maxBytes)
            throw new RocketChatException(
                $"Die Datei ist {declared / 1024 / 1024} MB groß und überschreitet die Grenze von "
                + $"{maxBytes / 1024 / 1024} MB.");

        await using var source = await response.Content.ReadAsStreamAsync(ct);

        // Erst in eine Nebendatei schreiben: Bricht der Download ab oder reißt die Grenze,
        // bleibt keine halbe Datei im Workspace liegen, die der Agent für vollständig hält.
        var temp = destinationPath + ".part";
        long written = 0;

        try
        {
            await using (var target = File.Create(temp))
            {
                var buffer = new byte[81920];
                int read;
                while ((read = await source.ReadAsync(buffer, ct)) > 0)
                {
                    written += read;

                    // Auch ohne Content-Length darf die Grenze nicht überschritten werden.
                    if (written > maxBytes)
                        throw new RocketChatException(
                            $"Die Datei überschreitet die Grenze von {maxBytes / 1024 / 1024} MB.");

                    await target.WriteAsync(buffer.AsMemory(0, read), ct);
                }
            }

            File.Move(temp, destinationPath, overwrite: true);
            return written;
        }
        catch
        {
            if (File.Exists(temp))
                try { File.Delete(temp); } catch { /* Aufräumen darf den Fehler nicht verdecken */ }
            throw;
        }
    }

    private static IReadOnlyList<string> ReadMentions(JsonElement message)
    {
        if (!message.TryGetProperty("mentions", out var mentions)
            || mentions.ValueKind != JsonValueKind.Array)
            return [];

        return mentions.EnumerateArray()
            .Select(x => GetString(x, "username"))
            .Where(x => !string.IsNullOrEmpty(x))
            .Select(x => x!)
            .ToList();
    }

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

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

    private static DateTime GetDate(JsonElement element, string name)
        => element.TryGetProperty(name, out var v)
           && v.ValueKind == JsonValueKind.String
           && DateTime.TryParse(v.GetString(), null,
               System.Globalization.DateTimeStyles.AdjustToUniversal
               | System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
            ? d
            : DateTime.UtcNow;

    private static string Truncate(string value, int max)
        => value.Length <= max ? value : value[..max] + "…";
}
