import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { friendlyErrorText } from "../../core/error-messages.js";
export class MatrixApiError extends Error {
    status;
    body;
    retryAfterMs;
    constructor(message, status, body) {
        super(message);
        this.status = status;
        this.body = body;
        if (body && typeof body === "object") {
            const retry = body.retry_after_ms;
            if (typeof retry === "number" && Number.isFinite(retry)) {
                this.retryAfterMs = retry;
            }
        }
    }
}
export class MatrixClient {
    homeserverUrl;
    accessToken;
    userId;
    deviceId;
    syncTimeoutMs;
    pollTimeoutMs;
    constructor(options) {
        this.homeserverUrl = options.homeserverUrl.replace(/\/+$/, "");
        this.accessToken = options.accessToken;
        this.userId = options.userId;
        this.deviceId = options.deviceId;
        this.syncTimeoutMs = options.syncTimeoutMs;
        this.pollTimeoutMs = options.pollTimeoutMs;
    }
    homeserverName() {
        const match = this.userId.match(/^@[^:]+:(.+)$/);
        return match?.[1];
    }
    async whoami() {
        return this.request("/_matrix/client/v3/account/whoami");
    }
    async joinedRooms() {
        const response = await this.request("/_matrix/client/v3/joined_rooms");
        return response.joined_rooms ?? [];
    }
    async sync(since) {
        const params = new URLSearchParams({ timeout: String(this.syncTimeoutMs) });
        if (since)
            params.set("since", since);
        return this.request(`/_matrix/client/v3/sync?${params.toString()}`, {
            signal: AbortSignal.timeout(Math.max(this.pollTimeoutMs, this.syncTimeoutMs + 1_000)),
        });
    }
    async joinRoom(roomIdOrAlias) {
        await this.request(`/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`, {
            method: "POST",
            body: JSON.stringify({}),
        });
    }
    async sendText(roomId, input) {
        return this.sendRoomEvent(roomId, "m.room.message", matrixTextContent(input));
    }
    async editText(roomId, eventId, input) {
        return this.sendRoomEvent(roomId, "m.room.message", {
            msgtype: "m.text",
            body: `* ${input.body}`,
            ...(input.formattedBody ? { format: "org.matrix.custom.html", formatted_body: `* ${input.formattedBody}` } : {}),
            "m.new_content": matrixTextContent(input),
            "m.relates_to": {
                rel_type: "m.replace",
                event_id: eventId,
            },
        });
    }
    async sendFile(roomId, input) {
        const name = input.name || path.basename(input.localPath);
        const size = (await stat(input.localPath)).size;
        const contentUri = await this.upload(input.localPath, name, input.mimeType);
        const isImage = input.mimeType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(name);
        return this.sendRoomEvent(roomId, "m.room.message", {
            msgtype: isImage ? "m.image" : "m.file",
            body: input.caption || name,
            filename: name,
            url: contentUri,
            info: {
                mimetype: input.mimeType ?? "application/octet-stream",
                size,
            },
            ...(input.threadId ? threadRelation(input.threadId) : {}),
        });
    }
    async setTyping(roomId, typing, timeoutMs = 10_000) {
        await this.request(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(this.userId)}`, {
            method: "PUT",
            body: JSON.stringify({ typing, timeout: timeoutMs }),
        });
    }
    async getMedia(mxcUri) {
        const parsed = parseMxcUri(mxcUri);
        const response = await this.rawRequest(`/_matrix/client/v1/media/download/${encodeURIComponent(parsed.serverName)}/${encodeURIComponent(parsed.mediaId)}`);
        if (!response.ok) {
            throw new MatrixApiError(`Matrix media download failed: ${response.status}`, response.status);
        }
        const contentType = response.headers.get("content-type") ?? undefined;
        const disposition = response.headers.get("content-disposition") ?? undefined;
        return {
            buffer: Buffer.from(await response.arrayBuffer()),
            contentType,
            filename: filenameFromDisposition(disposition),
        };
    }
    async upload(localPath, filename, mimeType) {
        const params = new URLSearchParams({ filename });
        const response = await this.rawRequest(`/_matrix/media/v3/upload?${params.toString()}`, {
            method: "POST",
            headers: { "content-type": mimeType ?? "application/octet-stream" },
            body: createReadStream(localPath),
            duplex: "half",
        });
        if (!response.ok) {
            throw await matrixError(response);
        }
        const body = await response.json();
        if (!body.content_uri) {
            throw new Error("Matrix media upload response did not include content_uri.");
        }
        return body.content_uri;
    }
    async sendRoomEvent(roomId, eventType, content) {
        const txnId = randomUUID();
        const response = await this.request(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {
            method: "PUT",
            body: JSON.stringify(content),
        });
        if (!response.event_id) {
            throw new Error("Matrix send response did not include event_id.");
        }
        return response.event_id;
    }
    async request(pathName, init = {}) {
        const response = await this.rawRequest(pathName, {
            ...init,
            headers: {
                ...(init.body ? { "content-type": "application/json" } : {}),
                ...(init.headers ?? {}),
            },
        });
        if (!response.ok) {
            throw await matrixError(response);
        }
        return response.json();
    }
    rawRequest(pathName, init = {}) {
        const url = `${this.homeserverUrl}${pathName.startsWith("/") ? pathName : `/${pathName}`}`;
        return fetch(url, {
            ...init,
            headers: {
                authorization: `Bearer ${this.accessToken}`,
                ...(init.headers ?? {}),
            },
        });
    }
}
export function matrixTextContent(input) {
    return {
        msgtype: input.msgtype ?? "m.text",
        body: input.body || ".",
        ...(input.formattedBody ? { format: "org.matrix.custom.html", formatted_body: input.formattedBody } : {}),
        ...(input.threadId ? threadRelation(input.threadId) : {}),
    };
}
function threadRelation(threadId) {
    return {
        "m.relates_to": {
            rel_type: "m.thread",
            event_id: threadId,
            is_falling_back: true,
        },
    };
}
async function matrixError(response) {
    let body;
    try {
        body = await response.json();
    }
    catch {
        body = await response.text().catch(() => undefined);
    }
    const detail = body && typeof body === "object" && typeof body.error === "string"
        ? body.error
        : friendlyErrorText(body);
    return new MatrixApiError(`Matrix API ${response.status}: ${detail}`, response.status, body);
}
function parseMxcUri(uri) {
    const match = uri.match(/^mxc:\/\/([^/]+)\/(.+)$/);
    if (!match?.[1] || !match[2]) {
        throw new Error(`Invalid Matrix media URI: ${uri}`);
    }
    return { serverName: match[1], mediaId: match[2] };
}
function filenameFromDisposition(value) {
    if (!value)
        return undefined;
    const match = value.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i);
    return match?.[1] ? decodeURIComponent(match[1].replace(/"$/, "")) : undefined;
}
