;;; meshmonitor-chat.el --- Chat client for MeshMonitor (Meshtastic) -*- lexical-binding: t; -*-

;; Copyright (C) 2026  Andros Fenollosa

;; Author: Andros Fenollosa <hi@andros.dev>
;; Maintainer: Andros Fenollosa <hi@andros.dev>
;; Package-Version: 20260409.802
;; Package-Revision: 862d8fed090d
;; Package-Requires: ((emacs "28.1"))
;; Keywords: comm
;; URL: https://git.andros.dev/andros/meshmonitor-chat.el

;; SPDX-License-Identifier: GPL-3.0-or-later

;; This file is NOT part of GNU Emacs.

;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.

;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;; General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see
;; <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Emacs chat client for MeshMonitor, a web-based Meshtastic network
;; monitor.  Provides channel and direct message chat buffers.
;;
;; Configure `meshmonitor-chat-host', `meshmonitor-chat-port' and
;; `meshmonitor-chat-token' in your init file.  Then:
;;
;;   M-x meshmonitor-chat                 - welcome screen
;;   M-x meshmonitor-chat-channels        - list channels
;;   M-x meshmonitor-chat-nodes           - list nodes by hops
;;   M-x meshmonitor-chat-unread          - nodes with unread DMs
;;   M-x meshmonitor-chat-direct-messages - list DM conversations

;;; Code:

(require 'json)
(require 'url)
(require 'url-http)
(require 'cl-lib)
(require 'notifications)
(require 'ring)
(require 'seq)

;;;; Customization

(defgroup meshmonitor-chat nil
  "Chat client for MeshMonitor (Meshtastic)."
  :group 'communication
  :prefix "meshmonitor-chat-")

(defcustom meshmonitor-chat-host ""
  "MeshMonitor server hostname or IP address."
  :type 'string)

(defcustom meshmonitor-chat-port 3000
  "MeshMonitor server port."
  :type 'integer)

(defcustom meshmonitor-chat-use-tls nil
  "Non-nil means use HTTPS instead of HTTP."
  :type 'boolean)

(defcustom meshmonitor-chat-token nil
  "Bearer token for MeshMonitor API.
When set, username/password login is skipped."
  :type '(choice (const nil) string))

(defcustom meshmonitor-chat-username "admin"
  "Username for MeshMonitor authentication."
  :type 'string)

(defcustom meshmonitor-chat-password ""
  "Password for MeshMonitor authentication."
  :type 'string)

(defcustom meshmonitor-chat-login-endpoint "/auth/login"
  "API endpoint path for login."
  :type 'string)

(defcustom meshmonitor-chat-poll-interval 10
  "Seconds between polling for new messages."
  :type 'integer)

(defcustom meshmonitor-chat-notify t
  "Non-nil means show desktop notifications for new messages."
  :type 'boolean)

(defcustom meshmonitor-chat-message-limit 50
  "Number of messages to fetch per request."
  :type 'integer)

(defcustom meshmonitor-chat-timestamp-format "%H:%M"
  "Format string for message timestamps."
  :type 'string)

;;;; Faces

(defface meshmonitor-chat-timestamp-face
  '((t :foreground "gray50"))
  "Face for message timestamps.")

(defface meshmonitor-chat-nick-self-face
  '((t :foreground "sea green" :weight bold))
  "Face for own nick in messages.")

(defface meshmonitor-chat-nick-other-face
  '((t :foreground "dodger blue" :weight bold))
  "Face for other nicks in messages.")

(defface meshmonitor-chat-prompt-face
  '((t :foreground "cyan" :weight bold))
  "Face for the input prompt.")

(defface meshmonitor-chat-system-face
  '((t :foreground "gray60" :slant italic))
  "Face for system messages.")

(defface meshmonitor-chat-delivery-pending-face
  '((t :foreground "gray50"))
  "Face for pending delivery icon.")

(defface meshmonitor-chat-delivery-confirmed-face
  '((t :foreground "green"))
  "Face for confirmed delivery icon.")

(defface meshmonitor-chat-delivery-failed-face
  '((t :foreground "red"))
  "Face for failed delivery icon.")

;;;; Internal state

(defvar meshmonitor-chat--base-url nil
  "Base URL for the MeshMonitor instance.")

(defvar meshmonitor-chat--auth-token nil
  "Bearer token for API requests.")

(defvar meshmonitor-chat--csrf-token nil
  "CSRF token for session-authenticated requests.")

(defvar meshmonitor-chat--connected nil
  "Non-nil when connected to MeshMonitor.")

(defvar meshmonitor-chat--nodes (make-hash-table :test 'equal)
  "Hash table mapping node IDs and nums to node alists.")

(defvar meshmonitor-chat--my-node-id nil
  "Node ID of the connected MeshMonitor node.")

(defvar meshmonitor-chat--my-node-num nil
  "Node number of the connected MeshMonitor node.")

(defvar meshmonitor-chat--channels nil
  "Cached list of channel alists from the API.")

(defvar meshmonitor-chat--chat-buffers nil
  "Alist of ((TYPE . TARGET) . BUFFER) for open chat buffers.")

(defvar meshmonitor-chat--read-timestamps (make-hash-table :test 'equal)
  "Hash table mapping node IDs to last-read Unix timestamp.")

(defvar meshmonitor-chat--poll-timer nil
  "Timer for periodic message polling.")

(defvar meshmonitor-chat--last-poll-time nil
  "Timestamp of the last successful poll, for gap detection.")

;;;; Buffer-local variables

(defvar-local meshmonitor-chat--target nil
  "Target for this buffer: channel number or node ID string.")

(defvar-local meshmonitor-chat--target-type nil
  "Type of target: symbol `channel' or `dm'.")

(defvar-local meshmonitor-chat--prompt-start nil
  "Marker at the beginning of the prompt.")

(defvar-local meshmonitor-chat--prompt-end nil
  "Marker at the end of the prompt.")

(defvar-local meshmonitor-chat--last-timestamp nil
  "Unix timestamp of the last rendered message.")

(defvar-local meshmonitor-chat--seen-ids nil
  "Hash table of rendered message IDs for deduplication.")

(defvar-local meshmonitor-chat--input-ring nil
  "Ring holding previous input strings.")

(defvar-local meshmonitor-chat--input-ring-index 0
  "Current index in the input ring.")

(defvar-local meshmonitor-chat--pending-deliveries nil
  "Alist of (REQUEST-ID . MARKER) for pending delivery icons.")

(defvar-local meshmonitor-chat--reply-to nil
  "Cons of (REQUEST-ID . SENDER-NAME) for the message being replied to.")

(defvar-local meshmonitor-chat--reactions nil
  "Hash table mapping requestId to list of (EMOJI . SENDER) pairs.")

;;;; Timestamp helpers

(defun meshmonitor-chat--parse-timestamp (ts)
  "Convert TS to a Unix timestamp in seconds.
Handles millisecond timestamps from MeshMonitor API."
  (cond
   ((numberp ts)
    (if (> ts 9999999999) (/ ts 1000) ts))
   ((stringp ts)
    (condition-case nil
        (truncate (float-time (date-to-time ts)))
      (error 0)))
   (t 0)))

(defun meshmonitor-chat--format-time (ts)
  "Format timestamp TS for display."
  (condition-case nil
      (format-time-string
       meshmonitor-chat-timestamp-format
       (seconds-to-time (meshmonitor-chat--parse-timestamp ts)))
    (error "??:??")))

;;;; HTTP helpers

(defun meshmonitor-chat--build-url (endpoint)
  "Build full URL for API ENDPOINT."
  (concat meshmonitor-chat--base-url endpoint))

(defun meshmonitor-chat--request (method endpoint &optional data callback)
  "Make an HTTP request with METHOD to ENDPOINT.
DATA is an alist to send as JSON body.
When CALLBACK is non-nil, make an async request and call
CALLBACK with (STATUS-CODE . JSON-BODY) or nil on error.
When CALLBACK is nil, make a synchronous request and return
the same cons cell."
  (let ((url-request-method method)
        (url-cookie-confirmation nil)
        (url-request-extra-headers
         (append '(("Content-Type" . "application/json")
                   ("Accept" . "application/json"))
                 (when meshmonitor-chat--auth-token
                   `(("Authorization"
                      . ,(concat "Bearer "
                                 meshmonitor-chat--auth-token))))))
        (url-request-data
         (when data
           (encode-coding-string (json-encode data) 'utf-8)))
        (full-url (meshmonitor-chat--build-url endpoint)))
    (if callback
        (url-retrieve
         full-url
         (lambda (status cb)
           (let ((result nil)
                 (resp-buf (current-buffer)))
             (unwind-protect
                 (progn
                   (unless (plist-get status :error)
                     (setq result
                           (meshmonitor-chat--parse-response)))
                   (funcall cb result))
               (when (buffer-live-p resp-buf)
                 (kill-buffer resp-buf)))))
         (list callback) t)
      (let ((buf (url-retrieve-synchronously full-url t nil 15)))
        (when buf
          (unwind-protect
              (with-current-buffer buf
                (meshmonitor-chat--parse-response))
            (kill-buffer buf)))))))

(defun meshmonitor-chat--parse-response ()
  "Parse HTTP response in current buffer.
Return (STATUS-CODE . JSON-BODY) or (STATUS-CODE . nil)."
  (goto-char (point-min))
  (let ((status-code 0))
    (when (re-search-forward "HTTP/[0-9.]+ \\([0-9]+\\)" nil t)
      (setq status-code (string-to-number (match-string 1))))
    (goto-char (point-min))
    (if (re-search-forward "\r?\n\r?\n" nil t)
        (condition-case nil
            (progn
              ;; url.el returns a unibyte buffer; convert to
              ;; multibyte so json-read decodes UTF-8 correctly.
              (set-buffer-multibyte t)
              (let ((json-object-type 'alist)
                    (json-array-type 'list)
                    (json-key-type 'symbol))
                (cons status-code (json-read))))
          (error (cons status-code nil)))
      (cons status-code nil))))

;;;; Authentication and connection

(defun meshmonitor-chat--login ()
  "Login to MeshMonitor using configured credentials.
Return non-nil on success."
  (let ((scheme (if meshmonitor-chat-use-tls "https" "http")))
    (setq meshmonitor-chat--base-url
          (format "%s://%s:%d" scheme
                  meshmonitor-chat-host
                  meshmonitor-chat-port)))
  (cond
   (meshmonitor-chat-token
    (setq meshmonitor-chat--auth-token meshmonitor-chat-token)
    t)
   (t
    (let ((result (meshmonitor-chat--request
                   "POST" meshmonitor-chat-login-endpoint
                   `((username . ,meshmonitor-chat-username)
                     (password . ,meshmonitor-chat-password)))))
      (when result
        (let ((body (cdr result)))
          (when body
            (let ((token (or (alist-get 'token body)
                             (alist-get 'accessToken body))))
              (when token
                (setq meshmonitor-chat--auth-token token)))))
        (< (car result) 400))))))

(defun meshmonitor-chat--ensure-connected ()
  "Ensure connection to MeshMonitor, auto-connecting if needed."
  (unless meshmonitor-chat--connected
    (when (string-empty-p meshmonitor-chat-host)
      (user-error "Set `meshmonitor-chat-host' in your init file"))
    (unless (meshmonitor-chat--login)
      (user-error "MeshMonitor: login failed at %s"
                  meshmonitor-chat--base-url))
    (setq meshmonitor-chat--connected t)
    ;; Fetch status (sync) for our node identity.
    (let ((info (meshmonitor-chat--fetch-status-sync)))
      (when info
        (let ((status (meshmonitor-chat--normalize-status
                       (cdr info))))
          (setq meshmonitor-chat--my-node-id
                (alist-get 'node-id status))
          (setq meshmonitor-chat--my-node-num
                (alist-get 'node-num status)))))
    ;; Fetch nodes (sync) for name resolution.
    (meshmonitor-chat--fetch-nodes-sync)
    ;; Fetch channels (sync).
    (meshmonitor-chat--fetch-channels-sync)
    ;; Start polling.
    (meshmonitor-chat--start-polling)
    (message "MeshMonitor: connected to %s" meshmonitor-chat-host))
  t)

;;;; Node resolution

(defun meshmonitor-chat--process-nodes (data)
  "Process and cache node DATA list."
  (when (listp data)
    (clrhash meshmonitor-chat--nodes)
    (dolist (node data)
      (let ((num (alist-get 'nodeNum node))
            (id (alist-get 'nodeId node)))
        (when num
          (puthash (number-to-string num) node
                   meshmonitor-chat--nodes))
        (when id
          (puthash id node meshmonitor-chat--nodes))))))

(defun meshmonitor-chat--fetch-nodes-sync ()
  "Fetch and cache nodes synchronously."
  (let ((result (meshmonitor-chat--request "GET" "/api/v1/nodes")))
    (when result
      (meshmonitor-chat--process-nodes
       (alist-get 'data (cdr result))))))

(defun meshmonitor-chat--fetch-nodes (&optional callback)
  "Fetch and cache nodes asynchronously.
Call CALLBACK with no arguments when done."
  (meshmonitor-chat--request
   "GET" "/api/v1/nodes" nil
   (lambda (result)
     (when result
       (meshmonitor-chat--process-nodes
        (alist-get 'data (cdr result))))
     (when callback (funcall callback)))))

(defun meshmonitor-chat--node-name (id)
  "Return display name for node ID.
Falls back to ID itself when no name is cached."
  (let* ((key (if (numberp id) (number-to-string id) (or id "")))
         (node (gethash key meshmonitor-chat--nodes)))
    (if node
        (or (alist-get 'longName node)
            (alist-get 'shortName node)
            key)
      key)))

;;;; Channel helpers

(defun meshmonitor-chat--fetch-channels-sync ()
  "Fetch and cache channels synchronously."
  (let ((result (meshmonitor-chat--request
                 "GET" "/api/v1/channels")))
    (when result
      (setq meshmonitor-chat--channels
            (alist-get 'data (cdr result))))))

(defun meshmonitor-chat--fetch-channels (&optional callback)
  "Fetch channels asynchronously.
Call CALLBACK with the channel list when done."
  (meshmonitor-chat--request
   "GET" "/api/v1/channels" nil
   (lambda (result)
     (when result
       (setq meshmonitor-chat--channels
             (alist-get 'data (cdr result))))
     (when callback
       (funcall callback meshmonitor-chat--channels)))))

(defun meshmonitor-chat--channel-name (id)
  "Return display name for channel ID."
  (let ((ch (seq-find (lambda (c) (equal (alist-get 'id c) id))
                      meshmonitor-chat--channels)))
    (if ch
        (or (let ((name (alist-get 'name ch)))
              (and name (not (string-empty-p name)) name))
            (format "%d" id))
      (format "%d" id))))

;;;; API wrappers

(defun meshmonitor-chat--api-messages (params callback)
  "Fetch messages with query PARAMS alist.
Call CALLBACK with (STATUS . BODY)."
  (let ((query (mapconcat
                (lambda (p)
                  (format "%s=%s"
                          (url-hexify-string (symbol-name (car p)))
                          (url-hexify-string (format "%s" (cdr p)))))
                params "&")))
    (meshmonitor-chat--request
     "GET" (concat "/api/v1/messages?" query) nil callback)))

(defun meshmonitor-chat--api-send (text &optional channel to-node
                                             reply-id callback)
  "Send TEXT message to CHANNEL or TO-NODE.
REPLY-ID is the requestId of the message being replied to.
Call CALLBACK with (STATUS . BODY)."
  (let ((data `((text . ,text))))
    (when channel (push `(channel . ,channel) data))
    (when to-node (push `(toNodeId . ,to-node) data))
    (when reply-id (push `(replyId . ,reply-id) data))
    (meshmonitor-chat--request
     "POST" "/api/v1/messages" data callback)))

;;;; Session-authenticated requests (for internal API)

(defun meshmonitor-chat--ensure-session ()
  "Ensure we have a session cookie and CSRF token.
Logs in with username/password if needed."
  (unless meshmonitor-chat--csrf-token
    (when (and (not (string-empty-p meshmonitor-chat-username))
               (not (string-empty-p meshmonitor-chat-password)))
      ;; Login to get session cookie (url.el stores it automatically).
      (let ((url-cookie-confirmation nil))
        (meshmonitor-chat--request
         "POST" meshmonitor-chat-login-endpoint
         `((username . ,meshmonitor-chat-username)
           (password . ,meshmonitor-chat-password))))
      ;; Fetch CSRF token.
      (let ((result (meshmonitor-chat--request
                     "GET" "/api/csrf-token")))
        (when result
          (let ((token (alist-get 'csrfToken (cdr result))))
            (when token
              (setq meshmonitor-chat--csrf-token token))))))))

(defun meshmonitor-chat--session-request (method endpoint
                                                  &optional data
                                                  callback)
  "Make a session-authenticated request with METHOD to ENDPOINT.
Includes CSRF token for POST requests.
DATA is an alist for the JSON body.
CALLBACK works like `meshmonitor-chat--request'."
  (meshmonitor-chat--ensure-session)
  (let ((url-request-method method)
        (url-cookie-confirmation nil)
        (url-request-extra-headers
         (append '(("Content-Type" . "application/json")
                   ("Accept" . "application/json"))
                 (when meshmonitor-chat--csrf-token
                   `(("X-CSRF-Token"
                      . ,meshmonitor-chat--csrf-token)))))
        (url-request-data
         (when data
           (encode-coding-string (json-encode data) 'utf-8)))
        (full-url (meshmonitor-chat--build-url endpoint)))
    (if callback
        (url-retrieve
         full-url
         (lambda (status cb)
           (let ((result nil)
                 (resp-buf (current-buffer)))
             (unwind-protect
                 (progn
                   (unless (plist-get status :error)
                     (setq result
                           (meshmonitor-chat--parse-response)))
                   (funcall cb result))
               (when (buffer-live-p resp-buf)
                 (kill-buffer resp-buf)))))
         (list callback) t)
      (let ((buf (url-retrieve-synchronously full-url t nil 15)))
        (when buf
          (unwind-protect
              (with-current-buffer buf
                (meshmonitor-chat--parse-response))
            (kill-buffer buf)))))))

;;;; Node actions

(defun meshmonitor-chat--node-has-pkc-p (node-id)
  "Return non-nil if NODE-ID has exchanged encryption keys."
  (let* ((node (gethash node-id meshmonitor-chat--nodes)))
    (and node
         (meshmonitor-chat--json-true-p
          (alist-get 'hasPKC node)))))

(defun meshmonitor-chat-traceroute ()
  "Send a traceroute to the node at point."
  (interactive)
  (let ((node-id (meshmonitor-chat--get-node-id-at-point)))
    (if node-id
        (progn
          (message "MeshMonitor: traceroute to %s..."
                   (meshmonitor-chat--node-name node-id))
          (meshmonitor-chat--session-request
           "POST" "/api/traceroute"
           `((destination . ,node-id))
           (lambda (result)
             (if (and result (< (car result) 400))
                 (message "MeshMonitor: traceroute sent to %s"
                          (meshmonitor-chat--node-name node-id))
               (message "MeshMonitor: traceroute failed (HTTP %s)"
                        (if result (car result) "timeout"))))))
      (user-error "No node at point"))))

(defun meshmonitor-chat-request-position ()
  "Request position from the node at point."
  (interactive)
  (let ((node-id (meshmonitor-chat--get-node-id-at-point)))
    (if node-id
        (progn
          (message "MeshMonitor: requesting position from %s..."
                   (meshmonitor-chat--node-name node-id))
          (meshmonitor-chat--session-request
           "POST" "/api/position/request"
           `((destination . ,node-id))
           (lambda (result)
             (if (and result (< (car result) 400))
                 (message "MeshMonitor: position requested from %s"
                          (meshmonitor-chat--node-name node-id))
               (message "MeshMonitor: position request failed (HTTP %s)"
                        (if result (car result) "timeout"))))))
      (user-error "No node at point"))))

(defun meshmonitor-chat--get-node-id-at-point ()
  "Get node ID from the current tabulated-list entry."
  (let ((entry (tabulated-list-get-entry)))
    (when entry
      ;; Node ID is in column index 2 for node list, or column 1 for DM/unread.
      (or (and (> (length entry) 2) (aref entry 2))
          (and (> (length entry) 1) (aref entry 1))))))

;;;; Delivery icons

(defun meshmonitor-chat--delivery-icon (state)
  "Return propertized delivery icon string for STATE."
  (let ((icon (pcase state
                ('confirmed "✓")
                ('failed "✗")
                (_ "·")))
        (face (pcase state
                ('confirmed 'meshmonitor-chat-delivery-confirmed-face)
                ('failed 'meshmonitor-chat-delivery-failed-face)
                (_ 'meshmonitor-chat-delivery-pending-face))))
    (propertize icon 'face face 'read-only t 'rear-nonsticky t)))

(defun meshmonitor-chat--update-delivery-icon (request-id state)
  "Update delivery icon for REQUEST-ID to STATE in current buffer."
  (let ((entry (assoc request-id meshmonitor-chat--pending-deliveries)))
    (when entry
      (let ((marker (cdr entry))
            (inhibit-read-only t))
        (when (and marker (marker-buffer marker))
          (save-excursion
            (goto-char marker)
            (delete-char 1)
            (insert (meshmonitor-chat--delivery-icon state))
            (set-marker marker (1- (point))))))
      (unless (eq state 'pending)
        (setq meshmonitor-chat--pending-deliveries
              (delq entry meshmonitor-chat--pending-deliveries))))))

(defun meshmonitor-chat--check-delivery (msg buffer)
  "Check if MSG confirms delivery of a pending message in BUFFER."
  (when (buffer-live-p buffer)
    (let ((req-id (alist-get 'requestId msg)))
      (when req-id
        (with-current-buffer buffer
          (when (assoc req-id meshmonitor-chat--pending-deliveries)
            (let ((state (cond
                          ((equal (alist-get 'ackFailed msg) 1)
                           'failed)
                          ((member (alist-get 'deliveryState msg)
                                   '("confirmed" "delivered"))
                           'confirmed)
                          (t nil))))
              (when state
                (meshmonitor-chat--update-delivery-icon
                 req-id state)
                (let ((msg-id (alist-get 'id msg)))
                  (when msg-id
                    (puthash msg-id t
                             meshmonitor-chat--seen-ids)))))))))))

;;;; Chat mode

(defvar meshmonitor-chat-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET") #'meshmonitor-chat-send-input)
    (define-key map (kbd "M-p") #'meshmonitor-chat-previous-input)
    (define-key map (kbd "M-n") #'meshmonitor-chat-next-input)
    (define-key map (kbd "C-c C-r") #'meshmonitor-chat-reply)
    (define-key map (kbd "C-c C-s") #'meshmonitor-chat-resend)
    (define-key map (kbd "C-c C-e") #'meshmonitor-chat-react)
    (define-key map (kbd "C-c C-k") #'meshmonitor-chat-cancel-reply)
    (define-key map (kbd "C-c C-d") #'meshmonitor-chat-dm-at-point)
    (define-key map (kbd "C-c C-l") #'meshmonitor-chat-refresh-chat)
    map)
  "Keymap for `meshmonitor-chat-mode'.")

(define-derived-mode meshmonitor-chat-mode fundamental-mode "MeshChat"
  "Major mode for MeshMonitor chat buffers.
Provides an input prompt at the bottom with message history above."
  :group 'meshmonitor-chat
  (setq-local meshmonitor-chat--prompt-start (make-marker))
  (setq-local meshmonitor-chat--prompt-end (make-marker))
  (setq-local meshmonitor-chat--input-ring (make-ring 64))
  (setq-local meshmonitor-chat--input-ring-index 0)
  (setq-local meshmonitor-chat--seen-ids
              (make-hash-table :test 'equal))
  (setq-local meshmonitor-chat--pending-deliveries nil)
  (setq-local meshmonitor-chat--reactions
              (make-hash-table :test 'equal))
  (setq-local meshmonitor-chat--last-timestamp nil)
  (setq mode-line-process
        '(" " (:eval (meshmonitor-chat--mode-line-status))))
  (add-hook 'post-command-hook #'force-mode-line-update nil t))

(defvar meshmonitor-chat--max-message-bytes 600
  "Maximum message size in bytes (3 parts x ~200 bytes).")

(defun meshmonitor-chat--input-byte-length ()
  "Return the byte length of the current input text."
  (if (and meshmonitor-chat--prompt-end
           (marker-position meshmonitor-chat--prompt-end))
      (string-bytes
       (buffer-substring-no-properties
        meshmonitor-chat--prompt-end (point-max)))
    0))

(defun meshmonitor-chat--mode-line-status ()
  "Return mode-line status string with connection and input size."
  (let* ((conn (if meshmonitor-chat--connected "[on]" "[off]"))
         (len (meshmonitor-chat--input-byte-length)))
    (if (> len 0)
        (let ((face (cond
                     ((> len meshmonitor-chat--max-message-bytes)
                      'error)
                     ((> len 200) 'warning)
                     (t nil))))
          (format "%s %s"
                  conn
                  (if face
                      (propertize (format "[%d/%d B]" len
                                         meshmonitor-chat--max-message-bytes)
                                 'face face)
                    (format "[%d B]" len))))
      conn)))

(defun meshmonitor-chat--prompt-string ()
  "Return the prompt string for the current buffer."
  (let ((base (pcase meshmonitor-chat--target-type
                ('channel (format "#%s> "
                                  (meshmonitor-chat--channel-name
                                   meshmonitor-chat--target)))
                ('dm (format "%s> "
                             (meshmonitor-chat--node-name
                              meshmonitor-chat--target)))
                (_ "MeshMonitor> "))))
    (if meshmonitor-chat--reply-to
        (format "[reply %s] %s"
                (cdr meshmonitor-chat--reply-to) base)
      base)))

(defun meshmonitor-chat--setup-prompt ()
  "Insert the input prompt at the end of the current buffer."
  (goto-char (point-max))
  (let ((start (point))
        (txt (meshmonitor-chat--prompt-string)))
    (insert (propertize txt
                        'face 'meshmonitor-chat-prompt-face
                        'read-only t
                        'front-sticky t
                        'rear-nonsticky t))
    (set-marker meshmonitor-chat--prompt-start start)
    (set-marker meshmonitor-chat--prompt-end (point))))

(defun meshmonitor-chat--refresh-prompt ()
  "Refresh the prompt text, preserving input."
  (let ((inhibit-read-only t)
        (input (buffer-substring-no-properties
                meshmonitor-chat--prompt-end (point-max))))
    (save-excursion
      (delete-region meshmonitor-chat--prompt-start (point-max))
      (goto-char meshmonitor-chat--prompt-start)
      (let ((txt (meshmonitor-chat--prompt-string)))
        (insert (propertize txt
                            'face 'meshmonitor-chat-prompt-face
                            'read-only t
                            'front-sticky t
                            'rear-nonsticky t))
        (set-marker meshmonitor-chat--prompt-end (point)))
      (insert input))))

;;;; Message rendering

(defun meshmonitor-chat--insert-at-prompt (buffer fn)
  "In BUFFER, execute FN at the prompt insertion point.
Handles marker manipulation and cursor restoration."
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (let ((inhibit-read-only t)
            (at-end (>= (point) meshmonitor-chat--prompt-end)))
        (save-excursion
          (set-marker-insertion-type
           meshmonitor-chat--prompt-start t)
          (set-marker-insertion-type
           meshmonitor-chat--prompt-end t)
          (goto-char meshmonitor-chat--prompt-start)
          (funcall fn)
          (set-marker-insertion-type
           meshmonitor-chat--prompt-start nil)
          (set-marker-insertion-type
           meshmonitor-chat--prompt-end nil))
        (when at-end
          (goto-char meshmonitor-chat--prompt-end))))))

(defun meshmonitor-chat--insert-msg (buffer ts sender text
                                            &optional selfp sysp
                                            request-id from-id
                                            reply-to-name)
  "Insert a chat message into BUFFER.
TS is the timestamp, SENDER the display name, TEXT the content.
SELFP non-nil marks the message as from the local node.
SYSP non-nil renders a system notification instead.
REQUEST-ID is stored as text property for reply support.
FROM-ID is the sender node ID for opening DMs.
REPLY-TO-NAME shows a reply indicator when non-nil."
  (meshmonitor-chat--insert-at-prompt
   buffer
   (lambda ()
     (insert
      (propertize
       (if sysp
           (format "*** %s\n" text)
         (concat
          (propertize
           (format "[%s] "
                   (if ts
                       (meshmonitor-chat--format-time ts)
                     "--:--"))
           'face 'meshmonitor-chat-timestamp-face)
          (propertize
           (format "<%s> " sender)
           'face (if selfp
                     'meshmonitor-chat-nick-self-face
                   'meshmonitor-chat-nick-other-face))
          (when reply-to-name
            (propertize (format "↩ %s: " reply-to-name)
                        'face 'meshmonitor-chat-system-face))
          text "\n"))
       'read-only t
       'rear-nonsticky t
       'front-sticky t
       'face (when sysp 'meshmonitor-chat-system-face)
       'meshmonitor-chat-msg-text text
       'meshmonitor-chat-request-id request-id
       'meshmonitor-chat-sender sender
       'meshmonitor-chat-from-id from-id)))))

(defun meshmonitor-chat--insert-sent-msg (buffer ts sender text
                                                 delivery
                                                 &optional request-id)
  "Insert a self message into BUFFER with DELIVERY state icon.
TS is the timestamp, SENDER the display name, TEXT the content.
DELIVERY is a symbol: `pending', `confirmed' or `failed'.
REQUEST-ID enables delivery tracking when DELIVERY is `pending'."
  (meshmonitor-chat--insert-at-prompt
   buffer
   (lambda ()
     (insert
      (propertize
       (concat
        (propertize
         (format "[%s] "
                 (if ts
                     (meshmonitor-chat--format-time ts)
                   "--:--"))
         'face 'meshmonitor-chat-timestamp-face)
        (propertize (format "<%s> " sender)
                    'face 'meshmonitor-chat-nick-self-face)
        text " ")
       'read-only t 'rear-nonsticky t 'front-sticky t
       'meshmonitor-chat-msg-text text))
     (let ((icon-pos (point)))
       (insert (meshmonitor-chat--delivery-icon delivery))
       (insert (propertize "\n" 'read-only t 'rear-nonsticky t))
       (when (and request-id (eq delivery 'pending))
         (push (cons request-id (copy-marker icon-pos))
               meshmonitor-chat--pending-deliveries))))))

(defun meshmonitor-chat--is-self-p (msg)
  "Return non-nil if MSG is from the local node."
  (let ((from-id (alist-get 'fromNodeId msg))
        (from-num (alist-get 'fromNodeNum msg)))
    (or (and meshmonitor-chat--my-node-id from-id
             (equal from-id meshmonitor-chat--my-node-id))
        (and meshmonitor-chat--my-node-num from-num
             (equal from-num meshmonitor-chat--my-node-num)))))

(defun meshmonitor-chat--msg-delivery-state (msg)
  "Return delivery state symbol for MSG, or nil."
  (cond
   ((equal (alist-get 'ackFailed msg) 1) 'failed)
   ((alist-get 'deliveryState msg)
    (let ((ds (alist-get 'deliveryState msg)))
      (cond
       ((member ds '("confirmed" "delivered")) 'confirmed)
       ((member ds '("failed" "error")) 'failed)
       ((equal ds "pending") 'pending)
       (t nil))))
   (t nil)))

(defun meshmonitor-chat--extract-request-id (msg)
  "Extract the request ID from MSG for reply support.
Use the requestId field if present, otherwise extract from
the id field (format nodeNum_requestId)."
  (or (alist-get 'requestId msg)
      (let ((id (alist-get 'id msg)))
        (when (and id (stringp id) (string-match "_\\([0-9]+\\)$" id))
          (string-to-number (match-string 1 id))))))

(defun meshmonitor-chat--text-message-p (msg)
  "Return non-nil if MSG is a text message (not traceroute etc)."
  (let ((pn (alist-get 'portnum msg)))
    (or (null pn) (equal pn 1))))

(defun meshmonitor-chat--reaction-p (msg)
  "Return non-nil if MSG is an emoji reaction."
  (equal (alist-get 'emoji msg) 1))

(defun meshmonitor-chat--find-reply-sender (buffer reply-id)
  "Find the sender name of the message with REQUEST-ID REPLY-ID in BUFFER."
  (when (and reply-id (buffer-live-p buffer))
    (with-current-buffer buffer
      (save-excursion
        (goto-char (point-min))
        (let ((limit (marker-position meshmonitor-chat--prompt-start))
              (found nil))
          (while (and (not found) (< (point) limit))
            (when (equal (get-text-property (point)
                                            'meshmonitor-chat-request-id)
                         reply-id)
              (setq found (get-text-property (point)
                                             'meshmonitor-chat-sender)))
            (goto-char (or (next-single-property-change
                            (point) 'meshmonitor-chat-request-id
                            nil limit)
                           limit)))
          found)))))

(defun meshmonitor-chat--find-request-id-pos (request-id)
  "Find buffer position of message with REQUEST-ID, or nil."
  (save-excursion
    (goto-char (point-min))
    (let ((limit (marker-position meshmonitor-chat--prompt-start))
          (found nil))
      (while (and (not found) (< (point) limit))
        (if (equal (get-text-property (point)
                                      'meshmonitor-chat-request-id)
                   request-id)
            (setq found (point))
          (goto-char (or (next-single-property-change
                          (point) 'meshmonitor-chat-request-id
                          nil limit)
                         limit))))
      found)))

(defun meshmonitor-chat--render-reaction-line (buffer reply-id)
  "Render or update the reaction display for REPLY-ID in BUFFER."
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (let ((reactions (gethash reply-id meshmonitor-chat--reactions))
            (inhibit-read-only t))
        (when reactions
          (let ((msg-pos (meshmonitor-chat--find-request-id-pos
                          reply-id)))
            (when msg-pos
              (save-excursion
                (goto-char msg-pos)
                (forward-line 1)
                ;; Delete existing reaction line if present.
                (when (and (< (point)
                              (marker-position
                               meshmonitor-chat--prompt-start))
                           (equal (get-text-property
                                   (point)
                                   'meshmonitor-chat-reaction-for)
                                  reply-id))
                  (delete-region (point)
                                 (save-excursion
                                   (forward-line 1) (point))))
                ;; Insert updated reaction line.
                (insert
                 (propertize
                  (format "  ↳ %s\n"
                          (mapconcat
                           (lambda (r)
                             (format "%s %s" (car r) (cdr r)))
                           (reverse reactions) ", "))
                  'face 'meshmonitor-chat-system-face
                  'read-only t
                  'rear-nonsticky t
                  'front-sticky t
                  'meshmonitor-chat-reaction-for reply-id))))))))))

(defun meshmonitor-chat--mark-seen (buffer id unix-ts)
  "Mark message ID as seen in BUFFER and update last timestamp to UNIX-TS."
  (with-current-buffer buffer
    (when id
      (puthash id t meshmonitor-chat--seen-ids))
    (when (or (null meshmonitor-chat--last-timestamp)
              (> unix-ts meshmonitor-chat--last-timestamp))
      (setq meshmonitor-chat--last-timestamp unix-ts))))

(defun meshmonitor-chat--render-messages (buffer messages)
  "Render MESSAGES into BUFFER with deduplication.
MESSAGES is a list of message alists from the API."
  (when (and (buffer-live-p buffer) messages)
    (let* ((text-msgs (seq-filter #'meshmonitor-chat--text-message-p
                                  (copy-sequence messages)))
           (sorted (sort text-msgs
                         (lambda (a b)
                           (< (meshmonitor-chat--parse-timestamp
                               (alist-get 'timestamp a))
                              (meshmonitor-chat--parse-timestamp
                               (alist-get 'timestamp b))))))
           (regular (seq-remove #'meshmonitor-chat--reaction-p
                                sorted))
           (reactions (seq-filter #'meshmonitor-chat--reaction-p
                                  sorted)))
      ;; Render regular messages.
      (dolist (msg regular)
        (let ((id (alist-get 'id msg)))
          ;; Always check delivery, even for already-seen messages.
          (meshmonitor-chat--check-delivery msg buffer)
          (unless (and id (with-current-buffer buffer
                            (gethash id meshmonitor-chat--seen-ids)))
            (let* ((from (or (alist-get 'fromNodeId msg)
                             (alist-get 'from msg)))
                   (sender (meshmonitor-chat--node-name from))
                   (text (or (alist-get 'text msg) ""))
                   (ts (alist-get 'timestamp msg))
                   (selfp (meshmonitor-chat--is-self-p msg))
                   (unix-ts (meshmonitor-chat--parse-timestamp ts))
                   (req-id (meshmonitor-chat--extract-request-id
                            msg))
                   (reply-id (alist-get 'replyId msg))
                   (reply-name (meshmonitor-chat--find-reply-sender
                                buffer reply-id))
                   (delivery (when selfp
                               (meshmonitor-chat--msg-delivery-state
                                msg))))
              (if (and selfp delivery)
                  (meshmonitor-chat--insert-sent-msg
                   buffer ts sender text delivery)
                (meshmonitor-chat--insert-msg
                 buffer ts sender text selfp nil req-id from
                 reply-name)
                (unless (or selfp (get-buffer-window buffer))
                  (with-current-buffer buffer
                    (meshmonitor-chat--notify
                     sender text
                     meshmonitor-chat--target-type
                     meshmonitor-chat--target))))
              (meshmonitor-chat--mark-seen
               buffer id unix-ts)))))
      ;; Process emoji reactions.
      (dolist (msg reactions)
        (let ((id (alist-get 'id msg))
              (reply-id (alist-get 'replyId msg))
              (emoji-text (or (alist-get 'text msg) ""))
              (from (or (alist-get 'fromNodeId msg)
                        (alist-get 'from msg)))
              (unix-ts (meshmonitor-chat--parse-timestamp
                        (alist-get 'timestamp msg))))
          (unless (and id (with-current-buffer buffer
                            (gethash id meshmonitor-chat--seen-ids)))
            (when reply-id
              (with-current-buffer buffer
                (let ((existing (gethash reply-id
                                         meshmonitor-chat--reactions)))
                  (push (cons emoji-text
                              (meshmonitor-chat--node-name from))
                        existing)
                  (puthash reply-id existing
                           meshmonitor-chat--reactions)))
              (meshmonitor-chat--render-reaction-line
               buffer reply-id))
            (meshmonitor-chat--mark-seen buffer id unix-ts))))
      ;; Mark conversation as read when buffer is visible.
      (when (get-buffer-window buffer)
        (with-current-buffer buffer
          (puthash (cons meshmonitor-chat--target-type
                        meshmonitor-chat--target)
                   (or meshmonitor-chat--last-timestamp 0)
                   meshmonitor-chat--read-timestamps))))))

;;;; Input handling

(defun meshmonitor-chat-send-input ()
  "Send the text after the prompt as a message."
  (interactive)
  (if (< (point) meshmonitor-chat--prompt-end)
      (goto-char (point-max))
    (let ((input (buffer-substring-no-properties
                  meshmonitor-chat--prompt-end (point-max))))
      (when (string-blank-p input)
        (user-error "Empty message"))
      (ring-insert meshmonitor-chat--input-ring input)
      (setq meshmonitor-chat--input-ring-index 0)
      (let ((inhibit-read-only t))
        (delete-region meshmonitor-chat--prompt-end (point-max)))
      (meshmonitor-chat--send-text input))))

(defun meshmonitor-chat--send-text (text)
  "Send TEXT to the current buffer target."
  (unless meshmonitor-chat--connected
    (user-error "Not connected to MeshMonitor"))
  (let ((buf (current-buffer))
        (target meshmonitor-chat--target)
        (ttype meshmonitor-chat--target-type)
        (reply-id (car meshmonitor-chat--reply-to)))
    (when meshmonitor-chat--reply-to
      (setq meshmonitor-chat--reply-to nil)
      (meshmonitor-chat--refresh-prompt))
    (let ((cb (lambda (result)
                (let ((status (if result (car result) 0))
                      (data (alist-get 'data (cdr result))))
                  (cond
                   ((and result (< status 400))
                    (let ((req-id (alist-get 'requestId data))
                          (msg-id (alist-get 'messageId data))
                          (parts (or (alist-get 'messageCount data)
                                     1))
                          (sender
                           (meshmonitor-chat--node-name
                            (or meshmonitor-chat--my-node-id "me"))))
                      (when (buffer-live-p buf)
                        (unless (and msg-id
                                     (with-current-buffer buf
                                       (gethash
                                        msg-id
                                        meshmonitor-chat--seen-ids)))
                          (meshmonitor-chat--insert-sent-msg
                           buf (float-time) sender text
                           'pending req-id))
                        (when msg-id
                          (with-current-buffer buf
                            (puthash msg-id t
                                     meshmonitor-chat--seen-ids))))
                      (when (> parts 1)
                        (meshmonitor-chat--insert-msg
                         buf nil nil
                         (format "Message split into %d parts"
                                 parts)
                         nil t))))
                   ((and result (= status 413))
                    (meshmonitor-chat--insert-msg
                     buf nil nil
                     "Message too long (max ~600 bytes, 3 parts)"
                     nil t))
                   ((and result (= status 503))
                    (meshmonitor-chat--insert-msg
                     buf nil nil
                     "Meshtastic node not connected" nil t))
                   (t
                    (meshmonitor-chat--insert-msg
                     buf nil nil
                     (format "Send failed (HTTP %s)"
                             (or status "timeout"))
                     nil t)))))))
      (pcase ttype
        ('channel
         (meshmonitor-chat--api-send text target nil reply-id cb))
        ('dm
         (meshmonitor-chat--api-send text nil target reply-id cb))
        (_ (user-error "No target set for this buffer"))))))

(defun meshmonitor-chat--navigate-input (direction)
  "Navigate input history in DIRECTION (1 for previous, -1 for next)."
  (when (> (ring-length meshmonitor-chat--input-ring) 0)
    (let ((inhibit-read-only t))
      (delete-region meshmonitor-chat--prompt-end (point-max))
      (when (= direction -1)
        (setq meshmonitor-chat--input-ring-index
              (mod (1- meshmonitor-chat--input-ring-index)
                   (ring-length meshmonitor-chat--input-ring))))
      (insert (ring-ref meshmonitor-chat--input-ring
                        meshmonitor-chat--input-ring-index))
      (when (= direction 1)
        (setq meshmonitor-chat--input-ring-index
              (mod (1+ meshmonitor-chat--input-ring-index)
                   (ring-length meshmonitor-chat--input-ring)))))))

(defun meshmonitor-chat-previous-input ()
  "Replace current input with previous entry from history."
  (interactive)
  (meshmonitor-chat--navigate-input 1))

(defun meshmonitor-chat-next-input ()
  "Replace current input with next entry from history."
  (interactive)
  (meshmonitor-chat--navigate-input -1))

(defun meshmonitor-chat--get-msg-property-at-line (prop)
  "Get text property PROP from the current line."
  (let ((value nil)
        (start (line-beginning-position))
        (end (line-end-position)))
    (save-excursion
      (goto-char start)
      (while (and (not value) (< (point) end))
        (setq value (get-text-property (point) prop))
        (goto-char (or (next-single-property-change
                        (point) prop nil end)
                       end))))
    value))

(defun meshmonitor-chat-resend ()
  "Resend the message at point."
  (interactive)
  (let ((text (meshmonitor-chat--get-msg-property-at-line
               'meshmonitor-chat-msg-text)))
    (if text
        (progn
          (goto-char meshmonitor-chat--prompt-end)
          (meshmonitor-chat--send-text text))
      (user-error "No sent message at point"))))

(defun meshmonitor-chat-reply ()
  "Set reply context to the message at point.
The next sent message will be a reply to this one."
  (interactive)
  (let ((req-id (meshmonitor-chat--get-msg-property-at-line
                 'meshmonitor-chat-request-id))
        (sender (meshmonitor-chat--get-msg-property-at-line
                 'meshmonitor-chat-sender)))
    (if req-id
        (progn
          (setq meshmonitor-chat--reply-to (cons req-id sender))
          (meshmonitor-chat--refresh-prompt)
          (goto-char (point-max))
          (message "Replying to %s (C-c C-k to cancel)" sender))
      (user-error "No message at point"))))

(defun meshmonitor-chat-cancel-reply ()
  "Cancel the current reply context."
  (interactive)
  (setq meshmonitor-chat--reply-to nil)
  (meshmonitor-chat--refresh-prompt)
  (message "Reply cancelled"))

(declare-function emojify-completing-read "emojify" (&optional prompt))

(defun meshmonitor-chat-react ()
  "React with an emoji to the message at point.
Uses `emojify-completing-read' when available for emoji selection.
Shows the reaction immediately and sends it via the API."
  (interactive)
  (let ((req-id (meshmonitor-chat--get-msg-property-at-line
                 'meshmonitor-chat-request-id)))
    (if req-id
        (let ((emoji (if (fboundp 'emojify-completing-read)
                         (emojify-completing-read "Reaction: ")
                       (read-string "Emoji: "))))
          (when (and emoji (not (string-empty-p emoji)))
            (let ((buf (current-buffer))
                  (target meshmonitor-chat--target)
                  (ttype meshmonitor-chat--target-type)
                  (sender (meshmonitor-chat--node-name
                           (or meshmonitor-chat--my-node-id "me")))
                  (reaction (cons emoji nil)))
              ;; Optimistic: show reaction immediately.
              (setcdr reaction sender)
              (let ((existing (gethash req-id
                                       meshmonitor-chat--reactions)))
                (push reaction existing)
                (puthash req-id existing
                         meshmonitor-chat--reactions))
              (meshmonitor-chat--render-reaction-line buf req-id)
              ;; Send via API.
              (meshmonitor-chat--api-send
               emoji
               (when (eq ttype 'channel) target)
               (when (eq ttype 'dm) target)
               req-id
               (lambda (result)
                 (if (and result (< (car result) 400))
                     ;; Mark as seen to prevent polling duplicate.
                     (let ((msg-id
                            (alist-get
                             'messageId
                             (alist-get 'data (cdr result)))))
                       (when (and msg-id (buffer-live-p buf))
                         (with-current-buffer buf
                           (puthash msg-id t
                                    meshmonitor-chat--seen-ids))))
                   ;; On error, remove the optimistic reaction.
                   (when (buffer-live-p buf)
                     (with-current-buffer buf
                       (let ((rs (gethash req-id
                                          meshmonitor-chat--reactions)))
                         (puthash req-id (delete reaction rs)
                                  meshmonitor-chat--reactions))
                       (meshmonitor-chat--render-reaction-line
                        buf req-id))
                     (meshmonitor-chat--insert-msg
                      buf nil nil "Reaction failed" nil t))))))))
      (user-error "No message at point"))))

(defun meshmonitor-chat-dm-at-point ()
  "Open a DM chat with the sender of the message at point."
  (interactive)
  (let ((from-id (meshmonitor-chat--get-msg-property-at-line
                  'meshmonitor-chat-from-id)))
    (if from-id
        (meshmonitor-chat-open-dm from-id)
      (user-error "No message at point"))))

(defun meshmonitor-chat-refresh-chat ()
  "Reload message history for the current chat buffer."
  (interactive)
  (unless meshmonitor-chat--target
    (user-error "Not a chat buffer"))
  (let ((buf (current-buffer)))
    (pcase meshmonitor-chat--target-type
      ('channel
       (meshmonitor-chat--api-messages
        `((channel . ,meshmonitor-chat--target)
          (limit . ,meshmonitor-chat-message-limit))
        (lambda (result)
          (when result
            (let ((msgs (alist-get 'data (cdr result))))
              (when msgs
                (meshmonitor-chat--render-messages buf msgs)))))))
      ('dm
       (meshmonitor-chat--fetch-dm-both-directions
        meshmonitor-chat--target meshmonitor-chat-message-limit
        (lambda (msgs)
          (when msgs
            (meshmonitor-chat--render-messages buf msgs))))))
    (message "MeshMonitor: refreshed")))

;;;; Buffer management

(defun meshmonitor-chat--buffer-name (ttype target)
  "Generate buffer name for target type TTYPE and TARGET."
  (pcase ttype
    ('channel (format "*MeshMonitor: #%s*"
                      (meshmonitor-chat--channel-name target)))
    ('dm (format "*MeshMonitor: DM %s*"
                 (meshmonitor-chat--node-name target)))))

(defun meshmonitor-chat--get-or-create-buffer (ttype target)
  "Get or create a chat buffer for TTYPE and TARGET."
  (let* ((name (meshmonitor-chat--buffer-name ttype target))
         (buf (get-buffer name)))
    (if (and buf (buffer-live-p buf))
        buf
      (setq buf (get-buffer-create name))
      (with-current-buffer buf
        (meshmonitor-chat-mode)
        (setq meshmonitor-chat--target target)
        (setq meshmonitor-chat--target-type ttype)
        (meshmonitor-chat--setup-prompt))
      (push (cons (cons ttype target) buf)
            meshmonitor-chat--chat-buffers)
      buf)))

;;;; Channel list mode

(defun meshmonitor-chat-channel-list-open-by-number ()
  "Open channel by the digit key pressed (0-7)."
  (interactive)
  (let ((id (- last-command-event ?0)))
    (meshmonitor-chat-open-channel id)))

(defvar meshmonitor-chat-channel-list-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map tabulated-list-mode-map)
    (define-key map (kbd "RET")
                #'meshmonitor-chat-channel-list-open)
    (define-key map (kbd "g")
                #'meshmonitor-chat-channel-list-refresh)
    (dotimes (i 8)
      (define-key map (kbd (number-to-string i))
                  #'meshmonitor-chat-channel-list-open-by-number))
    map)
  "Keymap for `meshmonitor-chat-channel-list-mode'.")

(define-derived-mode meshmonitor-chat-channel-list-mode
  tabulated-list-mode "MeshChannels"
  "Major mode for listing MeshMonitor channels."
  :group 'meshmonitor-chat
  (setq tabulated-list-format
        [("ID" 4 t)
         ("Name" 20 t)
         ("Role" 12 t)])
  (setq tabulated-list-padding 2)
  (tabulated-list-init-header))

(defun meshmonitor-chat-channel-list-open ()
  "Open the channel at point."
  (interactive)
  (let ((entry (tabulated-list-get-entry)))
    (when entry
      (meshmonitor-chat-open-channel
       (string-to-number (aref entry 0))))))

(defun meshmonitor-chat-channel-list-refresh ()
  "Refresh the channel list from the server."
  (interactive)
  (meshmonitor-chat--fetch-channels
   (lambda (_channels)
     (let ((buf (get-buffer "*MeshMonitor: Channels*")))
       (when (buffer-live-p buf)
         (with-current-buffer buf
           (meshmonitor-chat--populate-channel-list)))))))

(defun meshmonitor-chat--populate-channel-list ()
  "Populate the current buffer with cached channel data."
  (let ((role-names '((0 . "Disabled")
                      (1 . "Primary")
                      (2 . "Secondary"))))
    (setq tabulated-list-entries
          (mapcar
           (lambda (ch)
             (let ((id (alist-get 'id ch))
                   (name (or (alist-get 'name ch) ""))
                   (role (or (alist-get 'role ch) 0)))
               (list id
                     (vector (number-to-string id)
                             name
                             (or (cdr (assq role role-names))
                                 (format "%d" role))))))
           (seq-filter
            (lambda (ch) (> (or (alist-get 'role ch) 0) 0))
            meshmonitor-chat--channels)))
    (tabulated-list-print t)))

;;;; DM list mode

(defvar meshmonitor-chat-dm-list-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map tabulated-list-mode-map)
    (define-key map (kbd "RET")
                #'meshmonitor-chat-dm-list-open)
    (define-key map (kbd "g")
                #'meshmonitor-chat-dm-list-refresh)
    map)
  "Keymap for `meshmonitor-chat-dm-list-mode'.")

(define-derived-mode meshmonitor-chat-dm-list-mode
  tabulated-list-mode "MeshDMs"
  "Major mode for listing MeshMonitor DM conversations."
  :group 'meshmonitor-chat
  (setq tabulated-list-format
        [("Name" 20 t)
         ("Node ID" 14 t)
         ("Last message" 40 nil)])
  (setq tabulated-list-padding 2)
  (tabulated-list-init-header))

(defun meshmonitor-chat-dm-list-open ()
  "Open DM chat with the node at point."
  (interactive)
  (let ((entry (tabulated-list-get-entry)))
    (when entry
      (meshmonitor-chat-open-dm (aref entry 1)))))

(defun meshmonitor-chat-dm-list-refresh ()
  "Refresh the DM conversation list from the server."
  (interactive)
  (meshmonitor-chat--fetch-dm-conversations
   (lambda (partners)
     (let ((buf (get-buffer "*MeshMonitor: Direct Messages*")))
       (when (buffer-live-p buf)
         (with-current-buffer buf
           (meshmonitor-chat--populate-dm-list partners)))))))

(defun meshmonitor-chat--fetch-dm-conversations (callback)
  "Fetch DM conversations and call CALLBACK with partner alist.
Each element is (NODE-ID . LAST-MESSAGE-ALIST)."
  (meshmonitor-chat--api-messages
   `((limit . 200))
   (lambda (result)
     (let ((partners nil))
       (when result
         (let* ((msgs (alist-get 'data (cdr result)))
                (dm-msgs (seq-filter
                          (lambda (m)
                            (and (equal (alist-get 'channel m) -1)
                                 (meshmonitor-chat--text-message-p m)
                                 (not (meshmonitor-chat--reaction-p
                                       m))))
                          msgs)))
           (setq partners
                 (meshmonitor-chat--extract-dm-partners
                  dm-msgs))))
       (funcall callback partners)))))

(defun meshmonitor-chat--extract-dm-partners (messages)
  "Extract unique DM conversation partners from MESSAGES.
Return alist of (NODE-ID . LAST-MESSAGE-ALIST)."
  (let ((partners (make-hash-table :test 'equal)))
    (dolist (msg messages)
      (let ((from (alist-get 'fromNodeId msg))
            (to (alist-get 'toNodeId msg))
            (ts (meshmonitor-chat--parse-timestamp
                 (alist-get 'timestamp msg))))
        (let ((partner
               (cond
                ((and meshmonitor-chat--my-node-id
                      (equal from meshmonitor-chat--my-node-id))
                 to)
                ((and meshmonitor-chat--my-node-id
                      (equal to meshmonitor-chat--my-node-id))
                 from)
                (t from))))
          (when (and partner
                     (not (equal partner "broadcast"))
                     (not (equal partner
                                 meshmonitor-chat--my-node-id)))
            (let ((existing (gethash partner partners)))
              (when (or (null existing)
                        (> ts (meshmonitor-chat--parse-timestamp
                               (alist-get 'timestamp existing))))
                (puthash partner msg partners)))))))
    (let ((result nil))
      (maphash (lambda (k v) (push (cons k v) result)) partners)
      result)))

(defun meshmonitor-chat--populate-dm-list (partners)
  "Populate the current buffer with DM PARTNERS alist."
  (setq tabulated-list-entries
        (mapcar
         (lambda (entry)
           (let* ((node-id (car entry))
                  (msg (cdr entry))
                  (name (meshmonitor-chat--node-name node-id))
                  (text (or (alist-get 'text msg) "")))
             (list node-id
                   (vector name
                           node-id
                           (truncate-string-to-width
                            text 40 nil nil "...")))))
         partners))
  (tabulated-list-print t))

;;;; Node list mode

(defvar meshmonitor-chat-node-list-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map tabulated-list-mode-map)
    (define-key map (kbd "RET")
                #'meshmonitor-chat-node-list-open)
    (define-key map (kbd "g")
                #'meshmonitor-chat-node-list-refresh)
    (define-key map (kbd "t")
                #'meshmonitor-chat-traceroute)
    (define-key map (kbd "p")
                #'meshmonitor-chat-request-position)
    map)
  "Keymap for `meshmonitor-chat-node-list-mode'.")

(define-derived-mode meshmonitor-chat-node-list-mode
  tabulated-list-mode "MeshNodes"
  "Major mode for listing MeshMonitor nodes sorted by hops."
  :group 'meshmonitor-chat
  (setq tabulated-list-format
        [("Hops" 5 meshmonitor-chat--sort-by-hops)
         ("Name" 30 t)
         ("Node ID" 14 t)
         ("Last heard" 12 t)])
  (setq tabulated-list-padding 2)
  (setq tabulated-list-sort-key '("Hops"))
  (tabulated-list-init-header))

(defun meshmonitor-chat--sort-by-hops (a b)
  "Sort entries A and B by hop count numerically."
  (let ((ha (string-to-number (aref (cadr a) 0)))
        (hb (string-to-number (aref (cadr b) 0))))
    (< ha hb)))

(defun meshmonitor-chat-node-list-open ()
  "Open DM chat with the node at point.
Refuses if the node has not exchanged encryption keys."
  (interactive)
  (let ((entry (tabulated-list-get-entry)))
    (when entry
      (let ((node-id (aref entry 2)))
        (if (meshmonitor-chat--node-has-pkc-p node-id)
            (meshmonitor-chat-open-dm node-id)
          (user-error "Node %s has no key exchange, DM not possible"
                      (meshmonitor-chat--node-name node-id)))))))

(defun meshmonitor-chat-node-list-refresh ()
  "Refresh the node list from the server."
  (interactive)
  (meshmonitor-chat--fetch-nodes
   (lambda ()
     (let ((buf (get-buffer "*MeshMonitor: Nodes*")))
       (when (buffer-live-p buf)
         (with-current-buffer buf
           (meshmonitor-chat--populate-node-list)))))))

(defun meshmonitor-chat--format-last-heard (ts)
  "Format last heard timestamp TS as relative time."
  (if (and ts (numberp ts) (> ts 0))
      (let* ((secs (- (float-time) (if (> ts 9999999999)
                                       (/ ts 1000) ts)))
             (mins (/ secs 60))
             (hours (/ mins 60))
             (days (/ hours 24)))
        (cond
         ((< mins 1) "now")
         ((< mins 60) (format "%dm" (truncate mins)))
         ((< hours 24) (format "%dh" (truncate hours)))
         (t (format "%dd" (truncate days)))))
    "?"))

(defvar meshmonitor-chat--online-threshold 900
  "Seconds since last heard to consider a node online (default 15 min).")

(defun meshmonitor-chat--node-online-p (last-heard)
  "Return non-nil if LAST-HEARD timestamp is recent enough."
  (and last-heard (numberp last-heard) (> last-heard 0)
       (< (- (float-time) (if (> last-heard 9999999999)
                               (/ last-heard 1000) last-heard))
          meshmonitor-chat--online-threshold)))

(defun meshmonitor-chat--populate-node-list ()
  "Populate the current buffer with cached node data."
  (let ((entries nil))
    (maphash
     (lambda (key node)
       (when (string-prefix-p "!" key)
         (let* ((hops (or (alist-get 'hopsAway node) 99))
                (name (or (alist-get 'longName node) ""))
                (last-heard (alist-get 'lastHeard node))
                (online (meshmonitor-chat--node-online-p
                         last-heard))
                (pkc (meshmonitor-chat--json-true-p
                      (alist-get 'hasPKC node)))
                (indicator (concat (if online "🟢" "⚫")
                                   (if pkc " 🔑" ""))))
           (push (list key
                       (vector (number-to-string hops)
                               (format "%s %s" indicator name)
                               key
                               (meshmonitor-chat--format-last-heard
                                last-heard)))
                 entries))))
     meshmonitor-chat--nodes)
    (setq tabulated-list-entries entries)
    (tabulated-list-print t)))

;;;; Unread messages mode

(defvar meshmonitor-chat-unread-list-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map tabulated-list-mode-map)
    (define-key map (kbd "RET")
                #'meshmonitor-chat-unread-list-open)
    (define-key map (kbd "g")
                #'meshmonitor-chat-unread-list-refresh)
    map)
  "Keymap for `meshmonitor-chat-unread-list-mode'.")

(define-derived-mode meshmonitor-chat-unread-list-mode
  tabulated-list-mode "MeshUnread"
  "Major mode for listing nodes with unread messages."
  :group 'meshmonitor-chat
  (setq tabulated-list-format
        [("Unread" 7 meshmonitor-chat--sort-by-unread)
         ("Name" 25 t)
         ("Node ID" 14 t)
         ("Last message" 40 nil)])
  (setq tabulated-list-padding 2)
  (setq tabulated-list-sort-key '("Unread" . t))
  (tabulated-list-init-header))

(defun meshmonitor-chat--sort-by-unread (a b)
  "Sort entries A and B by unread count numerically."
  (let ((ua (string-to-number (aref (cadr a) 0)))
        (ub (string-to-number (aref (cadr b) 0))))
    (< ua ub)))

(defun meshmonitor-chat-unread-list-open ()
  "Open DM chat with the node at point."
  (interactive)
  (let ((entry (tabulated-list-get-entry)))
    (when entry
      (meshmonitor-chat-open-dm (aref entry 2)))))

(defun meshmonitor-chat-unread-list-refresh ()
  "Refresh the unread messages list from the server."
  (interactive)
  (meshmonitor-chat--fetch-unread
   (lambda (unread)
     (let ((buf (get-buffer "*MeshMonitor: Unread*")))
       (when (buffer-live-p buf)
         (with-current-buffer buf
           (meshmonitor-chat--populate-unread-list unread)))))))

(defun meshmonitor-chat--fetch-unread (callback)
  "Fetch messages and find unread conversations.
Call CALLBACK with alist of (NODE-ID COUNT LAST-MSG)."
  (meshmonitor-chat--api-messages
   `((limit . 200))
   (lambda (result)
     (let ((unread nil))
       (when result
         (let ((msgs (alist-get 'data (cdr result))))
           (when msgs
             (let ((by-node (make-hash-table :test 'equal)))
               (dolist (msg msgs)
                 (let* ((from (alist-get 'fromNodeId msg))
                        (ts (meshmonitor-chat--parse-timestamp
                             (alist-get 'timestamp msg)))
                        (is-dm (and (equal (alist-get 'channel msg)
                                           -1)
                                    (meshmonitor-chat--text-message-p
                                     msg)
                                    (not (meshmonitor-chat--reaction-p
                                          msg))))
                        (is-self (meshmonitor-chat--is-self-p msg)))
                   (when (and is-dm (not is-self) from)
                     (let ((read-ts
                            (or (gethash (cons 'dm from)
                                         meshmonitor-chat--read-timestamps)
                                0)))
                       (when (> ts read-ts)
                         (let ((entry (gethash from by-node)))
                           (if entry
                               (progn
                                 (cl-incf (car entry))
                                 (when (> ts
                                          (meshmonitor-chat--parse-timestamp
                                           (alist-get 'timestamp
                                                      (cdr entry))))
                                   (setcdr entry msg)))
                             (puthash from (cons 1 msg)
                                      by-node))))))))
               (maphash (lambda (k v)
                          (push (list k (car v) (cdr v)) unread))
                        by-node)))))
       (funcall callback unread)))))

(defun meshmonitor-chat--populate-unread-list (unread)
  "Populate current buffer with UNREAD conversation data.
Each element of UNREAD is (NODE-ID COUNT LAST-MSG)."
  (setq tabulated-list-entries
        (mapcar
         (lambda (entry)
           (let* ((node-id (nth 0 entry))
                  (count (nth 1 entry))
                  (msg (nth 2 entry))
                  (name (meshmonitor-chat--node-name node-id))
                  (text (or (alist-get 'text msg) "")))
             (list node-id
                   (vector (number-to-string count)
                           name
                           node-id
                           (truncate-string-to-width
                            text 40 nil nil "...")))))
         unread))
  (tabulated-list-print t))

;;;; Open chat buffers

(defun meshmonitor-chat-open-channel (channel-id)
  "Open a chat buffer for CHANNEL-ID and load message history."
  (meshmonitor-chat--ensure-connected)
  (let ((buf (meshmonitor-chat--get-or-create-buffer
              'channel channel-id)))
    (switch-to-buffer buf)
    (meshmonitor-chat--api-messages
     `((channel . ,channel-id)
       (limit . ,meshmonitor-chat-message-limit))
     (lambda (result)
       (when result
         (let ((msgs (alist-get 'data (cdr result))))
           (when msgs
             (meshmonitor-chat--render-messages buf msgs))))))))

(defun meshmonitor-chat--fetch-dm-both-directions (node-id limit
                                                           callback)
  "Fetch DM messages with NODE-ID in both directions.
LIMIT is the max messages per direction.
Call CALLBACK with the merged message list."
  (let ((all-messages nil)
        (pending 2))
    (let ((handler
           (lambda (result)
             (when result
               (let ((msgs (alist-get 'data (cdr result))))
                 (when msgs
                   (setq all-messages
                         (append msgs all-messages)))))
             (setq pending (1- pending))
             (when (zerop pending)
               (funcall callback all-messages)))))
      (meshmonitor-chat--api-messages
       `((toNodeId . ,node-id) (limit . ,limit)) handler)
      (meshmonitor-chat--api-messages
       `((fromNodeId . ,node-id) (limit . ,limit)) handler))))

(defun meshmonitor-chat-open-dm (node-id)
  "Open a DM chat buffer with NODE-ID and load history."
  (meshmonitor-chat--ensure-connected)
  (let ((buf (meshmonitor-chat--get-or-create-buffer 'dm node-id)))
    (switch-to-buffer buf)
    (meshmonitor-chat--fetch-dm-both-directions
     node-id meshmonitor-chat-message-limit
     (lambda (msgs)
       (when msgs
         (meshmonitor-chat--render-messages buf msgs))))))

;;;; Polling

(defun meshmonitor-chat--start-polling ()
  "Start the periodic message polling timer."
  (meshmonitor-chat--stop-polling)
  (setq meshmonitor-chat--poll-timer
        (run-with-timer
         meshmonitor-chat-poll-interval
         meshmonitor-chat-poll-interval
         #'meshmonitor-chat--poll)))

(defun meshmonitor-chat--stop-polling ()
  "Stop the periodic message polling timer."
  (when meshmonitor-chat--poll-timer
    (cancel-timer meshmonitor-chat--poll-timer)
    (setq meshmonitor-chat--poll-timer nil)))

(defun meshmonitor-chat--poll ()
  "Poll for new messages in all open chat buffers.
Detects gaps from sleep/suspend and does a full fetch if needed."
  (when meshmonitor-chat--connected
    (let* ((now (float-time))
           (gap-p (and meshmonitor-chat--last-poll-time
                       (> (- now meshmonitor-chat--last-poll-time)
                          (* 2 meshmonitor-chat-poll-interval)))))
      (setq meshmonitor-chat--last-poll-time now)
      (setq meshmonitor-chat--chat-buffers
            (seq-filter (lambda (e) (buffer-live-p (cdr e)))
                        meshmonitor-chat--chat-buffers))
      (dolist (entry meshmonitor-chat--chat-buffers)
        (let* ((key (car entry))
               (buf (cdr entry))
               (ttype (car key))
               (target (cdr key))
               (since (unless gap-p
                        (with-current-buffer buf
                          meshmonitor-chat--last-timestamp)))
               (limit (if gap-p
                          meshmonitor-chat-message-limit 20))
               (render-cb
                (lambda (result)
                  (when result
                    (let ((msgs (alist-get 'data (cdr result))))
                      (when msgs
                        (meshmonitor-chat--render-messages
                         buf msgs)))))))
          (pcase ttype
            ('channel
             (let ((params `((channel . ,target)
                             (limit . ,limit))))
               (when since
                 (push `(since . ,(1+ since)) params))
               (meshmonitor-chat--api-messages params render-cb)))
            ('dm
             (let ((params-from
                    `((fromNodeId . ,target) (limit . ,limit)))
                   (params-to
                    `((toNodeId . ,target) (limit . ,limit)))
                   (all-msgs nil)
                   (dm-pending 2))
               (when since
                 (push `(since . ,(1+ since)) params-from)
                 (push `(since . ,(1+ since)) params-to))
               (let ((dm-handler
                      (lambda (result)
                        (when result
                          (let ((msgs (alist-get 'data
                                                 (cdr result))))
                            (when msgs
                              (setq all-msgs
                                    (append msgs all-msgs)))))
                        (setq dm-pending (1- dm-pending))
                        (when (zerop dm-pending)
                          (when all-msgs
                            (meshmonitor-chat--render-messages
                             buf all-msgs))))))
                 (meshmonitor-chat--api-messages
                  params-from dm-handler)
                 (meshmonitor-chat--api-messages
                  params-to dm-handler))))))))))

;;;; Notifications

(defun meshmonitor-chat--notify (sender text target-type target)
  "Show desktop notification for a message from SENDER.
TEXT is the message content, TARGET-TYPE and TARGET identify the chat."
  (when meshmonitor-chat-notify
    (let ((title (pcase target-type
                   ('channel (format "#%s"
                                     (meshmonitor-chat--channel-name
                                      target)))
                   ('dm sender)
                   (_ "MeshMonitor")))
          (body (truncate-string-to-width
                 (format "%s: %s" sender text) 100 nil nil "...")))
      (notifications-notify
       :title title
       :body body
       :app-name "MeshMonitor"
       :category "im.received"
       :urgency 'normal))))

;;;; Cleanup

;;;###autoload
(defun meshmonitor-chat-disconnect ()
  "Disconnect from MeshMonitor and clean up state."
  (interactive)
  (meshmonitor-chat--stop-polling)
  (setq meshmonitor-chat--connected nil
        meshmonitor-chat--auth-token nil
        meshmonitor-chat--csrf-token nil
        meshmonitor-chat--last-poll-time nil
        meshmonitor-chat--base-url nil
        meshmonitor-chat--my-node-id nil
        meshmonitor-chat--my-node-num nil
        meshmonitor-chat--channels nil
        meshmonitor-chat--chat-buffers nil)
  (clrhash meshmonitor-chat--nodes)
  (clrhash meshmonitor-chat--read-timestamps)
  (message "MeshMonitor: disconnected"))

;;;; Status API

(defun meshmonitor-chat--fetch-status-sync ()
  "Fetch server status synchronously.
Try legacy endpoint first (more data), fall back to v1."
  (let ((result (meshmonitor-chat--request "GET" "/api/status")))
    (if (and result (< (car result) 400))
        result
      (meshmonitor-chat--request "GET" "/api/v1/status"))))

(defun meshmonitor-chat--fetch-status (callback)
  "Fetch server status asynchronously.
Try legacy endpoint first (more data), fall back to v1.
Call CALLBACK with (STATUS-CODE . BODY)."
  (meshmonitor-chat--request
   "GET" "/api/status" nil
   (lambda (result)
     (if (and result (< (car result) 400))
         (funcall callback result)
       (meshmonitor-chat--request
        "GET" "/api/v1/status" nil callback)))))

(defun meshmonitor-chat--json-true-p (value)
  "Return non-nil if JSON VALUE is true.
Handles `json-read' representation where false is `:json-false'."
  (and value (not (eq value :json-false))))

(defun meshmonitor-chat--normalize-status (body)
  "Normalize status BODY from v1 or legacy API into a common alist.
Returns alist with keys: connected, node-name, node-id,
node-num, version, uptime, nodes, messages, channels."
  (let* ((v1-data (alist-get 'data body))
         (conn (alist-get 'connection body))
         (local-node (and conn (alist-get 'localNode conn)))
         (stats (alist-get 'statistics body)))
    ;; v1: { success, data: { connected, localNodeId, longName, ... } }
    ;; Legacy: { version, uptime, connection: { connected, localNode },
    ;;           statistics: { nodes, messages, channels } }
    `((connected . ,(meshmonitor-chat--json-true-p
                     (if v1-data
                         (alist-get 'connected v1-data)
                       (and conn (alist-get 'connected conn)))))
      (node-name . ,(or (and v1-data (alist-get 'longName v1-data))
                         (and local-node
                              (alist-get 'longName local-node))))
      (node-id . ,(or (and v1-data (alist-get 'localNodeId v1-data))
                       (and local-node
                            (alist-get 'nodeId local-node))))
      (node-num . ,(or (and v1-data
                             (alist-get 'localNodeNum v1-data))
                        (and local-node
                             (alist-get 'nodeNum local-node))))
      (version . ,(alist-get 'version body))
      (uptime . ,(alist-get 'uptime body))
      (nodes . ,(and stats (alist-get 'nodes stats)))
      (messages . ,(and stats (alist-get 'messages stats)))
      (channels . ,(and stats (alist-get 'channels stats))))))

;;;; Welcome buffer

(defface meshmonitor-chat-welcome-title-face
  '((t :weight bold :height 1.3))
  "Face for the welcome buffer title.")

(defface meshmonitor-chat-welcome-heading-face
  '((t :weight bold))
  "Face for section headings in the welcome buffer.")

(defface meshmonitor-chat-welcome-key-face
  '((t :foreground "cyan" :weight bold))
  "Face for keyboard shortcuts in the welcome buffer.")

(defvar meshmonitor-chat-welcome-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "c") #'meshmonitor-chat-channels)
    (define-key map (kbd "n") #'meshmonitor-chat-nodes)
    (define-key map (kbd "d") #'meshmonitor-chat-direct-messages)
    (define-key map (kbd "u") #'meshmonitor-chat-unread)
    (define-key map (kbd "g") #'meshmonitor-chat-refresh)
    (define-key map (kbd "q") #'quit-window)
    map)
  "Keymap for `meshmonitor-chat-welcome-mode'.")

(define-derived-mode meshmonitor-chat-welcome-mode special-mode
  "MeshMonitor"
  "Major mode for the MeshMonitor welcome screen."
  :group 'meshmonitor-chat
  (buffer-disable-undo)
  (setq truncate-lines t))

(defun meshmonitor-chat--format-uptime (seconds)
  "Format SECONDS as a human-readable uptime string."
  (let* ((days (floor (/ seconds 86400)))
         (hours (floor (/ (mod seconds 86400) 3600)))
         (mins (floor (/ (mod seconds 3600) 60))))
    (cond
     ((> days 0) (format "%dd %dh" days hours))
     ((> hours 0) (format "%dh %dm" hours mins))
     (t (format "%dm" mins)))))

(defun meshmonitor-chat--insert-welcome-shortcut (key label)
  "Insert a formatted shortcut KEY with LABEL."
  (insert "  ")
  (insert (propertize (format "[%s]" key)
                      'face 'meshmonitor-chat-welcome-key-face))
  (insert (format " %-17s" label)))

(defun meshmonitor-chat--render-welcome (status-data)
  "Render the welcome buffer with STATUS-DATA."
  (let ((buf (get-buffer-create "*MeshMonitor*"))
        (info (meshmonitor-chat--normalize-status
               (cdr status-data))))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (erase-buffer)
        (let* ((connected (alist-get 'connected info))
               (version (or (alist-get 'version info) "?"))
               (uptime (alist-get 'uptime info))
               (node-name (alist-get 'node-name info))
               (node-id (alist-get 'node-id info))
               (sep (propertize (format "  %s\n"
                                        (make-string 38 ?─))
                                'face 'meshmonitor-chat-timestamp-face)))
          ;; Title.
          (insert "\n")
          (insert (propertize "  MeshMonitor Chat\n"
                              'face 'meshmonitor-chat-welcome-title-face))
          (insert (propertize (format "  %s\n" (make-string 38 ?═))
                              'face 'meshmonitor-chat-timestamp-face))
          (insert "\n")
          ;; Connection.
          (insert (propertize "  Connection\n"
                              'face 'meshmonitor-chat-welcome-heading-face))
          (insert (format "  Server:    %s:%d\n"
                          meshmonitor-chat-host
                          meshmonitor-chat-port))
          (insert (format "  Version:   %s\n" version))
          (insert (format "  Status:    %s\n"
                          (propertize
                           (if connected "Connected" "Disconnected")
                           'face (if connected
                                     'meshmonitor-chat-delivery-confirmed-face
                                   'meshmonitor-chat-delivery-failed-face))))
          (when node-name
            (insert (format "  Node:      %s (%s)\n"
                            node-name node-id)))
          (when uptime
            (insert (format "  Uptime:    %s\n"
                            (meshmonitor-chat--format-uptime uptime))))
          (insert "\n")
          ;; Statistics.
          (let ((nodes (alist-get 'nodes info))
                (messages (alist-get 'messages info))
                (channels (alist-get 'channels info)))
            (when (or nodes messages channels)
              (insert (propertize "  Statistics\n"
                                  'face 'meshmonitor-chat-welcome-heading-face))
              (when nodes
                (insert (format "  Nodes:     %s\n" nodes)))
              (when messages
                (insert (format "  Messages:  %s\n" messages)))
              (when channels
                (insert (format "  Channels:  %s\n" channels)))
              (insert "\n")))
          ;; Separator.
          (insert sep)
          (insert "\n")
          ;; Navigation.
          (meshmonitor-chat--insert-welcome-shortcut "c" "Channels")
          (meshmonitor-chat--insert-welcome-shortcut "n" "Nodes")
          (insert "\n")
          (meshmonitor-chat--insert-welcome-shortcut "d" "Direct Messages")
          (meshmonitor-chat--insert-welcome-shortcut "u" "Unread")
          (insert "\n")
          (meshmonitor-chat--insert-welcome-shortcut "g" "Refresh")
          (meshmonitor-chat--insert-welcome-shortcut "q" "Quit")
          (insert "\n\n")
          (insert sep)))
      (meshmonitor-chat-welcome-mode)
      (goto-char (point-min)))))

(defun meshmonitor-chat-refresh ()
  "Refresh the MeshMonitor welcome buffer."
  (interactive)
  (meshmonitor-chat--fetch-status
   (lambda (result)
     (when result
       (meshmonitor-chat--render-welcome result)))))

;;;; Entry points

(defun meshmonitor-chat--show-list-buffer (name mode fetch-fn
                                                populate-fn)
  "Show a list buffer named NAME with MODE.
FETCH-FN fetches data and calls its callback with results.
POPULATE-FN receives the data and populates the buffer."
  (meshmonitor-chat--ensure-connected)
  (let ((buf (get-buffer-create name)))
    (with-current-buffer buf
      (unless (eq major-mode mode)
        (funcall mode)))
    (switch-to-buffer buf)
    (funcall fetch-fn
             (lambda (data)
               (when (buffer-live-p buf)
                 (with-current-buffer buf
                   (funcall populate-fn data)))))))

;;;###autoload
(defun meshmonitor-chat-channels ()
  "Show the MeshMonitor channel list."
  (interactive)
  (meshmonitor-chat--show-list-buffer
   "*MeshMonitor: Channels*"
   'meshmonitor-chat-channel-list-mode
   (lambda (cb) (meshmonitor-chat--fetch-channels
                 (lambda (_) (funcall cb nil))))
   (lambda (_) (meshmonitor-chat--populate-channel-list))))

;;;###autoload
(defun meshmonitor-chat-direct-messages ()
  "Show the MeshMonitor DM conversation list."
  (interactive)
  (meshmonitor-chat--show-list-buffer
   "*MeshMonitor: Direct Messages*"
   'meshmonitor-chat-dm-list-mode
   #'meshmonitor-chat--fetch-dm-conversations
   #'meshmonitor-chat--populate-dm-list))

;;;###autoload
(defun meshmonitor-chat-nodes ()
  "Show all MeshMonitor nodes sorted by hop count."
  (interactive)
  (meshmonitor-chat--show-list-buffer
   "*MeshMonitor: Nodes*"
   'meshmonitor-chat-node-list-mode
   (lambda (cb) (meshmonitor-chat--fetch-nodes
                 (lambda () (funcall cb nil))))
   (lambda (_) (meshmonitor-chat--populate-node-list))))

;;;###autoload
(defun meshmonitor-chat-unread ()
  "Show nodes with unread direct messages."
  (interactive)
  (meshmonitor-chat--show-list-buffer
   "*MeshMonitor: Unread*"
   'meshmonitor-chat-unread-list-mode
   #'meshmonitor-chat--fetch-unread
   #'meshmonitor-chat--populate-unread-list))

;;;###autoload
(defun meshmonitor-chat ()
  "Open the MeshMonitor welcome screen."
  (interactive)
  (meshmonitor-chat--ensure-connected)
  (meshmonitor-chat--fetch-status
   (lambda (result)
     (when result
       (meshmonitor-chat--render-welcome result)
       (switch-to-buffer "*MeshMonitor*")))))

(provide 'meshmonitor-chat)
;;; meshmonitor-chat.el ends here
