;;; meshtastic.el --- Chat client for Meshtastic LoRa devices via serial  -*- lexical-binding: t; -*-

;; Copyright (C) 2026  Andros Fenollosa

;; Author: Andros Fenollosa <hi@andros.dev>
;; Maintainer: Andros Fenollosa <hi@andros.dev>
;; Package-Version: 20260527.1500
;; Package-Revision: 5ad027669b3e
;; Package-Requires: ((emacs "28.1"))
;; Keywords: comm
;; URL: https://git.andros.dev/andros/meshtastic.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 Meshtastic LoRa mesh networks.
;; Connects to a Meshtastic device over serial USB via a Python bridge.
;;
;; Requires the meshtastic Python package:
;;   pip install meshtastic
;;
;; Configure `meshtastic-serial-port' in your init file, then:
;;
;;   M-x meshtastic           - welcome screen
;;   M-x meshtastic-channels  - list channels
;;   M-x meshtastic-nodes     - list nodes by hops
;;
;; Messages received since the bridge started are buffered in memory
;; and shown when a chat buffer is opened.

;;; Code:

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

;;;; Customization

(defgroup meshtastic nil
  "Chat client for Meshtastic LoRa mesh networks."
  :group 'communication
  :prefix "meshtastic-")

(defcustom meshtastic-serial-port "/dev/ttyUSB0"
  "Serial port path for the Meshtastic device."
  :type 'string
  :group 'meshtastic)

(defcustom meshtastic-python-executable "python3"
  "Python executable used to launch the bridge script.
See also `python-shell-interpreter'."
  :type 'string
  :group 'meshtastic)

(defcustom meshtastic-bridge-script
  (expand-file-name
   "meshtastic-bridge.py"
   (file-name-directory
    (or load-file-name
        (locate-library "meshtastic")
        "")))
  "Absolute path to meshtastic-bridge.py."
  :type 'file
  :group 'meshtastic)

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

(defcustom meshtastic-timestamp-format "%H:%M"
  "Format string for message timestamps, passed to `format-time-string'."
  :type 'string
  :group 'meshtastic)

(defcustom meshtastic-message-history 200
  "Number of messages to keep in memory per channel or DM."
  :type 'integer
  :group 'meshtastic)

;;;; Faces

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

(defface meshtastic-nick-self-face
  '((t :foreground "sea green" :weight bold))
  "Face for the local node nick in messages."
  :group 'meshtastic)

(defface meshtastic-nick-other-face
  '((t :foreground "dodger blue" :weight bold))
  "Face for remote node nicks in messages."
  :group 'meshtastic)

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

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

(defface meshtastic-delivery-pending-face
  '((t :foreground "gray50"))
  "Face for the pending delivery indicator."
  :group 'meshtastic)

(defface meshtastic-delivery-confirmed-face
  '((t :foreground "green"))
  "Face for the confirmed delivery indicator."
  :group 'meshtastic)

(defface meshtastic-delivery-failed-face
  '((t :foreground "red"))
  "Face for the failed delivery indicator."
  :group 'meshtastic)

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

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

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

;;;; Internal state

(defvar meshtastic--process nil
  "The bridge subprocess, or nil when stopped.")

(defvar meshtastic--connected nil
  "Non-nil when the bridge has confirmed connection to the device.")

(defvar meshtastic--my-node-id nil
  "Node ID string of the local device, e.g. \"!deadbeef\".")

(defvar meshtastic--my-long-name nil
  "Long name of the local device as reported by the firmware.")

(defvar meshtastic--nodes (make-hash-table :test 'equal)
  "Hash table mapping node ID strings to node alists.")

(defvar meshtastic--channels nil
  "List of channel alists fetched from the device.")

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

(defvar meshtastic--message-rings (make-hash-table :test 'equal)
  "Hash table mapping (TYPE . TARGET) cons to a ring of message alists.")

(defvar meshtastic--cmd-counter 0
  "Monotonic counter for generating unique command IDs.")

(defvar meshtastic--traceroute-entries (make-hash-table :test 'equal)
  "Hash table mapping cmd-id to (status-start-marker . status-end-marker).")

(defvar meshtastic--telemetry (make-hash-table :test 'equal)
  "Hash table mapping node ID strings to their latest telemetry alist.")

(defvar meshtastic--pending-cmds (make-hash-table :test 'equal)
  "Hash table mapping cmd-id string to a delivery callback function.")

;;;; Buffer-local variables

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

(defvar-local meshtastic--target-type nil
  "Target type for this chat buffer: symbol `channel' or `dm'.")

(defvar-local meshtastic--prompt-start nil
  "Marker at the start of the input prompt.")

(defvar-local meshtastic--prompt-end nil
  "Marker at the end of the input prompt (start of user input).")

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

(defvar-local meshtastic--input-ring nil
  "Ring of previous input strings for history navigation.")

(defvar-local meshtastic--input-ring-index 0
  "Current position in `meshtastic--input-ring'.")

(defvar-local meshtastic--pending-send nil
  "Alist of (CMD-ID . MARKER) for messages awaiting delivery confirmation.")

;;;; Timestamp helpers

(defun meshtastic--format-time (ts)
  "Format unix timestamp TS using `meshtastic-timestamp-format'."
  (condition-case nil
      (format-time-string meshtastic-timestamp-format
                          (seconds-to-time ts))
    (error "??:??")))

(defun meshtastic--format-last-heard (ts)
  "Format last-heard unix timestamp TS as a relative string."
  (if (and ts (numberp ts) (> ts 0))
      (let* ((secs (- (float-time) 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)))))
    "?"))

(defun meshtastic--node-online-p (last-heard)
  "Return non-nil if LAST-HEARD is within the last 15 minutes."
  (and last-heard (numberp last-heard) (> last-heard 0)
       (< (- (float-time) last-heard) 900)))

;;;; Node and channel helpers

(defun meshtastic--node-name (id)
  "Return a display name for node ID, falling back to ID itself."
  (let ((node (and id (gethash id meshtastic--nodes))))
    (if node
        (or (alist-get 'longName node)
            (alist-get 'shortName node)
            (format "%s" id))
      (format "%s" (or id "?")))))

(defun meshtastic--channel-name (index)
  "Return the name for channel INDEX, falling back to its number."
  (let ((ch (seq-find (lambda (c) (equal (alist-get 'index c) index))
                      meshtastic--channels)))
    (if ch
        (let ((name (alist-get 'name ch)))
          (if (and name (not (string-empty-p name)))
              name
            (format "%d" index)))
      (format "%d" index))))

(defun meshtastic--node-count ()
  "Return the number of nodes in the in-memory cache."
  (hash-table-count meshtastic--nodes))

(defun meshtastic--is-self-p (msg)
  "Return non-nil if MSG was sent by the local device."
  (let ((from (alist-get 'from msg)))
    (and meshtastic--my-node-id from
         (equal from meshtastic--my-node-id))))

(defun meshtastic--broadcast-p (to-id)
  "Return non-nil if TO-ID represents a broadcast address."
  (or (null to-id)
      (string-empty-p to-id)
      (string= to-id "!ffffffff")
      (string= to-id "^all")
      (string= to-id "4294967295")))

;;;; Message ring buffer

(defun meshtastic--ring-push (ttype target msg)
  "Append MSG to the in-memory ring for TTYPE and TARGET."
  (let ((key (cons ttype target)))
    (let ((ring (or (gethash key meshtastic--message-rings)
                    (let ((r (make-ring meshtastic-message-history)))
                      (puthash key r meshtastic--message-rings)
                      r))))
      (ring-insert ring msg))))

(defun meshtastic--ring-messages (ttype target)
  "Return all buffered messages for TTYPE and TARGET in chronological order."
  (let* ((key (cons ttype target))
         (ring (gethash key meshtastic--message-rings)))
    (when ring
      (let ((msgs nil))
        (dotimes (i (ring-length ring))
          (push (ring-ref ring i) msgs))
        msgs))))

;;;; Process management

(defun meshtastic--cmd-id ()
  "Return a fresh unique command ID string."
  (format "cmd-%d" (cl-incf meshtastic--cmd-counter)))

(defun meshtastic--send-cmd (alist)
  "Serialize ALIST as JSON and send it to the bridge stdin."
  (when (and meshtastic--process
             (process-live-p meshtastic--process))
    (process-send-string
     meshtastic--process
     (concat (json-encode alist) "\n"))))

(defun meshtastic--process-filter (proc string)
  "Accumulate STRING from bridge PROC and dispatch complete JSON lines."
  (let ((buf (concat (or (process-get proc 'partial) "") string)))
    (let ((lines (split-string buf "\n")))
      (process-put proc 'partial (car (last lines)))
      (dolist (line (butlast lines))
        (unless (string-empty-p (string-trim line))
          (meshtastic--handle-line line))))))

(defun meshtastic--process-sentinel (proc event)
  "Handle bridge PROC lifecycle EVENT."
  (unless (process-live-p proc)
    (when (eq meshtastic--process proc)
      (setq meshtastic--connected nil
            meshtastic--process nil)
      (message "Meshtastic: bridge stopped (%s)" (string-trim event))
      (dolist (entry meshtastic--chat-buffers)
        (let ((buf (cdr entry)))
          (when (buffer-live-p buf)
            (meshtastic--insert-msg buf nil nil
                                    "Bridge disconnected" nil t)))))))

(defun meshtastic--handle-line (line)
  "Parse a JSON LINE from the bridge and dispatch to the correct handler."
  (condition-case _err
      (let* ((json-object-type 'alist)
             (json-array-type 'list)
             (json-key-type 'symbol)
             (obj (json-read-from-string line))
             (type (alist-get 'type obj)))
        (pcase type
          ("connected"  (meshtastic--on-connected obj))
          ("message"    (meshtastic--on-message obj))
          ("nodes"      (meshtastic--on-nodes obj))
          ("channels"   (meshtastic--on-channels obj))
          ("info"       (meshtastic--on-info obj))
          ("sent"       (meshtastic--on-sent obj))
          ("traceroute"  (meshtastic--on-traceroute obj))
          ("telemetry"   (meshtastic--on-telemetry obj))
          ("nodeUpdated" (meshtastic--on-node-updated obj))
          ("error"       (message "Meshtastic bridge: %s"
                                  (alist-get 'message obj)))))
    (error (message "Meshtastic bridge stderr: %s" line))))

(defun meshtastic--start ()
  "Launch the Python bridge subprocess."
  (when (and meshtastic--process (process-live-p meshtastic--process))
    (delete-process meshtastic--process))
  (unless (file-exists-p meshtastic-bridge-script)
    (user-error "Bridge script not found: %s" meshtastic-bridge-script))
  (let ((default-directory (file-name-directory meshtastic-bridge-script)))
    (setq meshtastic--process
          (make-process
           :name "meshtastic-bridge"
           :command (append (split-string meshtastic-python-executable)
                            (list "-u"
                                  meshtastic-bridge-script
                                  meshtastic-serial-port))
           :filter #'meshtastic--process-filter
           :sentinel #'meshtastic--process-sentinel
           :connection-type 'pipe
           :noquery t)))
  (message "Meshtastic: connecting on %s..." meshtastic-serial-port))

(defun meshtastic--ensure-connected ()
  "Start the bridge if it is not already running."
  (when (string-empty-p meshtastic-serial-port)
    (user-error "Set `meshtastic-serial-port' in your init file"))
  (unless (and meshtastic--process
               (process-live-p meshtastic--process))
    (meshtastic--start)))

;;;; Bridge event handlers

(defun meshtastic--on-connected (obj)
  "Handle a connected event OBJ from the bridge."
  (setq meshtastic--connected t
        meshtastic--my-node-id (alist-get 'nodeId obj)
        meshtastic--my-long-name (alist-get 'longName obj))
  (meshtastic--send-cmd '((action . "channels")))
  (meshtastic--send-cmd '((action . "nodes")))
  (message "Meshtastic: connected as %s (%s)"
           (or meshtastic--my-long-name "?")
           (or meshtastic--my-node-id "?"))
  (let ((buf (get-buffer "*Meshtastic*")))
    (when (buffer-live-p buf)
      (meshtastic--render-welcome))))

(defun meshtastic--on-message (obj)
  "Handle an incoming message OBJ from the bridge."
  (let* ((from (alist-get 'from obj))
         (to   (alist-get 'to obj))
         (text (alist-get 'text obj))
         (channel (or (alist-get 'channel obj) 0))
         (ttype (if (meshtastic--broadcast-p to) 'channel 'dm))
         (target (if (eq ttype 'channel) channel from)))
    (meshtastic--ring-push ttype target obj)
    (let ((buf (cdr (assoc (cons ttype target) meshtastic--chat-buffers))))
      (when (buffer-live-p buf)
        (meshtastic--render-messages buf (list obj)))
      (unless (and (buffer-live-p buf) (get-buffer-window buf))
        (meshtastic--notify (meshtastic--node-name from)
                            (or text "") ttype target)))))

(defun meshtastic--on-nodes (obj)
  "Handle a nodes data OBJ from the bridge."
  (let ((data (alist-get 'data obj)))
    (when (listp data)
      (clrhash meshtastic--nodes)
      (dolist (node data)
        (let ((id (alist-get 'nodeId node)))
          (when (and id (not (string-empty-p id)))
            (puthash id node meshtastic--nodes))))))
  (let ((buf (get-buffer "*Meshtastic: Nodes*")))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (meshtastic--populate-node-list))))
  (let ((buf (get-buffer "*Meshtastic*")))
    (when (buffer-live-p buf)
      (meshtastic--render-welcome))))

(defun meshtastic--on-channels (obj)
  "Handle a channels data OBJ from the bridge."
  (setq meshtastic--channels (alist-get 'data obj))
  (let ((buf (get-buffer "*Meshtastic: Channels*")))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (meshtastic--populate-channel-list))))
  (let ((buf (get-buffer "*Meshtastic*")))
    (when (buffer-live-p buf)
      (meshtastic--render-welcome))))

(defun meshtastic--on-info (obj)
  "Handle a device info OBJ from the bridge."
  (setq meshtastic--my-node-id  (alist-get 'nodeId obj)
        meshtastic--my-long-name (alist-get 'longName obj)))

(defun meshtastic--on-sent (obj)
  "Handle a sent confirmation OBJ from the bridge."
  (let* ((cmd-id (alist-get 'id obj))
         (cb (and cmd-id (gethash cmd-id meshtastic--pending-cmds))))
    (when cb
      (remhash cmd-id meshtastic--pending-cmds)
      (funcall cb 'confirmed))))

(defun meshtastic--on-traceroute (obj)
  "Handle a traceroute response OBJ from the bridge."
  (let ((cmd-id    (alist-get 'id obj))
        (dest-id   (alist-get 'dest obj))
        (route     (alist-get 'route obj))
        (route-back (alist-get 'routeBack obj))
        (snr-fwd   (alist-get 'snrTowards obj))
        (snr-back  (alist-get 'snrBack obj)))
    (meshtastic--traceroute-set-result
     cmd-id dest-id route route-back snr-fwd snr-back)))

(defun meshtastic--on-telemetry (obj)
  "Handle a telemetry event OBJ from the bridge."
  (let ((from (alist-get 'from obj)))
    (when (and from (not (string-empty-p from)))
      (puthash from obj meshtastic--telemetry)
      (let ((buf (get-buffer "*Meshtastic: Nodes*")))
        (when (buffer-live-p buf)
          (with-current-buffer buf
            (meshtastic--populate-node-list)))))))

(defun meshtastic--on-node-updated (obj)
  "Handle a nodeUpdated event OBJ from the bridge."
  (let ((node-id (alist-get 'nodeId obj)))
    (when (and node-id (not (string-empty-p node-id)))
      (puthash node-id obj meshtastic--nodes)
      (let ((buf (get-buffer "*Meshtastic: Nodes*")))
        (when (buffer-live-p buf)
          (with-current-buffer buf
            (meshtastic--populate-node-list)))))))

;;;; Delivery icons

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

(defun meshtastic--update-delivery (cmd-id state buffer)
  "Replace the delivery icon for CMD-ID with STATE in BUFFER."
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (let ((entry (assoc cmd-id meshtastic--pending-send)))
        (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 (meshtastic--delivery-icon state)))))
          (setq meshtastic--pending-send
                (delq entry meshtastic--pending-send)))))))

;;;; API commands

(defun meshtastic--api-send (text channel dest callback)
  "Send TEXT to CHANNEL index (integer) or DEST node ID (string).
Call CALLBACK with symbol `confirmed' or `failed' when the bridge responds."
  (let* ((cmd-id (meshtastic--cmd-id))
         (alist (list (cons 'action "send")
                      (cons 'text text)
                      (cons 'id cmd-id))))
    (when (and channel (not dest))
      (push (cons 'channel channel) alist))
    (when dest
      (push (cons 'dest dest) alist))
    (when callback
      (puthash cmd-id callback meshtastic--pending-cmds))
    (meshtastic--send-cmd alist)
    cmd-id))

(defun meshtastic--api-nodes ()
  "Request an updated node list from the bridge."
  (meshtastic--send-cmd '((action . "nodes"))))

(defun meshtastic--api-channels ()
  "Request an updated channel list from the bridge."
  (meshtastic--send-cmd '((action . "channels"))))

;;;; Chat mode

(defvar meshtastic-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET")   #'meshtastic-send-input)
    (define-key map (kbd "M-p")   #'meshtastic-previous-input)
    (define-key map (kbd "M-n")   #'meshtastic-next-input)
    (define-key map (kbd "C-c C-l") #'meshtastic-refresh-chat)
    map)
  "Keymap for `meshtastic-mode'.")

(define-derived-mode meshtastic-mode fundamental-mode "MeshChat"
  "Major mode for Meshtastic chat buffers.
Provides an input prompt with message history above."
  :group 'meshtastic
  (setq-local meshtastic--prompt-start (make-marker))
  (setq-local meshtastic--prompt-end (make-marker))
  (setq-local meshtastic--input-ring (make-ring 64))
  (setq-local meshtastic--input-ring-index 0)
  (setq-local meshtastic--seen-ids (make-hash-table :test 'equal))
  (setq-local meshtastic--pending-send nil)
  (setq mode-line-process
        '(" " (:eval (if meshtastic--connected "[on]" "[off]")))))

;;;; Message rendering

(defun meshtastic--insert-at-prompt (buffer fn)
  "In BUFFER, call FN at the prompt insertion point.
Preserves cursor position when the user is at the end of the buffer."
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (let ((inhibit-read-only t)
            (at-end (>= (point) meshtastic--prompt-end)))
        (save-excursion
          (set-marker-insertion-type meshtastic--prompt-start t)
          (set-marker-insertion-type meshtastic--prompt-end t)
          (goto-char meshtastic--prompt-start)
          (funcall fn)
          (set-marker-insertion-type meshtastic--prompt-start nil)
          (set-marker-insertion-type meshtastic--prompt-end nil))
        (when at-end
          (goto-char meshtastic--prompt-end))))))

(defun meshtastic--insert-msg (buffer ts sender text
                                      &optional selfp sysp)
  "Insert a chat line into BUFFER.
TS is a unix timestamp, SENDER the display name, TEXT the message body.
SELFP non-nil marks the message as from the local node.
SYSP non-nil renders a system notification line instead."
  (meshtastic--insert-at-prompt
   buffer
   (lambda ()
     (insert
      (propertize
       (if sysp
           (format "*** %s\n" text)
         (concat
          (propertize (format "[%s] "
                              (if ts (meshtastic--format-time ts) "--:--"))
                      'face 'meshtastic-timestamp-face)
          (propertize (format "<%s> " sender)
                      'face (if selfp
                                'meshtastic-nick-self-face
                              'meshtastic-nick-other-face))
          text "\n"))
       'read-only t
       'rear-nonsticky t
       'front-sticky t
       'face (when sysp 'meshtastic-system-face)
       'meshtastic-msg-text text
       'meshtastic-sender sender)))))

(defun meshtastic--insert-sent-msg (buffer ts sender text cmd-id)
  "Insert an outgoing message with delivery tracking into BUFFER.
TS is the timestamp, SENDER the display name, TEXT the content.
CMD-ID is used to update the delivery icon when the bridge confirms."
  (meshtastic--insert-at-prompt
   buffer
   (lambda ()
     (insert
      (propertize
       (concat
        (propertize (format "[%s] "
                            (if ts (meshtastic--format-time ts) "--:--"))
                    'face 'meshtastic-timestamp-face)
        (propertize (format "<%s> " sender)
                    'face 'meshtastic-nick-self-face)
        text " ")
       'read-only t 'rear-nonsticky t 'front-sticky t
       'meshtastic-msg-text text))
     (let ((icon-pos (point)))
       (insert (meshtastic--delivery-icon 'pending))
       (insert (propertize "\n" 'read-only t 'rear-nonsticky t))
       (when cmd-id
         (push (cons cmd-id (copy-marker icon-pos))
               meshtastic--pending-send))))))

(defun meshtastic--render-messages (buffer messages)
  "Render MESSAGES into BUFFER, skipping already-seen message IDs."
  (when (and (buffer-live-p buffer) messages)
    (let ((sorted (sort (copy-sequence messages)
                        (lambda (a b)
                          (< (or (alist-get 'rxTime a) 0)
                             (or (alist-get 'rxTime b) 0))))))
      (dolist (msg sorted)
        (let ((id (alist-get 'id msg)))
          (unless (and id
                       (with-current-buffer buffer
                         (gethash id meshtastic--seen-ids)))
            (let* ((from   (alist-get 'from msg))
                   (sender (meshtastic--node-name from))
                   (text   (or (alist-get 'text msg) ""))
                   (ts     (alist-get 'rxTime msg))
                   (selfp  (meshtastic--is-self-p msg)))
              (meshtastic--insert-msg buffer ts sender text selfp)
              (unless (or selfp (get-buffer-window buffer))
                (with-current-buffer buffer
                  (meshtastic--notify sender text
                                      meshtastic--target-type
                                      meshtastic--target))))
            (when id
              (with-current-buffer buffer
                (puthash id t meshtastic--seen-ids)))))))))

;;;; Prompt

(defun meshtastic--prompt-string ()
  "Return the prompt string for the current chat buffer."
  (pcase meshtastic--target-type
    ('channel (format "#%s> " (meshtastic--channel-name meshtastic--target)))
    ('dm      (format "%s> "  (meshtastic--node-name meshtastic--target)))
    (_        "Meshtastic> ")))

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

;;;; Input handling

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

(defun meshtastic--send-text (text)
  "Send TEXT to the target of the current chat buffer."
  (unless meshtastic--connected
    (user-error "Meshtastic is not connected"))
  (let* ((buf    (current-buffer))
         (target meshtastic--target)
         (ttype  meshtastic--target-type)
         (sender (or meshtastic--my-long-name
                     meshtastic--my-node-id
                     "me"))
         (cmd-id (meshtastic--cmd-id)))
    (meshtastic--insert-sent-msg buf (float-time) sender text cmd-id)
    (pcase ttype
      ('channel
       (meshtastic--api-send
        text target nil
        (lambda (state)
          (meshtastic--update-delivery cmd-id state buf))))
      ('dm
       (meshtastic--api-send
        text nil target
        (lambda (state)
          (meshtastic--update-delivery cmd-id state buf))))
      (_ (user-error "No target set for this buffer")))))

(defun meshtastic--navigate-input (dir)
  "Move DIR step through the input ring (1 = older, -1 = newer)."
  (when (> (ring-length meshtastic--input-ring) 0)
    (let ((inhibit-read-only t))
      (delete-region meshtastic--prompt-end (point-max))
      (when (= dir -1)
        (setq meshtastic--input-ring-index
              (mod (1- meshtastic--input-ring-index)
                   (ring-length meshtastic--input-ring))))
      (insert (ring-ref meshtastic--input-ring
                        meshtastic--input-ring-index))
      (when (= dir 1)
        (setq meshtastic--input-ring-index
              (mod (1+ meshtastic--input-ring-index)
                   (ring-length meshtastic--input-ring)))))))

(defun meshtastic-previous-input ()
  "Replace the current input with the previous history entry."
  (interactive)
  (meshtastic--navigate-input 1))

(defun meshtastic-next-input ()
  "Replace the current input with the next history entry."
  (interactive)
  (meshtastic--navigate-input -1))

;;;; Buffer management

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

(defun meshtastic--get-or-create-buffer (ttype target)
  "Return an existing or new chat buffer for TTYPE and TARGET."
  (let* ((name (meshtastic--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
        (meshtastic-mode)
        (setq meshtastic--target target
              meshtastic--target-type ttype)
        (meshtastic--setup-prompt))
      (push (cons (cons ttype target) buf) meshtastic--chat-buffers)
      buf)))

(defun meshtastic-refresh-chat ()
  "Re-render buffered messages in the current chat buffer."
  (interactive)
  (unless meshtastic--target
    (user-error "Not a chat buffer"))
  (let ((msgs (meshtastic--ring-messages meshtastic--target-type
                                         meshtastic--target)))
    (when msgs
      (meshtastic--render-messages (current-buffer) msgs)))
  (message "Meshtastic: refreshed"))

;;;; Notifications

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

;;;; Channel list mode

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

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

(defun meshtastic-channel-list-open-by-number ()
  "Open the channel whose number matches the digit key pressed."
  (interactive)
  (meshtastic-open-channel (- last-command-event ?0)))

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

(defun meshtastic-channel-list-refresh ()
  "Request an updated channel list from the device."
  (interactive)
  (meshtastic--api-channels)
  (message "Meshtastic: refreshing channels..."))

(defun meshtastic--populate-channel-list ()
  "Fill the channel list buffer from the in-memory channel cache."
  (let ((role-names '((0 . "Disabled") (1 . "Primary") (2 . "Secondary"))))
    (setq tabulated-list-entries
          (mapcar
           (lambda (ch)
             (let ((idx  (or (alist-get 'index ch) 0))
                   (name (or (alist-get 'name  ch) ""))
                   (role (or (alist-get 'role  ch) 0)))
               (list idx
                     (vector (number-to-string idx)
                             name
                             (or (cdr (assq role role-names))
                                 (format "%d" role))))))
           (seq-filter (lambda (ch) (> (or (alist-get 'role ch) 0) 0))
                       meshtastic--channels)))
    (tabulated-list-print t)))

;;;; Node list mode

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

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

(defun meshtastic--sort-by-hops (a b)
  "Sort tabulated entries A and B by hop count ascending."
  (< (string-to-number (aref (cadr a) 0))
     (string-to-number (aref (cadr b) 0))))

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

(defun meshtastic-node-list-refresh ()
  "Request an updated node list from the device."
  (interactive)
  (meshtastic--api-nodes)
  (message "Meshtastic: refreshing nodes..."))

(defun meshtastic--populate-node-list ()
  "Fill the node list buffer from the in-memory node cache."
  (let ((entries nil))
    (maphash
     (lambda (_key node)
       (let* ((node-id    (alist-get 'nodeId node))
              (hops       (or (alist-get 'hopsAway node) 0))
              (name       (or (alist-get 'longName  node)
                              (alist-get 'shortName node) ""))
              (last-heard (alist-get 'lastHeard node))
              (indicator  (if (meshtastic--node-online-p last-heard)
                              "🟢" "⚫")))
         (let* ((tel     (gethash node-id meshtastic--telemetry))
                (battery (and tel (alist-get 'batteryLevel tel)))
                (bat-str (if (and battery (numberp battery))
                             (format "%d%%" battery)
                           "-")))
           (push (list node-id
                       (vector (number-to-string hops)
                               (format "%s %s" indicator name)
                               (or node-id "")
                               (meshtastic--format-last-heard last-heard)
                               bat-str))
                 entries))))
     meshtastic--nodes)
    (setq tabulated-list-entries entries)
    (tabulated-list-print t)))

;;;; Node actions

(defun meshtastic--node-id-at-point ()
  "Return the node ID string from the tabulated-list entry at point."
  (let ((entry (tabulated-list-get-entry)))
    (when (and entry (> (length entry) 2))
      (aref entry 2))))

(defun meshtastic--node-id-context ()
  "Return a contextual node ID from the current buffer.
Tries the tabulated-list entry at point first, then the DM target."
  (or (meshtastic--node-id-at-point)
      (and (eq meshtastic--target-type 'dm) meshtastic--target)))

;;;###autoload
(defun meshtastic-traceroute (node-id)
  "Send a traceroute to NODE-ID via the bridge.
When called from the node list, uses the node at point.
When called from a DM buffer, uses the DM target.
Otherwise prompts for a node ID."
  (interactive
   (list (or (meshtastic--node-id-context)
             (read-string "Node ID: "))))
  (meshtastic--send-traceroute node-id))

(defun meshtastic--send-traceroute (node-id)
  "Send a traceroute request to NODE-ID and show the traceroute buffer."
  (unless meshtastic--connected
    (user-error "Not connected"))
  (let ((cmd-id (meshtastic--cmd-id)))
    (meshtastic--traceroute-append cmd-id node-id)
    (meshtastic--send-cmd
     `((action   . "traceroute")
       (id       . ,cmd-id)
       (dest     . ,node-id)
       (hopLimit . 7)))
    (display-buffer (meshtastic--traceroute-buffer))
    (message "Meshtastic: traceroute to %s..."
             (meshtastic--node-name node-id))))

;;;; Traceroute buffer

(defvar meshtastic-traceroute-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "q") #'quit-window)
    map)
  "Keymap for `meshtastic-traceroute-mode'.")

(define-derived-mode meshtastic-traceroute-mode special-mode "MeshTrace"
  "Major mode for the Meshtastic traceroute log."
  :group 'meshtastic
  (buffer-disable-undo)
  (setq truncate-lines t))

(defun meshtastic--traceroute-buffer ()
  "Return the traceroute log buffer, creating it if needed."
  (let ((buf (get-buffer-create "*Meshtastic: Traceroute*")))
    (with-current-buffer buf
      (unless (derived-mode-p 'meshtastic-traceroute-mode)
        (meshtastic-traceroute-mode)))
    buf))

(defun meshtastic--format-route-segment (nodes snrs)
  "Return \" → node (snr)\" string for each element in NODES paired with SNRS."
  (let ((result ""))
    (cl-mapcar
     (lambda (node snr)
       (setq result
             (concat result
                     (format " → %s%s"
                             (meshtastic--node-name node)
                             (if snr (format " (%.1fdB)" snr) "")))))
     nodes
     (append snrs (make-list (max 0 (- (length nodes) (length snrs))) nil)))
    result))

(defun meshtastic--traceroute-format-result (dest-id route route-back snr-fwd snr-back)
  "Return formatted multi-line status for a completed traceroute to DEST-ID.
ROUTE and ROUTE-BACK are lists of intermediate node ID strings.
SNR-FWD and SNR-BACK are lists of SNR floats (dB) for each hop."
  (let* ((local (or meshtastic--my-long-name meshtastic--my-node-id "Local"))
         (dest  (meshtastic--node-name dest-id))
         (fwd   (concat local
                        (meshtastic--format-route-segment route snr-fwd)
                        " → " dest))
         (bck   (concat dest
                        (meshtastic--format-route-segment route-back snr-back)
                        " → " local)))
    (concat
     (propertize (format "  ✓ → %s\n" fwd) 'face 'meshtastic-delivery-confirmed-face)
     (propertize (format "  ← %s\n" bck) 'face 'meshtastic-system-face))))

(defun meshtastic--traceroute-append (cmd-id dest-id)
  "Append a pending entry for CMD-ID targeting DEST-ID to the traceroute buffer."
  (let ((buf (meshtastic--traceroute-buffer)))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (goto-char (point-max))
        (insert (format "[%s] → %s (%s)\n"
                        (format-time-string "%H:%M:%S")
                        (meshtastic--node-name dest-id)
                        dest-id))
        (let ((s-start (copy-marker (point))))
          (insert "  ⧖ Pending...\n")
          (let ((s-end (copy-marker (point))))
            (insert "\n")
            (puthash cmd-id (cons s-start s-end)
                     meshtastic--traceroute-entries)))))))

(defun meshtastic--traceroute-set-result (cmd-id dest-id route route-back snr-fwd snr-back)
  "Replace the pending status for CMD-ID with route result data.
DEST-ID is the destination node ID.  ROUTE and ROUTE-BACK are lists of
intermediate node ID strings.  SNR-FWD and SNR-BACK are SNR floats in dB."
  (let* ((entry (gethash cmd-id meshtastic--traceroute-entries))
         (buf   (get-buffer "*Meshtastic: Traceroute*")))
    (when (and entry (buffer-live-p buf))
      (let ((s-start (car entry))
            (s-end   (cdr entry)))
        (when (and (marker-buffer s-start) (marker-buffer s-end))
          (with-current-buffer buf
            (let ((inhibit-read-only t))
              (delete-region s-start s-end)
              (save-excursion
                (goto-char s-start)
                (insert (meshtastic--traceroute-format-result
                         dest-id route route-back snr-fwd snr-back)))))
          (remhash cmd-id meshtastic--traceroute-entries))))))

;;;; Position

(defun meshtastic--calendar-to-float (val)
  "Convert calendar coordinate VAL to a float.
Handles both plain numbers and (degrees minutes direction) lists."
  (cond
   ((numberp val) (float val))
   ((and (listp val) (>= (length val) 2))
    (let* ((deg (float (nth 0 val)))
           (min (float (nth 1 val)))
           (dir (nth 2 val))
           (abs (+ deg (/ min 60.0))))
      (if (memq dir '(south west)) (- abs) abs)))
   (t nil)))

(defun meshtastic--get-emacs-position ()
  "Return (lat lon) from Emacs calendar variables, or nil if not set."
  (when (and (boundp 'calendar-latitude)
             (boundp 'calendar-longitude)
             calendar-latitude
             calendar-longitude)
    (let ((lat (meshtastic--calendar-to-float calendar-latitude))
          (lon (meshtastic--calendar-to-float calendar-longitude)))
      (when (and lat lon)
        (list lat lon)))))

;;;###autoload
(defun meshtastic-send-position (node-id)
  "Send GPS position to NODE-ID.
Uses `calendar-latitude' and `calendar-longitude' if set, otherwise
falls back to the device GPS.  Signals an error if neither is available.
When called from the node list, uses the node at point.
When called from a DM buffer, uses the DM target.
See also `calendar-latitude', `calendar-longitude'."
  (interactive
   (list (or (meshtastic--node-id-context)
             (read-string "Node ID: "))))
  (unless meshtastic--connected
    (user-error "Not connected"))
  (let* ((pos     (meshtastic--get-emacs-position))
         (cmd-id  (meshtastic--cmd-id))
         (alist   `((action . "sendposition")
                    (id     . ,cmd-id)
                    (dest   . ,node-id))))
    (when pos
      (push `(lon . ,(cadr pos)) alist)
      (push `(lat . ,(car  pos)) alist))
    (meshtastic--send-cmd alist)
    (message "Meshtastic: sending position to %s..."
             (meshtastic--node-name node-id))))

;;;; Welcome buffer

(defvar meshtastic-welcome-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "c") #'meshtastic-channels)
    (define-key map (kbd "n") #'meshtastic-nodes)
    (define-key map (kbd "g") #'meshtastic-refresh)
    (define-key map (kbd "q") #'quit-window)
    map)
  "Keymap for `meshtastic-welcome-mode'.")

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

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

(defun meshtastic--render-welcome ()
  "Render the welcome buffer and return it."
  (let ((buf (get-buffer-create "*Meshtastic*")))
    (with-current-buffer buf
      (unless (derived-mode-p 'meshtastic-welcome-mode)
        (meshtastic-welcome-mode))
      (let ((inhibit-read-only t)
            (sep (propertize (format "  %s\n" (make-string 38 ?-))
                             'face 'meshtastic-timestamp-face)))
        (erase-buffer)
        (insert "\n")
        (insert (propertize "  Meshtastic\n"
                            'face 'meshtastic-welcome-title-face))
        (insert (propertize (format "  %s\n" (make-string 38 ?=))
                            'face 'meshtastic-timestamp-face))
        (insert "\n")
        (insert (propertize "  Connection\n"
                            'face 'meshtastic-welcome-heading-face))
        (insert (format "  Port:      %s\n" meshtastic-serial-port))
        (insert (format "  Status:    %s\n"
                        (propertize
                         (cond
                          (meshtastic--connected "Connected")
                          ((and meshtastic--process
                                (process-live-p meshtastic--process))
                           "Connecting...")
                          (t "Disconnected"))
                         'face (cond
                                (meshtastic--connected
                                 'meshtastic-delivery-confirmed-face)
                                ((and meshtastic--process
                                      (process-live-p meshtastic--process))
                                 'meshtastic-delivery-pending-face)
                                (t
                                 'meshtastic-delivery-failed-face)))))
        (when meshtastic--my-long-name
          (insert (format "  Node:      %s (%s)\n"
                          meshtastic--my-long-name
                          meshtastic--my-node-id)))
        (insert "\n")
        (when (> (meshtastic--node-count) 0)
          (insert (propertize "  Statistics\n"
                              'face 'meshtastic-welcome-heading-face))
          (insert (format "  Nodes:     %d\n" (meshtastic--node-count)))
          (when meshtastic--channels
            (insert (format "  Channels:  %d\n"
                            (length
                             (seq-filter
                              (lambda (c) (> (or (alist-get 'role c) 0) 0))
                              meshtastic--channels)))))
          (insert "\n"))
        (insert sep)
        (insert "\n")
        (meshtastic--insert-shortcut "c" "Channels")
        (meshtastic--insert-shortcut "n" "Nodes")
        (insert "\n")
        (meshtastic--insert-shortcut "g" "Refresh")
        (meshtastic--insert-shortcut "q" "Quit")
        (insert "\n\n")
        (insert sep)
        (goto-char (point-min))))
    buf))

;;;; Open chat buffers

(defun meshtastic-open-channel (channel-id)
  "Open a chat buffer for CHANNEL-ID and load buffered messages."
  (meshtastic--ensure-connected)
  (let ((buf (meshtastic--get-or-create-buffer 'channel channel-id)))
    (switch-to-buffer buf)
    (let ((msgs (meshtastic--ring-messages 'channel channel-id)))
      (when msgs
        (meshtastic--render-messages buf msgs)))))

(defun meshtastic-open-dm (node-id)
  "Open a DM chat buffer with NODE-ID and load buffered messages."
  (meshtastic--ensure-connected)
  (let ((buf (meshtastic--get-or-create-buffer 'dm node-id)))
    (switch-to-buffer buf)
    (let ((msgs (meshtastic--ring-messages 'dm node-id)))
      (when msgs
        (meshtastic--render-messages buf msgs)))))

;;;; Entry points

;;;###autoload
(defun meshtastic-channels ()
  "Show the Meshtastic channel list buffer."
  (interactive)
  (meshtastic--ensure-connected)
  (let ((buf (get-buffer-create "*Meshtastic: Channels*")))
    (with-current-buffer buf
      (unless (derived-mode-p 'meshtastic-channel-list-mode)
        (meshtastic-channel-list-mode))
      (meshtastic--populate-channel-list))
    (switch-to-buffer buf)))

;;;###autoload
(defun meshtastic-nodes ()
  "Show the Meshtastic node list buffer."
  (interactive)
  (meshtastic--ensure-connected)
  (meshtastic--api-nodes)
  (let ((buf (get-buffer-create "*Meshtastic: Nodes*")))
    (with-current-buffer buf
      (unless (derived-mode-p 'meshtastic-node-list-mode)
        (meshtastic-node-list-mode))
      (meshtastic--populate-node-list))
    (switch-to-buffer buf)))

;;;###autoload
(defun meshtastic ()
  "Open the Meshtastic welcome screen and start the bridge if needed."
  (interactive)
  (meshtastic--ensure-connected)
  (switch-to-buffer (meshtastic--render-welcome)))

;;;###autoload
(defun meshtastic-disconnect ()
  "Stop the bridge subprocess and reset all state."
  (interactive)
  (when (and meshtastic--process (process-live-p meshtastic--process))
    (meshtastic--send-cmd '((action . "quit")))
    (sit-for 0.5)
    (when (process-live-p meshtastic--process)
      (delete-process meshtastic--process)))
  (setq meshtastic--process     nil
        meshtastic--connected   nil
        meshtastic--my-node-id  nil
        meshtastic--my-long-name nil
        meshtastic--channels    nil
        meshtastic--chat-buffers nil)
  (clrhash meshtastic--nodes)
  (clrhash meshtastic--message-rings)
  (clrhash meshtastic--pending-cmds)
  (clrhash meshtastic--traceroute-entries)
  (clrhash meshtastic--telemetry)
  (message "Meshtastic: disconnected"))

(defun meshtastic-refresh ()
  "Refresh the welcome buffer with current state."
  (interactive)
  (meshtastic--render-welcome))

(provide 'meshtastic)
;;; meshtastic.el ends here
