// retoor <retoor@molodetz.nl>

import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
import { ChatSocket } from "../chat/ChatSocket.js";
import { shouldGroup } from "../chat/MessageGrouping.js";
import { dateKey, dateLabel } from "../chat/DateSeparators.js";
import { Http } from "../Http.js";
import { Avatar } from "../Avatar.js";
import { DomUtils } from "../DomUtils.js";
import { ListNav } from "../ListNav.js";
import { contentRenderer } from "../ContentRenderer.js";
import { PubSubClient } from "../PubSubClient.js";
import { PresenceManager } from "../PresenceManager.js";

const CHAT_CSS_ID = "chat-css";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
const AUTO_SCROLL_MARGIN_PX = 100;
const STABILIZE_MAX_FRAMES = 300;
const SEND_TIMEOUT_MS = 20000;
const MOBILE_BREAKPOINT_PX = 768;
const AI_INDICATOR_MS = 4000;
const SEARCH_DEBOUNCE_MS = 200;
const AUTO_GROW_MAX_HEIGHT_PX = 160;
const CONVERSATION_PAGE_SIZE = 500;

const LIGHT_THEME_OVERRIDES = {
    "--bg-primary": "#f5f3fa",
    "--bg-secondary": "#ffffff",
    "--bg-card": "#ffffff",
    "--bg-card-hover": "#f0edf7",
    "--text-primary": "#1a1030",
    "--text-secondary": "#4a3f5c",
    "--text-muted": "#8b7fa0",
    "--border": "rgba(0, 0, 0, 0.1)",
    "--border-light": "rgba(0, 0, 0, 0.16)",
};

export class AppChat extends Component {
    constructor() {
        super();
        this._built = false;
        this.socket = null;
        this._presence = null;
        this._pendingSends = new Map();
        this._failedSends = new Map();
        this._uploading = false;
        this._userAtBottom = true;
        this._stabilizeFrames = 0;
        this._stabilizePending = false;
        this._lastTypingSent = 0;
        this._typingHideTimer = null;
        this._socketReady = false;
        this._activePane = "list";
        this._supportsFieldSizing = typeof CSS !== "undefined" && !!CSS.supports && CSS.supports("field-sizing", "content");
        this._autoGrowFrame = null;
        this._lastAutoGrowHeight = null;
        this._hasOlder = false;
        this._loadingOlder = false;
        this._opening = false;
        this._newCount = 0;
        this._lastSyncAt = null;
        this._coarseQuery = null;
        this._connBanner = null;
        this._disconnectTimer = null;
    }

    _ensureCss() {
        if (document.getElementById(CHAT_CSS_ID)) return;
        const link = document.createElement("link");
        link.id = CHAT_CSS_ID;
        link.rel = "stylesheet";
        link.href = assetUrl("/static/css/chat.css");
        document.head.appendChild(link);
    }

    connectedCallback() {
        if (this._built) return;
        this._built = true;
        this._ensureCss();
        this._readConfig();
        this._applyThemeOverrides();
        this._adopt();
        this._ensureSkeleton();
        this._ensureConnectionBanner();
        this._normalizeDateSeparators();
        this._capAttachments();
        this._initComposer();
        this._initRetry();
        this._initSearch();
        this._initMobilePane();
        this._bindHistory();
        this._bindViewport();
        this._bindAutoScroll();
        this._startScrollWatcher();
        this._ensureJumpButton();
        this._connect();
        this._initPresence();
        this._scrollThreadToEnd();
        this._hasOlder = !!(this.thread && this.thread.querySelectorAll(".message-bubble").length >= CONVERSATION_PAGE_SIZE);
        if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
    }

    disconnectedCallback() {
        if (this.socket) this.socket.close();
        if (this._scrollWatcher) this._scrollWatcher.disconnect();
        if (this._viewportIO) this._viewportIO.disconnect();
        if (this._resizeObserver) this._resizeObserver.disconnect();
        if (this._viewportListenerCleanup) this._viewportListenerCleanup();
        if (this._mobileQuery && this._onMobileChange) {
            this._mobileQuery.removeEventListener("change", this._onMobileChange);
        }
        clearTimeout(this._typingHideTimer);
        clearTimeout(this._disconnectTimer);
        cancelAnimationFrame(this._autoGrowFrame);
        for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
        this._pendingSends.clear();
        this._failedSends.clear();
        if (this._presence) this._presence.stop();
        if (this._onPopState) window.removeEventListener("popstate", this._onPopState);
    }

    _readConfig() {
        this.mode = this.attr("mode", "page");
        this.selfUid = this.attr("self-uid");
        this.withUid = this.attr("with-uid") || null;
        this.conversationsUrl = this.attr("conversations-url", "/messages/conversations");
        this.searchUrl = this.attr("search-url", "/messages/search");
        this.sendUrl = this.attr("send-url", "/messages/send");
        this.wsPath = this.attr("ws-url", "/messages/ws");
        this.apiKey = this.attr("api-key", "");
        this.wsTicketUrl = this.attr("ws-ticket-url", "");
        this.aiIndicatorEnabled = this.attr("ai-indicator", "false") === "true";
        this.maxAttachments = this.intAttr("max-attachments", 5);
    }

    _applyThemeOverrides() {
        const accent = this.attr("accent", "");
        if (accent) this.style.setProperty("--accent", accent);
        const theme = this.attr("theme", "dark");
        if (theme === "light") {
            for (const [prop, value] of Object.entries(LIGHT_THEME_OVERRIDES)) {
                this.style.setProperty(prop, value);
            }
        }
    }

    _adopt() {
        this.list = this.querySelector(".messages-list");
        this.main = this.querySelector(".messages-main");
        this.thread = this.querySelector(".messages-thread");
        this.form = this.querySelector(".messages-input-area");
        this.input = this.form ? this.form.querySelector('textarea[name="content"], input[name="content"]') : null;
        this.upload = this.form ? this.form.querySelector("dp-upload") : null;
        this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
        this.typingEl = this.querySelector("#typing-indicator");
        this.backBtn = this.querySelector("#messages-back-btn");
        this.searchInput = this.querySelector("#message-search");
    }

    _ensureSkeleton() {
        if (this.main) return;

        this.main = document.createElement("div");
        this.main.className = "messages-main";

        const header = document.createElement("div");
        header.className = "messages-main-header";
        const headerInfo = document.createElement("div");
        headerInfo.className = "messages-header-info";
        const presence = document.createElement("span");
        presence.className = "messages-presence";
        presence.id = "messages-presence";
        presence.setAttribute("role", "status");
        presence.setAttribute("aria-live", "polite");
        presence.setAttribute("data-presence-label", "");
        presence.setAttribute("data-presence-uid", this.withUid || "");
        presence.textContent = "offline";
        headerInfo.appendChild(presence);
        header.appendChild(headerInfo);

        this.thread = document.createElement("div");
        this.thread.className = "messages-thread";
        this.thread.setAttribute("role", "log");
        this.thread.setAttribute("aria-label", "Message transcript");
        this.thread.setAttribute("aria-live", "polite");
        this.thread.setAttribute("aria-relevant", "additions");

        this.typingEl = document.createElement("div");
        this.typingEl.className = "typing-indicator";
        this.typingEl.id = "typing-indicator";
        this.typingEl.hidden = true;
        this.typingEl.append(document.createElement("span"), document.createElement("span"), document.createElement("span"));
        this.thread.appendChild(this.typingEl);

        this.form = document.createElement("form");
        this.form.className = "messages-input-area";
        this.form.setAttribute("method", "POST");
        this.form.setAttribute("action", this.sendUrl);
        this.form.setAttribute("data-live-form", "");

        const receiverInput = document.createElement("input");
        receiverInput.type = "hidden";
        receiverInput.name = "receiver_uid";
        receiverInput.value = this.withUid || "";
        this.form.appendChild(receiverInput);

        this.input = document.createElement("textarea");
        this.input.name = "content";
        this.input.rows = 1;
        this.input.maxLength = 2000;
        this.input.autocomplete = "off";
        this.input.placeholder = "Type a message...";
        this.input.setAttribute("aria-label", "Type a message");

        this.upload = document.createElement("dp-upload");
        this.upload.setAttribute("multiple", "");
        this.upload.setAttribute("paste", "");
        this.upload.setAttribute("max-files", String(this.maxAttachments));

        this.sendBtn = document.createElement("button");
        this.sendBtn.type = "submit";
        this.sendBtn.className = "messages-send-btn";
        this.sendBtn.setAttribute("aria-label", "Send");
        const sendIcon = document.createElement("span");
        sendIcon.className = "send-icon";
        sendIcon.textContent = "➤";
        const sendSpinner = document.createElement("span");
        sendSpinner.className = "send-spinner";
        sendSpinner.setAttribute("aria-hidden", "true");
        this.sendBtn.append(sendIcon, sendSpinner);

        this.form.append(this.input, this.upload, this.sendBtn);
        this.main.append(header, this.thread, this.form);
        this.appendChild(this.main);
    }

    _capAttachments() {
        if (!this.upload) return;
        const capped = this.maxAttachments;
        const current = parseInt(this.upload.getAttribute("max-files") || "", 10);
        if (!Number.isFinite(current) || current > capped) {
            this.upload.setAttribute("max-files", String(capped));
        }
    }

    _bubbleMeta(bubble) {
        const timeNode = bubble.querySelector(".message-time[datetime], .message-time time[datetime]");
        const createdAt = timeNode ? timeNode.getAttribute("datetime") : null;
        const senderUid = bubble.classList.contains("mine") ? this.selfUid : this.withUid;
        return { senderUid, createdAt };
    }

    _lastBubbleMeta() {
        if (!this.thread) return null;
        const bubbles = this.thread.querySelectorAll(".message-bubble");
        if (!bubbles.length) return null;
        return this._bubbleMeta(bubbles[bubbles.length - 1]);
    }

    _previousMetaBefore(bubble) {
        let sibling = bubble.previousElementSibling;
        while (sibling && !sibling.classList.contains("message-bubble")) {
            sibling = sibling.previousElementSibling;
        }
        return sibling ? this._bubbleMeta(sibling) : null;
    }

    _initComposer() {
        if (!this.form || !this.input) return;
        this.form.addEventListener("submit", (event) => {
            event.preventDefault();
            this._sendViaSocket();
        });
        if (this.upload) {
            this.upload.addEventListener("dp-upload:busy", (event) => {
                this._uploading = !!(event.detail && event.detail.busy);
                this._refreshSendButton();
            });
        }
        this.input.addEventListener("keydown", (event) => {
            if (event.key === "Enter" && !event.shiftKey && !this._isCoarse()) {
                event.preventDefault();
                this.form.requestSubmit();
            }
        });
        this.input.addEventListener("input", () => {
            if (!this._supportsFieldSizing) this._scheduleAutoGrow();
            this._sendTyping();
        });
    }

    _scheduleAutoGrow() {
        if (this._autoGrowFrame) return;
        this._autoGrowFrame = requestAnimationFrame(() => {
            this._autoGrowFrame = null;
            this.input.style.height = "auto";
            const next = Math.min(this.input.scrollHeight, AUTO_GROW_MAX_HEIGHT_PX);
            if (next !== this._lastAutoGrowHeight) {
                this.input.style.height = `${next}px`;
                this._lastAutoGrowHeight = next;
            }
        });
    }

    _resetAutoGrow() {
        if (this._supportsFieldSizing) return;
        cancelAnimationFrame(this._autoGrowFrame);
        this._autoGrowFrame = null;
        this.input.style.height = "auto";
        this._lastAutoGrowHeight = null;
    }

    _initRetry() {
        if (!this.thread) return;
        this.thread.addEventListener("click", (event) => {
            if (event.target.closest(".content-copy-btn")) return;
            const bubble = event.target.closest(".message-bubble.failed");
            if (!bubble) return;
            const clientId = bubble.dataset.clientId;
            const stored = clientId ? this._failedSends.get(clientId) : null;
            if (!stored || !this._socketReady || !this.socket || !this.socket.isOpen()) return;
            this._failedSends.delete(clientId);
            this._sendViaSocket(clientId, stored.content, stored.attachmentUids);
        });
    }

    _sendTyping() {
        if (!this._socketReady || !this.withUid || !this.socket) return;
        const now = Date.now();
        if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
        this._lastTypingSent = now;
        this.socket.send({ type: "typing", receiver_uid: this.withUid });
    }

    _refreshSendButton() {
        if (!this.sendBtn) return;
        const busy = this._uploading;
        this.sendBtn.disabled = busy;
        this.sendBtn.classList.toggle("is-sending", busy);
    }

    _collectAttachments() {
        const hidden = this.form.querySelector('input[name="attachment_uids"]');
        if (!hidden || !hidden.value) return [];
        return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
    }

    _sendViaSocket(retryClientId, retryContent, retryAttachments) {
        const content = retryClientId ? retryContent : (this.input.value || "").trim();
        const attachmentUids = retryClientId ? retryAttachments : this._collectAttachments();
        if (!content && attachmentUids.length === 0) return;
        const clientId = retryClientId || `c${Date.now()}${Math.random().toString(36).slice(2, 8)}`;

        let bubble = retryClientId
            ? this.thread.querySelector(`.message-bubble[data-client-id="${clientId}"]`)
            : null;
        if (bubble) {
            bubble.classList.remove("failed");
            bubble.classList.add("pending");
            const hint = bubble.querySelector(".retry-hint");
            if (hint) hint.remove();
        } else {
            bubble = this._appendOptimisticBubble(content, clientId, attachmentUids.length);
            if (/@ai\s+\S/i.test(content)) this._showAiPending(bubble);
        }

        const ok = !!(this.socket && this.socket.send({
            type: "send",
            receiver_uid: this.withUid,
            content,
            attachment_uids: attachmentUids,
            client_id: clientId,
        }));

        const timeoutId = window.setTimeout(() => this._failPendingSend(clientId), SEND_TIMEOUT_MS);
        this._pendingSends.set(clientId, { content, attachmentUids, timeoutId });
        this._refreshSendButton();

        if (!retryClientId) {
            this.input.value = "";
            this._resetAutoGrow();
            if (this.upload && typeof this.upload.clear === "function") this.upload.clear();
            this.input.focus({ preventScroll: true });
        }

        if (!ok) this._sendViaHttp(clientId, content, attachmentUids);
    }

    async _sendViaHttp(clientId, content, attachmentUids) {
        try {
            const response = await Http.sendForm(this.sendUrl, {
                receiver_uid: this.withUid,
                content,
                attachment_uids: attachmentUids.join(","),
                client_id: clientId,
            }, { silent: true });
            if (response && response.data) this._handleIncoming(response.data);
        } catch {
            this._failPendingSend(clientId);
        }
    }

    _appendOptimisticBubble(content, clientId, attachmentCount) {
        const bubble = this._buildBubble({ content, mine: true, clientId, iso: null, attachmentCount });
        bubble.classList.add("pending");
        this._insertBubble(bubble);
        return bubble;
    }

    _failPendingSend(clientId) {
        const entry = this._pendingSends.get(clientId);
        if (!entry) return;
        clearTimeout(entry.timeoutId);
        this._pendingSends.delete(clientId);
        this._refreshSendButton();
        const bubble = this.thread ? this.thread.querySelector(`.message-bubble[data-client-id="${clientId}"]`) : null;
        if (bubble && !bubble.dataset.msgUid) {
            bubble.classList.remove("pending");
            bubble.classList.add("failed");
            this._failedSends.set(clientId, { content: entry.content, attachmentUids: entry.attachmentUids });
            if (!bubble.querySelector(".retry-hint")) {
                const hint = document.createElement("span");
                hint.className = "retry-hint";
                hint.textContent = "Tap to retry";
                bubble.appendChild(hint);
            }
        }
    }

    _clearPendingSend(clientId) {
        const entry = clientId ? this._pendingSends.get(clientId) : null;
        if (!entry) return;
        clearTimeout(entry.timeoutId);
        this._pendingSends.delete(clientId);
        this._refreshSendButton();
    }

    _buildBubble({ content, mine, clientId, uid, iso, senderRole, attachments, attachmentCount }) {
        const bubble = document.createElement("div");
        bubble.className = `message-bubble ${mine ? "mine" : "theirs"}`;
        bubble.setAttribute("tabindex", "0");
        if (uid) bubble.dataset.msgUid = uid;
        if (clientId) bubble.dataset.clientId = clientId;

        const el = document.createElement("dp-content");
        if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
        el.dataset.source = content || "";
        el.textContent = content || "";
        bubble.appendChild(el);

        const gallery = this._renderAttachments(attachments);
        if (gallery) {
            bubble.appendChild(gallery);
        } else if (attachmentCount > 0) {
            const placeholder = document.createElement("div");
            placeholder.className = "attachment-pending";
            placeholder.textContent = attachmentCount === 1
                ? "Uploading attachment..."
                : `Uploading ${attachmentCount} attachments...`;
            bubble.appendChild(placeholder);
        }

        const timeEl = document.createElement(iso ? "time" : "span");
        timeEl.className = "message-time";
        if (iso) {
            timeEl.setAttribute("datetime", iso);
            timeEl.dataset.dt = "";
            timeEl.dataset.dtMode = "ago";
            if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
        } else {
            timeEl.textContent = "sending...";
        }
        bubble.appendChild(timeEl);

        if (mine) {
            const receipt = document.createElement("span");
            receipt.className = "message-receipt";
            receipt.hidden = true;
            receipt.innerHTML = "&#x2713;&#x2713;";
            bubble.appendChild(receipt);
        } else if (uid) {
            bubble.appendChild(this._reportButton(uid));
        }

        return bubble;
    }

    _reportButton(uid) {
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "message-report-btn report-btn";
        btn.dataset.reportType = "message";
        btn.dataset.reportUid = uid;
        btn.setAttribute("aria-label", "Report this message");
        btn.title = "Report";
        const icon = document.createElement("span");
        icon.className = "icon";
        icon.setAttribute("aria-hidden", "true");
        icon.textContent = "\u{1F6A9}";
        const label = document.createElement("span");
        label.className = "label";
        label.textContent = " Report";
        btn.append(icon, label);
        return btn;
    }

    _contentSource(el) {
        if (!el) return "";
        if (el.dataset && Object.prototype.hasOwnProperty.call(el.dataset, "source")) {
            return el.dataset.source;
        }
        if (el._source != null) return el._source;
        return el.textContent || "";
    }

    _renderAttachments(attachments) {
        if (!attachments || !attachments.length) return null;
        const single = attachments.length === 1;
        const gallery = document.createElement("div");
        gallery.className = `attachment-gallery${single ? " single" : ""}`;
        for (const att of attachments) {
            const item = document.createElement("div");
            item.className = "attachment-gallery-item";
            const filename = att.original_filename || att.filename || "file";
            const mime = att.mime_type || "";
            const isAudio = att.is_audio || mime.startsWith("audio/");
            if (att.is_image) {
                const img = document.createElement("img");
                img.src = (single || !att.thumbnail_url) ? (att.url || att.thumbnail_url) : att.thumbnail_url;
                img.alt = filename;
                img.loading = "lazy";
                img.className = "gallery-thumb";
                img.dataset.lightbox = "";
                img.dataset.full = att.url || att.thumbnail_url || "";
                if (mime) img.dataset.mime = mime;
                item.appendChild(img);
            } else if (att.is_video) {
                const video = document.createElement("video");
                video.src = att.url;
                video.controls = true;
                video.preload = "metadata";
                video.className = "gallery-video";
                item.appendChild(video);
            } else if (isAudio) {
                const audio = document.createElement("audio");
                audio.src = att.url;
                audio.controls = true;
                audio.preload = "metadata";
                audio.className = "gallery-audio";
                item.appendChild(audio);
            } else {
                const link = document.createElement("a");
                link.href = att.url;
                link.target = "_blank";
                link.rel = "noopener";
                link.className = "non-image";
                link.download = filename;
                link.textContent = filename;
                item.appendChild(link);
            }
            gallery.appendChild(item);
        }
        return gallery;
    }

    _insertBubble(bubble) {
        if (!this.thread) return;
        const previous = this._lastBubbleMeta();
        const current = this._bubbleMeta(bubble);
        bubble.classList.toggle("grouped", shouldGroup(previous, current));
        if (this.typingEl && this.typingEl.parentElement === this.thread) {
            this.thread.insertBefore(bubble, this.typingEl);
        } else {
            this.thread.appendChild(bubble);
        }
        this._normalizeDateSeparators();
        this._scrollThreadToEnd();
    }

    _buildDateSeparator(iso) {
        const sep = document.createElement("div");
        sep.className = "date-separator";
        const pill = document.createElement("span");
        pill.textContent = dateLabel(iso);
        sep.appendChild(pill);
        return sep;
    }

    _normalizeDateSeparators() {
        if (!this.thread) return;
        this.thread.querySelectorAll(".date-separator").forEach((el) => el.remove());
        let previousKey = null;
        this.thread.querySelectorAll(".message-bubble").forEach((bubble) => {
            const time = bubble.querySelector(".message-time[datetime], .message-time time[datetime]");
            const iso = time ? time.getAttribute("datetime") : null;
            const key = dateKey(iso);
            if (key && key !== previousKey) {
                this.thread.insertBefore(this._buildDateSeparator(iso), bubble);
                previousKey = key;
            }
        });
    }

    async _connect() {
        const url = await this._resolveWsUrl();
        if (!url) return;
        this.socket = new ChatSocket(url, {
            onReady: (payload) => this.onReady(payload),
            onMessage: (frame) => this.onFrame(frame),
            onClose: () => {
                this._socketReady = false;
                this._noteDisconnected();
            },
        });
        this.socket.connect();
    }

    _wsBaseUrl() {
        if (/^wss?:\/\//i.test(this.wsPath)) return this.wsPath;
        const scheme = location.protocol === "https:" ? "wss:" : "ws:";
        const path = this.wsPath.startsWith("/") ? this.wsPath : `/${this.wsPath}`;
        return `${scheme}//${location.host}${path}`;
    }

    async _resolveWsUrl() {
        const base = this._wsBaseUrl();
        if (this.mode !== "embed" || !this.apiKey || !this.wsTicketUrl) {
            return base;
        }
        try {
            const response = await fetch(this.wsTicketUrl, {
                method: "POST",
                headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
            });
            if (!response.ok) return null;
            const data = await response.json();
            if (!data || !data.ticket) return null;
            const separator = base.includes("?") ? "&" : "?";
            return `${base}${separator}ticket=${encodeURIComponent(data.ticket)}`;
        } catch {
            return null;
        }
    }

    onReady(payload) {
        this._socketReady = true;
        if (payload && payload.user_uid) this.selfUid = payload.user_uid;
        this._noteConnected();
        this._markRead();
        this._syncMissed();
    }

    _ensureConnectionBanner() {
        if (!this.main) return;
        if (!this._connBanner) {
            const banner = document.createElement("div");
            banner.className = "messages-connection-banner";
            banner.hidden = true;
            banner.textContent = "Reconnecting\u2026";
            this._connBanner = banner;
        }
        if (this._connBanner.parentElement !== this.main || this.main.firstChild !== this._connBanner) {
            this.main.insertBefore(this._connBanner, this.main.firstChild || null);
        }
    }

    _noteDisconnected() {
        if (this._disconnectTimer) return;
        this._disconnectTimer = window.setTimeout(() => {
            this._disconnectTimer = null;
            if (!this._socketReady && this._connBanner) this._connBanner.hidden = false;
        }, 900);
    }

    _noteConnected() {
        clearTimeout(this._disconnectTimer);
        this._disconnectTimer = null;
        if (this._connBanner) this._connBanner.hidden = true;
    }

    _syncMissed() {
        if (!this._socketReady || !this.socket) return;
        const since = this._latestStamp() || this._lastSyncAt;
        if (!since) {
            this._lastSyncAt = new Date().toISOString();
            return;
        }
        this.socket.send({
            type: "sync",
            since,
            with_uid: this.withUid || "",
        });
        this._lastSyncAt = new Date().toISOString();
    }

    _latestStamp() {
        if (!this.thread) return null;
        const times = [...this.thread.querySelectorAll(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]")];
        let latest = null;
        for (const el of times) {
            const value = el.getAttribute("datetime");
            if (value && (!latest || value > latest)) latest = value;
        }
        return latest;
    }

    _threadIsActive() {
        if (!this.withUid) return false;
        if (!this._isMobile()) return true;
        return this._activePane === "thread";
    }

    onFrame(frame) {
        switch (frame.type) {
            case "message":
                this._handleIncoming(frame);
                break;
            case "typing":
                if (frame.from_uid === this.withUid) this._showTyping();
                break;
            case "read":
                if (frame.by_uid === this.withUid) this._markReceipts();
                break;
            case "error":
                this._handleError(frame);
                break;
            default:
                break;
        }
    }

    _handleError(frame) {
        if (frame.client_id) this._failPendingSend(frame.client_id);
        if (window.app && window.app.toast) {
            window.app.toast.show(frame.text || "Message not sent.", { type: "error" });
        }
    }

    _handleIncoming(frame) {
        if (frame.sender_uid === this.selfUid && frame.client_id) {
            const pending = this.thread
                ? this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`)
                : null;
            if (pending && !pending.dataset.msgUid) {
                this._reconcilePending(pending, frame);
                return;
            }
        }
        if (frame.uid && this.thread) {
            const existing = this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`);
            if (existing) {
                this._clearPendingSend(frame.client_id);
                const changed = this._applyFrameContent(existing, frame);
                if (changed) this._bumpConversation(frame, frame.sender_uid === this.selfUid);
                return;
            }
        }

        const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
        const inThisThread = this.withUid && partnerUid === this.withUid;
        if (inThisThread) {
            const mine = frame.sender_uid === this.selfUid;
            const bubble = this._buildBubble({
                content: frame.content,
                mine,
                iso: frame.created_at,
                uid: frame.uid,
                senderRole: frame.sender_role,
                attachments: frame.attachments,
            });
            if (frame.ai_pending) this._showAiPending(bubble);
            this._insertBubble(bubble);
            if (!mine && this._socketReady && this._threadIsActive()) {
                this.socket.send({ type: "read", with_uid: this.withUid });
            }
            if (!mine && !this._userAtBottom) this._noteNewMessage();
        }
        this._bumpConversation(frame, frame.sender_uid === this.selfUid);
    }

    _applyFrameContent(bubble, frame) {
        const oldBody = bubble.querySelector("dp-content");
        const previousContent = this._contentSource(oldBody);
        let changed = false;
        if (oldBody && frame.content !== undefined && frame.content !== previousContent) {
            const content = document.createElement("dp-content");
            if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
            content.dataset.source = frame.content || "";
            content.textContent = frame.content || "";
            oldBody.replaceWith(content);
            changed = true;
        }

        const placeholder = bubble.querySelector(".attachment-pending");
        if (placeholder) placeholder.remove();
        if (!bubble.querySelector(".attachment-gallery")) {
            const gallery = this._renderAttachments(frame.attachments);
            if (gallery) {
                const time = bubble.querySelector(".message-time");
                bubble.insertBefore(gallery, time || null);
            }
        }

        if (frame.uid && !bubble.dataset.msgUid) bubble.dataset.msgUid = frame.uid;
        if (frame.uid && !bubble.classList.contains("mine") && !bubble.querySelector(".message-report-btn")) {
            bubble.appendChild(this._reportButton(frame.uid));
        }

        if (frame.ai_pending && !frame.ai_processed) {
            this._showAiPending(bubble);
        } else if (frame.ai_processed) {
            this._clearAiPending(bubble);
            if (this.aiIndicatorEnabled && changed) this._showAiIndicator(bubble);
        }
        return changed;
    }

    _reconcilePending(bubble, frame) {
        this._clearPendingSend(frame.client_id);
        this._failedSends.delete(frame.client_id);
        bubble.classList.remove("pending", "failed");
        bubble.dataset.msgUid = frame.uid;
        const hint = bubble.querySelector(".retry-hint");
        if (hint) hint.remove();

        if (frame.ai_pending) this._showAiPending(bubble);
        this._applyFrameContent(bubble, frame);

        const time = bubble.querySelector(".message-time");
        if (time && frame.created_at) {
            time.setAttribute("datetime", frame.created_at);
            time.dataset.dt = "";
            time.dataset.dtMode = "ago";
            if (window.app && window.app.localTime) window.app.localTime.apply(time);
            else time.textContent = frame.time_ago || "";
        }

        const previousMeta = this._previousMetaBefore(bubble);
        bubble.classList.toggle("grouped", shouldGroup(previousMeta, this._bubbleMeta(bubble)));

        this._normalizeDateSeparators();
        this._bumpConversation(frame, true);
    }

    _showAiPending(bubble) {
        this._clearAiPending(bubble);
        const note = document.createElement("div");
        note.className = "ai-adjusted-note ai-pending-note";
        note.textContent = "Adjusting\u2026";
        bubble.appendChild(note);
    }

    _clearAiPending(bubble) {
        const pending = bubble.querySelector(".ai-pending-note");
        if (pending) pending.remove();
    }

    _showAiIndicator(bubble) {
        this._clearAiPending(bubble);
        const existing = bubble.querySelector(".ai-adjusted-note");
        if (existing) existing.remove();
        const note = document.createElement("div");
        note.className = "ai-adjusted-note";
        note.textContent = "Adjusted by AI";
        bubble.appendChild(note);
        window.setTimeout(() => {
            note.classList.add("fading");
            window.setTimeout(() => note.remove(), 400);
        }, AI_INDICATOR_MS);
    }

    _markReceipts() {
        if (!this.thread) return;
        this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
            el.hidden = false;
        });
    }

    _markRead() {
        if (this._socketReady && this.withUid && this.socket && this._threadIsActive()) {
            this.socket.send({ type: "read", with_uid: this.withUid });
        }
    }

    _showTyping() {
        if (!this.typingEl) return;
        this.typingEl.hidden = false;
        this._scrollThreadToEnd();
        clearTimeout(this._typingHideTimer);
        this._typingHideTimer = window.setTimeout(() => {
            this.typingEl.hidden = true;
        }, TYPING_HIDE_MS);
    }

    _bumpConversation(frame, mine) {
        const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
        const item = this.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
        if (!item) {
            this._refreshConversations();
            return;
        }
        const preview = item.querySelector(".conversation-preview");
        if (preview) preview.textContent = contentRenderer.preview(frame.content, 60) || (frame.attachments && frame.attachments.length ? "Attachment" : "");
        const dot = item.querySelector(".conversation-unread-dot");
        if (dot) dot.hidden = mine || (partnerUid === this.withUid && this._threadIsActive());
        const time = item.querySelector(".conversation-time");
        if (time && frame.created_at) {
            time.setAttribute("datetime", frame.created_at);
            time.dataset.dt = "";
            time.dataset.dtMode = "ago";
            if (window.app && window.app.localTime) window.app.localTime.apply(time);
        }
        const list = item.parentElement;
        if (list && list.firstElementChild !== item) {
            list.insertBefore(item, list.firstElementChild);
        }
    }

    async _refreshConversations() {
        if (this.mode === "embed" || !this.list) return;
        const container = this.list.querySelector(".messages-conversations");
        if (!container) return;
        try {
            const data = await Http.getJson(this.conversationsUrl);
            const conversations = data.conversations || [];
            container.innerHTML = "";
            if (!conversations.length) {
                const empty = document.createElement("div");
                empty.className = "empty-state empty-state-borderless";
                empty.textContent = "No conversations yet";
                container.appendChild(empty);
                return;
            }
            for (const conv of conversations) {
                container.appendChild(this._buildConversationItem(conv));
            }
        } catch {
            /* keep the current list on failure */
        }
    }

    _buildConversationItem(conv) {
        const user = conv.other_user || {};
        const item = document.createElement("a");
        item.href = `/messages?with_uid=${user.uid || ""}`;
        item.className = `conversation-item${user.uid && user.uid === this.withUid ? " active" : ""}`;
        item.dataset.convUid = user.uid || "";
        item.setAttribute("role", "listitem");
        if (user.uid && user.uid === this.withUid) item.setAttribute("aria-current", "true");

        item.appendChild(Avatar.badgeElement(user));

        const info = document.createElement("div");
        info.className = "conversation-info";
        const name = document.createElement("div");
        name.className = "conversation-name";
        name.textContent = user.username || "";
        const preview = document.createElement("div");
        preview.className = "conversation-preview";
        preview.textContent = contentRenderer.preview(conv.last_message, 60);
        info.append(name, preview);
        item.appendChild(info);

        const unread = document.createElement("span");
        unread.className = "conversation-unread-dot";
        unread.hidden = !conv.unread;
        const sr = document.createElement("span");
        sr.className = "sr-only";
        sr.textContent = "Unread messages";
        unread.appendChild(sr);
        item.appendChild(unread);

        const time = document.createElement("span");
        time.className = "conversation-time";
        if (conv.last_message_at) {
            time.setAttribute("datetime", conv.last_message_at);
            time.dataset.dt = "";
            time.dataset.dtMode = "ago";
            if (window.app && window.app.localTime) window.app.localTime.apply(time);
        }
        item.appendChild(time);

        this._bindConversationClick(item);
        return item;
    }

    _scrollThreadToEnd() {
        if (!this.thread || !this._userAtBottom) return;
        this.thread.scrollTop = this.thread.scrollHeight;
    }

    _startScrollWatcher() {
        if (!this.thread) return;
        const onAnyChange = () => {
            if (!this._userAtBottom || this._stabilizePending) return;
            this._stabilizePending = true;
            requestAnimationFrame(() => {
                this._stabilizePending = false;
                this._stabilizeScroll();
            });
        };
        this._scrollWatcher = new MutationObserver(onAnyChange);
        this._scrollWatcher.observe(this.thread, { childList: true, subtree: true });
        this.thread.addEventListener("load", (event) => {
            if (event.target.tagName === "IMG" && this._userAtBottom) onAnyChange();
        }, true);
        onAnyChange();
    }

    _stabilizeScroll() {
        if (!this.thread || !this._userAtBottom) {
            this._stabilizeFrames = 0;
            return;
        }
        if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
            this._stabilizeFrames = 0;
            return;
        }
        this._stabilizeFrames++;
        this.thread.scrollTop = this.thread.scrollHeight;
        const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < 10;
        if (!atBottom) {
            this._stabilizePending = true;
            requestAnimationFrame(() => {
                this._stabilizePending = false;
                this._stabilizeScroll();
            });
        } else {
            this._stabilizeFrames = 0;
        }
    }

    _bindAutoScroll() {
        if (!this.thread) return;
        this.thread.addEventListener("scroll", () => {
            if (this._stabilizePending) return;
            const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
            this._userAtBottom = atBottom;
            if (atBottom) {
                this._newCount = 0;
                this._updateJumpButton();
                this._stabilizeScroll();
            }
            if (this.thread.scrollTop < 48) this._loadOlder();
        }, { passive: true });
    }

    _bindViewport() {
        if (!this.input || !this.form) return;
        const viewport = window.visualViewport;

        const ensureInputVisible = () => {
            const visibleBottom = viewport
                ? viewport.offsetTop + viewport.height
                : window.innerHeight;
            const formRect = this.form.getBoundingClientRect();
            const currentInset = parseInt(this.style.getPropertyValue("--kb-inset"), 10) || 0;
            const delta = formRect.bottom - visibleBottom;
            const newInset = Math.max(0, currentInset + delta);
            if (newInset !== currentInset) {
                this.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
                this._userAtBottom = true;
                this._scrollThreadToEnd();
            }
        };

        if (viewport) {
            let insetTimer = null;
            const onViewportChange = () => {
                cancelAnimationFrame(insetTimer);
                insetTimer = requestAnimationFrame(ensureInputVisible);
            };
            viewport.addEventListener("resize", onViewportChange);
            viewport.addEventListener("scroll", onViewportChange);
            this._viewportListenerCleanup = () => {
                viewport.removeEventListener("resize", onViewportChange);
                viewport.removeEventListener("scroll", onViewportChange);
            };
        }

        this._resizeObserver = new ResizeObserver(() => ensureInputVisible());
        this._resizeObserver.observe(this);

        const io = new IntersectionObserver((entries) => {
            for (const entry of entries) {
                if (!entry.isIntersecting) ensureInputVisible();
            }
        }, { root: null, threshold: [0, 0.5, 1] });
        io.observe(this.form);
        this._viewportIO = io;

        this.input.addEventListener("focus", () => {
            this._userAtBottom = true;
            ensureInputVisible();
            const doScroll = () => {
                this._scrollThreadToEnd();
                if (this._isMobile()) {
                    const retry = (delay) => window.setTimeout(() => {
                        ensureInputVisible();
                        this._scrollThreadToEnd();
                    }, delay);
                    retry(100);
                    retry(350);
                    retry(600);
                }
            };
            requestAnimationFrame(doScroll);
        });

        this.input.addEventListener("blur", (event) => {
            const next = event.relatedTarget;
            if (next && this.form && this.form.contains(next)) return;
            this.style.setProperty("--kb-inset", "0px");
        });

        ensureInputVisible();
    }

    _isMobile() {
        return window.innerWidth <= MOBILE_BREAKPOINT_PX;
    }

    _isCoarse() {
        if (!this._coarseQuery) {
            this._coarseQuery = window.matchMedia("(hover: none) and (pointer: coarse)");
        }
        return this._coarseQuery.matches;
    }

    _applyPane() {
        if (!this.list || !this.main) return;
        this.classList.toggle("show-list", this._isMobile() && this._activePane === "list");
        if (!this._isMobile()) {
            this.list.classList.remove("hide");
            this.main.classList.remove("hide");
            return;
        }
        this.list.classList.toggle("hide", this._activePane !== "list");
        this.main.classList.toggle("hide", this._activePane !== "thread");
    }

    _initMobilePane() {
        if (this.mode === "embed" || !this.list || !this.main) return;
        this._activePane = this.withUid ? "thread" : "list";
        this._applyPane();

        if (this.backBtn) {
            this.backBtn.addEventListener("click", () => {
                if (!this._isMobile()) return;
                this._showList();
            });
        }

        this.list.querySelectorAll(".conversation-item").forEach((item) => {
            this._bindConversationClick(item);
        });

        this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
        this._onMobileChange = () => {
            this._applyPane();
        };
        this._mobileQuery.addEventListener("change", this._onMobileChange);
    }

    _showList() {
        this._activePane = "list";
        this._applyPane();
        const url = "/messages";
        if (window.location.pathname + window.location.search !== url) {
            window.history.replaceState({ withUid: null }, "", url);
        }
    }

    _bindConversationClick(item) {
        if (item.dataset.chatBound) return;
        item.dataset.chatBound = "1";
        item.addEventListener("click", (event) => {
            if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
            const uid = item.dataset.convUid;
            if (!uid) return;
            event.preventDefault();
            this._openConversation(uid, true);
        });
    }

    _bindHistory() {
        if (this.mode === "embed" || this._onPopState) return;
        this._onPopState = () => {
            const params = new URLSearchParams(window.location.search);
            const uid = params.get("with_uid");
            if (uid) this._openConversation(uid, false);
            else this._showList();
        };
        window.addEventListener("popstate", this._onPopState);
    }

    async _openConversation(uid, push) {
        if (!uid || this._opening) return;
        if (uid === this.withUid && this._activePane === "thread" && this.thread) {
            this._activePane = "thread";
            this._applyPane();
            return;
        }
        if (uid === this.withUid && this.thread && this.thread.querySelector(".message-bubble, .messages-empty")) {
            this._activePane = "thread";
            this._applyPane();
            this._markRead();
            const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
            if (push && window.location.pathname + window.location.search !== url) {
                window.history.pushState({ withUid: uid }, "", url);
            }
            if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
            return;
        }
        this._opening = true;
        try {
            const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(uid)}`);
            this._renderConversation(uid, data);
            const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
            if (push) window.history.pushState({ withUid: uid }, "", url);
            else window.history.replaceState({ withUid: uid }, "", url);
            this._activePane = "thread";
            this._applyPane();
            this._markRead();
            this._scrollThreadToEnd();
        } catch {
            window.location.href = `/messages?with_uid=${encodeURIComponent(uid)}`;
        } finally {
            this._opening = false;
        }
    }

    _renderConversation(uid, data) {
        this.withUid = uid;
        this.setAttribute("with-uid", uid);
        this._ensureMainShell(data.other_user || {});
        const receiver = this.form ? this.form.querySelector('input[name="receiver_uid"]') : null;
        if (receiver) receiver.value = uid;
        this._fillHeader(data.other_user || {});
        this._fillThread(data.messages || []);
        this.querySelectorAll(".conversation-item").forEach((item) => {
            const active = item.dataset.convUid === uid;
            item.classList.toggle("active", active);
            if (active) item.setAttribute("aria-current", "true");
            else item.removeAttribute("aria-current");
            const dot = item.querySelector(".conversation-unread-dot");
            if (dot && active) dot.hidden = true;
        });
        this._newCount = 0;
        this._updateJumpButton();
    }

    _ensureMainShell(otherUser) {
        if (this.thread && this.form) return;
        const empty = this.main ? this.main.querySelector(".messages-empty") : null;
        if (empty) empty.remove();
        if (!this.main) {
            this._ensureSkeleton();
            this._adopt();
            this._initComposer();
            this._ensureConnectionBanner();
            return;
        }
        this.main.innerHTML = "";
        this.main.appendChild(this._buildHeader(otherUser));
        this.thread = document.createElement("div");
        this.thread.className = "messages-thread";
        this.thread.setAttribute("role", "log");
        this.thread.setAttribute("aria-label", "Message transcript");
        this.thread.setAttribute("aria-live", "polite");
        this.thread.setAttribute("aria-relevant", "additions");
        this.typingEl = document.createElement("div");
        this.typingEl.className = "typing-indicator";
        this.typingEl.id = "typing-indicator";
        this.typingEl.hidden = true;
        this.typingEl.append(document.createElement("span"), document.createElement("span"), document.createElement("span"));
        this.thread.appendChild(this.typingEl);
        this.form = this._buildComposer(otherUser.uid || this.withUid);
        this.main.append(this.thread, this.form);
        this._adopt();
        this._initComposer();
        this._wireContentEnhancer();
        this._bindAutoScroll();
        this._startScrollWatcher();
        this._ensureJumpButton();
        this._capAttachments();
        this._ensureConnectionBanner();
    }

    _buildHeader(user) {
        const header = document.createElement("div");
        header.className = "messages-main-header";
        const back = document.createElement("button");
        back.type = "button";
        back.className = "messages-back-btn";
        back.id = "messages-back-btn";
        back.setAttribute("aria-label", "Back to conversations");
        back.innerHTML = "&#x2190;";
        back.addEventListener("click", () => {
            if (!this._isMobile()) return;
            this._showList();
        });
        this.backBtn = back;
        header.appendChild(back);
        const info = document.createElement("div");
        info.className = "messages-header-info";
        const title = document.createElement("h3");
        const link = document.createElement("a");
        link.href = `/profile/${encodeURIComponent(user.username || "")}`;
        link.textContent = user.username || "";
        title.appendChild(link);
        const presence = document.createElement("span");
        presence.className = "messages-presence";
        presence.id = "messages-presence";
        presence.setAttribute("role", "status");
        presence.setAttribute("aria-live", "polite");
        presence.setAttribute("data-presence-label", "");
        presence.setAttribute("data-presence-uid", user.uid || "");
        presence.setAttribute("data-presence-last-seen", user.last_seen || "");
        presence.textContent = "offline";
        info.append(title, presence);
        header.appendChild(info);
        return header;
    }

    _fillHeader(user) {
        const header = this.querySelector(".messages-main-header");
        if (!header) return;
        const avatarHost = header.querySelector(".user-avatar-link, .avatar-badge");
        if (avatarHost && user.uid) {
            const badge = Avatar.badgeElement(user);
            const link = document.createElement("a");
            link.href = `/profile/${encodeURIComponent(user.username || "")}`;
            link.className = "user-avatar-link";
            link.appendChild(badge);
            avatarHost.replaceWith(link);
        }
        const nameLink = header.querySelector(".messages-header-info h3 a, .messages-header-info a");
        if (nameLink) {
            nameLink.href = `/profile/${encodeURIComponent(user.username || "")}`;
            nameLink.textContent = user.username || "";
        }
        const presence = header.querySelector("#messages-presence, .messages-presence");
        if (presence) {
            presence.setAttribute("data-presence-uid", user.uid || "");
            presence.setAttribute("data-presence-last-seen", user.last_seen || "");
        }
    }

    _wireContentEnhancer() {
        if (!window.app || !window.app.content) return;
        window.app.content.initEmojiPickers();
        window.app.content.initMentionInputs();
    }

    _buildComposer(receiverUid) {
        const form = document.createElement("form");
        form.className = "messages-input-area";
        form.setAttribute("method", "POST");
        form.setAttribute("action", this.sendUrl);
        form.setAttribute("data-live-form", "");
        const receiverInput = document.createElement("input");
        receiverInput.type = "hidden";
        receiverInput.name = "receiver_uid";
        receiverInput.value = receiverUid || "";
        this.input = document.createElement("textarea");
        this.input.name = "content";
        this.input.rows = 1;
        this.input.maxLength = 2000;
        this.input.autocomplete = "off";
        this.input.placeholder = "Type a message...";
        this.input.setAttribute("aria-label", "Type a message");
        this.input.className = "emoji-picker-target";
        this.input.setAttribute("data-mention", "");
        this.upload = document.createElement("dp-upload");
        this.upload.setAttribute("multiple", "");
        this.upload.setAttribute("paste", "");
        this.upload.setAttribute("max-files", String(this.maxAttachments));
        this.sendBtn = document.createElement("button");
        this.sendBtn.type = "submit";
        this.sendBtn.className = "messages-send-btn";
        this.sendBtn.setAttribute("aria-label", "Send");
        const sendIcon = document.createElement("span");
        sendIcon.className = "send-icon";
        sendIcon.textContent = "\u27A4";
        const sendSpinner = document.createElement("span");
        sendSpinner.className = "send-spinner";
        sendSpinner.setAttribute("aria-hidden", "true");
        this.sendBtn.append(sendIcon, sendSpinner);
        form.append(receiverInput, this.input, this.upload, this.sendBtn);
        return form;
    }

    _fillThread(items) {
        if (!this.thread) return;
        this.thread.querySelectorAll(".message-bubble").forEach((el) => el.remove());
        for (const item of items) {
            const message = item.message || item;
            const bubble = this._buildBubble({
                content: message.content || "",
                mine: !!item.is_mine,
                iso: message.created_at,
                uid: message.uid,
                senderRole: (item.sender && item.sender.role) || "",
                attachments: item.attachments || [],
            });
            if (item.grouped) bubble.classList.add("grouped");
            if (item.is_mine && message.read) {
                const receipt = bubble.querySelector(".message-receipt");
                if (receipt) receipt.hidden = false;
            }
            if (this.typingEl && this.typingEl.parentElement === this.thread) {
                this.thread.insertBefore(bubble, this.typingEl);
            } else {
                this.thread.appendChild(bubble);
            }
        }
        this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
        this._userAtBottom = true;
        this._normalizeDateSeparators();
    }

    _ensureJumpButton() {
        if (!this.main || this._jumpBtn) return;
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "messages-jump-new";
        btn.hidden = true;
        btn.addEventListener("click", () => {
            this._userAtBottom = true;
            this._newCount = 0;
            this._updateJumpButton();
            this._scrollThreadToEnd();
        });
        this.main.appendChild(btn);
        this._jumpBtn = btn;
        this._updateJumpButton();
    }

    _noteNewMessage() {
        this._newCount += 1;
        this._updateJumpButton();
    }

    _updateJumpButton() {
        if (!this._jumpBtn) return;
        if (!this._newCount) {
            this._jumpBtn.hidden = true;
            return;
        }
        this._jumpBtn.hidden = false;
        this._jumpBtn.textContent = this._newCount === 1 ? "New message" : `${this._newCount} new messages`;
    }

    async _loadOlder() {
        if (!this.thread || !this.withUid || this._loadingOlder || !this._hasOlder) return;
        const oldest = this.thread.querySelector(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]");
        const before = oldest ? oldest.getAttribute("datetime") : "";
        if (!before) {
            this._hasOlder = false;
            return;
        }
        this._loadingOlder = true;
        const previousHeight = this.thread.scrollHeight;
        const previousTop = this.thread.scrollTop;
        try {
            const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(this.withUid)}&before=${encodeURIComponent(before)}`);
            const items = data.messages || [];
            if (!items.length) {
                this._hasOlder = false;
                return;
            }
            const first = this.thread.querySelector(".message-bubble");
            const fragment = document.createDocumentFragment();
            for (const item of items) {
                const message = item.message || item;
                if (message.uid && this.thread.querySelector(`.message-bubble[data-msg-uid="${message.uid}"]`)) continue;
                const bubble = this._buildBubble({
                    content: message.content || "",
                    mine: !!item.is_mine,
                    iso: message.created_at,
                    uid: message.uid,
                    senderRole: (item.sender && item.sender.role) || "",
                    attachments: item.attachments || [],
                });
                if (item.grouped) bubble.classList.add("grouped");
                fragment.appendChild(bubble);
            }
            if (fragment.childNodes.length) {
                this.thread.insertBefore(fragment, first || this.typingEl || null);
            }
            this._normalizeDateSeparators();
            this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
            this.thread.scrollTop = previousTop + (this.thread.scrollHeight - previousHeight);
        } catch {
            this._hasOlder = false;
        } finally {
            this._loadingOlder = false;
        }
    }

    _initSearch() {
        if (this.mode === "embed" || !this.searchInput) return;
        const wrap = this.searchInput.parentElement;
        const dropdown = document.createElement("div");
        dropdown.className = "search-dropdown";
        dropdown.setAttribute("role", "listbox");
        wrap.appendChild(dropdown);

        const hide = () => {
            DomUtils.hide(dropdown);
            nav.closed();
        };

        const nav = new ListNav(this.searchInput, dropdown, {
            itemSelector: ".search-dropdown-item",
            activeClass: "active",
            chooseOnTab: false,
            isOpen: () => DomUtils.isShown(dropdown),
            onChoose: (item) => {
                hide();
                const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
                if (uid) this._openConversation(uid, true);
                else window.location.href = item.href;
            },
            onEscape: hide,
        });

        let debounceTimer = null;
        this.searchInput.addEventListener("input", () => {
            clearTimeout(debounceTimer);
            const q = this.searchInput.value.trim();
            if (q.length < 1) {
                dropdown.innerHTML = "";
                hide();
                return;
            }
            debounceTimer = window.setTimeout(async () => {
                try {
                    const data = await Http.getJson(`${this.searchUrl}?q=${encodeURIComponent(q)}`);
                    const results = data.results || [];
                    if (!results.length) {
                        hide();
                        return;
                    }
                    dropdown.innerHTML = "";
                    for (const r of results) {
                        const item = document.createElement("a");
                        item.className = "search-dropdown-item";
                        item.href = `/messages?with_uid=${r.uid}`;
                        const label = document.createElement("span");
                        label.textContent = r.username;
                        item.append(Avatar.imgElement(r.username), label);
                        dropdown.appendChild(item);
                    }
                    DomUtils.show(dropdown);
                    nav.opened();
                } catch {
                    hide();
                }
            }, SEARCH_DEBOUNCE_MS);
        });

        dropdown.addEventListener("click", (event) => {
            const item = event.target.closest(".search-dropdown-item");
            if (!item) return;
            event.preventDefault();
            const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
            hide();
            if (uid) this._openConversation(uid, true);
        });

        document.addEventListener("click", (event) => {
            if (!wrap.contains(event.target)) hide();
        });
    }

    _initPresence() {
        if (this.mode !== "embed") return;
        if (window.app && window.app.presence) return;
        this._presence = new PresenceManager(new PubSubClient(), this);
    }
}

customElements.define("dp-chat", AppChat);
