;;; dmsg.el --- Timestamped debug messages with backtrace support -*- lexical-binding: t -*-

;; Copyright (C) 2026 Free Software Foundation, Inc.
;; Author: Al Haji-Ali <abdo.haji.ali@gmail.com>
;; URL: https://github.com/haji-ali/dmsg.el
;; Version: 0.2
;; Package-Requires: ((emacs "28.1"))
;; Keywords: maint, tools
;;
;; 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:
;; `dmsg' writes structured entries to a dedicated buffer and provides
;; `dmsg-mode' to interact with the buffer.
;;
;; Buffer format:
;;
;;   [LVL] [YYYY-MM-DD HH:MM:SS.mmm] first line of message
;;    continuation line               (exactly one leading space per \n)
;;   (fn-name args)                backtrace frame
;;   (fn-name ...)                unevaluated frame
;;
;;
;; Default keys (dmsg-mode):
;;   <tab>   Toggle compact fn <- fn <- fn chain for entry at point
;;   b       Open detailed backtrace for entry at point in a side window
;;   c       Clear entries without modifying buffer (toggle)
;;   e       Erase buffer (destructive)
;;   f       Filter entries by regexp
;;   s       Snapshot visible entries to a .log file
;;   l1-l4   Set minimum display level (l1=debug l2=info l3=warn l4=error)
;;
;;;; Usage:
;;   (require 'dmsg)
;;   (dmsg "value is %S" x)
;;   (dmsg 'warn "something odd: %=S" x)

;;; Code:

(require 'cl-lib)
(require 'mule-util)


(cl-defstruct (dmsg--level (:constructor dmsg--level-create (symbol label face)))
  symbol label face)

(defconst dmsg--levels
  (list (dmsg--level-create 'debug "DBG" 'dmsg-level-debug-face)
        (dmsg--level-create 'info  "INF" 'dmsg-level-info-face)
        (dmsg--level-create 'warn  "WRN" 'dmsg-level-warn-face)
        (dmsg--level-create 'error "ERR" 'dmsg-level-error-face))
  "Level definitions in increasing severity order.")

(defconst dmsg--level-order
  (mapcar #'dmsg--level-symbol dmsg--levels)
  "Level symbols ordered least-to-most severe, derived from `dmsg--levels'.")

(defconst dmsg--header-re
  (concat "^\\* \\("
          (mapconcat #'dmsg--level-label dmsg--levels "\\|")
          "\\) \\[\\([^]]+\\)\\]")
  "Regexp matching an entry header at column 0.
Group 1: label (e.g. \"DBG\").  Group 2: timestamp string.
After `(match-end 0)' is an optional space then the first message line.")

(defgroup dmsg nil
  "Timestamped debug messages with collapsible backtraces."
  :group 'development
  :prefix "dmsg-")

(defcustom dmsg-buffer-name "*DEBUG*Messages*"
  "Name of the buffer where debug messages are collected."
  :type 'string)

(defcustom dmsg-backtrace-buffer-name "*DEBUG*Backtrace*"
  "Name of the buffer used to display detailed backtraces."
  :type 'string)

(defcustom dmsg-min-level 'debug
  "Minimum severity level to display.
Entries below this level are hidden by an overlay, never deleted.
Changing this via `customize' or `dmsg-set-min-level' refreshes the
buffer."
  :type `(choice
          ,@(mapcar (lambda (e)
                      `(const :tag
                              ,(capitalize (symbol-name
                                            (dmsg--level-symbol e)))
                              ,(dmsg--level-symbol e)))
                    dmsg--levels))
  :set (lambda (sym val)
         (set-default sym val)
         (when-let* ((buf (and (boundp 'dmsg-buffer-name)
                               (get-buffer dmsg-buffer-name))))
           (with-current-buffer buf
             (when (derived-mode-p 'dmsg-mode)
               (dmsg--refresh-visibility))))))

(defcustom dmsg-show-caller t
  "If non-nil, append a clickable caller tag to each entry header."
  :type 'boolean)

(defcustom dmsg-max-entries nil
  "When non-nil, hide the oldest entries that exceed this count.
Buffer text is never deleted by this limit.
Changing this via `customize' refreshes the buffer."
  :type '(choice (const :tag "Unlimited" nil) integer)
  :set (lambda (sym val)
         (set-default sym val)
         (when-let* ((buf (and (boundp 'dmsg-buffer-name)
                               (get-buffer dmsg-buffer-name))))
           (with-current-buffer buf
             (when (derived-mode-p 'dmsg-mode)
               (dmsg--refresh-visibility))))))

(defcustom dmsg-message-continuation-indent "  "
  "String to indent message continuation lines."
  :type 'string)

(defcustom dmsg-compact-skip-functions
  '("edebug.*" debug-after apply funcall (pred special-form-p))
  "Functions to omit from the compact backtrace chain.
Each element: a symbol (eq match), a regexp string (name match),
or a list (pred FN) where FN is called with the symbol."
  :type '(repeat (choice symbol regexp)))

(defcustom dmsg-detailed-arg-max-length 100
  "Maximum displayed characters per argument in the detailed backtrace."
  :type 'natnum)

(defface dmsg-timestamp-face
  '((t :foreground "gray50" :weight light))
  "Timestamp.")
(defface dmsg-caller-face
  '((t :foreground "medium sea green" :underline t))
  "Caller tag.")
(defface dmsg-compact-bt-face
  '((t :foreground "gray55" :slant italic)) "Compact chain.")
(defface dmsg-level-debug-face
  '((t :foreground "gray60"))              "DEBUG.")
(defface dmsg-level-info-face
  '((t :foreground "deep sky blue"))       "INFO.")
(defface dmsg-level-warn-face
  '((t :foreground "orange" :weight bold)) "WARN.")
(defface dmsg-level-error-face
  '((t :foreground "tomato" :weight bold)) "ERROR.")

;;;; Level data accessors

(defsubst dmsg--level (sym)
  "Return the `dmsg--level' struct for SYM, or nil."
  (cl-find sym dmsg--levels :key #'dmsg--level-symbol))

(defsubst dmsg--label->level (lbl)
  "Return the `dmsg--level' struct for label string LBL, or nil."
  (cl-find lbl dmsg--levels :key #'dmsg--level-label :test #'equal))

;;;; Buffer-local state

(defvar-local dmsg--entry-count 0
  "Total entry count including hidden entries.
Incremented by `dmsg-write', reset by `dmsg--scan-buffer'.")

(defvar-local dmsg--visible-count 0
  "Count of currently visible (non-hidden) entries.
Maintained incrementally by `dmsg--on-new-entry' and
recomputed from scratch by `dmsg--refresh-visibility'.")

(defvar-local dmsg--filter-regexp nil
  "Active regexp filter string, or nil.  Stored separately for header display.")

(defvar-local dmsg--hide-predicates nil
  "Alist of (KEY . PRED).
PRED is called with the buffer position of an entry header; non-nil means
hide that entry.  Managed via `dmsg--set-predicate'.
Built-in keys used internally: `regexp', `clear'.")

(defvar dmsg-mode-map
  (let ((m (make-sparse-keymap)))
    (define-key m [tab] #'dmsg-toggle-compact)
    (define-key m "b" #'dmsg-show-backtrace)
    (define-key m "c" #'dmsg-clear)
    (define-key m "e" #'dmsg-erase)
    (define-key m "f" #'dmsg-filter)
    (define-key m "s" #'dmsg-snapshot)
    ;; Level shortcuts: l1=debug l2=info l3=warn l4=error
    (cl-loop for lvl in dmsg--level-order
             for key from ?1
             do
             (define-key m (concat "l" (char-to-string key))
                         (lambda () (interactive) (dmsg-set-min-level lvl))))
    m)
  "Keymap for `dmsg-mode'.")

(defun dmsg-jump-to-def (&optional e)
  "Jump to the definition of the dmsg function label at point or event E."
  (interactive "e")
  (when-let* ((fn (get-text-property
                   (if e
                       (posn-point (event-start e))
                     (point))
                   'dmsg-fn)))
    (condition-case err (find-function fn)
      (error (message "%s" (error-message-string err))))))

(defvar dmsg--fn-keymap
  (let ((km (make-sparse-keymap)))
    (define-key km [mouse-1] #'dmsg-jump-to-def)
    (define-key km (kbd "RET") (lambda () (interactive) (dmsg-jump-to-def)))
    km)
  "Keymap for dmsg function-jump labels.
Reads the target symbol from the `dmsg-fn' text property at point.")

(define-derived-mode dmsg-mode special-mode "DMsg"
  "Major mode for `dmsg' output.  Buffer text is the sole persistent state.
Save the buffer, reopen it and re-enable this mode to restore interactivity.

\\{dmsg-mode-map}"
  (setq-local truncate-lines t)
  ;; dmsg--teardown runs on any mode change, including switch to
  ;; fundamental-mode.
  (add-hook 'change-major-mode-hook #'dmsg--teardown nil t)
  (dmsg--scan-buffer)
  (dmsg--refresh-visibility))

(defun dmsg--teardown ()
  "Remove all dmsg overlays and text properties.
Runs on `change-major-mode-hook', covering the switch to `fundamental-mode'."
  (remove-overlays (point-min) (point-max) 'dmsg-ov t)
  (with-silent-modifications
    (remove-text-properties (point-min) (point-max)
                            '(dmsg-entry nil invisible nil
                                         dmsg-level nil face nil)))
  (setq dmsg--entry-count    0
        dmsg--visible-count  0
        dmsg--filter-regexp  nil
        dmsg--hide-predicates nil)
  (kill-local-variable 'header-line-format))

(defun dmsg--make-ov (start end &rest props)
  "Create an overlay START-END tagged `dmsg-ov t', with additional PROPS.
The tag is required for `remove-overlays' calls in teardown and scan."
  (let ((ov (make-overlay start end)))
    (overlay-put ov 'dmsg-ov t)
    (cl-loop for (k v) on props by #'cddr do (overlay-put ov k v))
    ov))

;;;; Entry navigation

(defun dmsg--entry-end (entry-pos)
  "Return position just past all lines belonging to the entry at ENTRY-POS."
  (save-excursion
    (goto-char entry-pos)
    (forward-line 1)
    (or (and (re-search-forward dmsg--header-re nil t)
             (line-beginning-position))
        (point-max))))

(defun dmsg--bt-start (entry-pos)
  "Return start of the `backtrace-frame' block for the entry at ENTRY-POS.
Skips the header line and any message-continuation lines (leading space)."
  (save-excursion
    (goto-char entry-pos)
    (forward-line 1)
    (or (and (re-search-forward "^[^ \n]" nil t)
             (line-beginning-position))
        (point-max))))

(defun dmsg--entry-at-point ()
  "Return buffer position of the entry header at or enclosing point, or nil."
  (save-excursion
    (end-of-line)
    (when (re-search-backward dmsg--header-re nil t)
      (point))))

(defun dmsg--entry-message (entry-pos)
  "Return the full message string for the entry at ENTRY-POS.
Newlines are restored: each continuation line contributes one joined part."
  (save-excursion
    (goto-char entry-pos)
    (let (lines)
      ;; First line: text following the header (after "[LVL] [TS]" + optional
      ;; space)
      (when (looking-at dmsg--header-re)
        (let ((from (match-end 0)))
          (when (and (< from (line-end-position))
                     (eq (char-after from) ?\s))
            (cl-incf from))
          (push (buffer-substring-no-properties from (line-end-position))
                lines)))
      ;; Continuation lines: exactly one leading space
      (forward-line 1)
      (while (and (not (eobp)) (eq (char-after) ?\s))
        (push (buffer-substring-no-properties
               (1+ (point)) (line-end-position))
              lines)
        (forward-line 1))
      (string-join (nreverse lines) "\n"))))

(defun dmsg--scan-buffer ()
  "Scan all entries; set text properties and create display overlays.
Idempotent.  Does not modify buffer text."
  (dmsg--teardown)
  (save-excursion
    (goto-char (point-min))
    (while (search-forward-regexp dmsg--header-re nil t)
      (cl-incf dmsg--entry-count)
      (beginning-of-line)
      (dmsg--apply-entry-display (point))
      (end-of-line))))

(defun dmsg--apply-entry-display (entry-pos)
  "Apply faces, hide BT lines, and add caller tag for the entry at ENTRY-POS.
Called from `dmsg--scan-buffer' and from `dmsg-write' after writing.
Never inserts, deletes, or modifies buffer text."
  (save-excursion
    (goto-char entry-pos)
    (when (looking-at dmsg--header-re)
      (let* ((level   (dmsg--label->level (match-string 1)))
             (level-start   (match-beginning 1))
             (ts-b    (match-beginning 2))
             (ts-e    (match-end 2))
             (hdr-end (line-end-position)))
        (with-silent-modifications
          ;; Hide anything before level
          (put-text-property entry-pos level-start 'invisible t)
          (put-text-property entry-pos (1+ hdr-end) 'dmsg-entry t)
          (put-text-property entry-pos (1+ hdr-end) 'dmsg-level
                             (dmsg--level-symbol level))
          (put-text-property entry-pos (1- ts-b)
                             'face (dmsg--level-face level))
          (put-text-property (1- ts-b)  (1+ ts-e)  'face 'dmsg-timestamp-face)
          ;; Message continuation lines: replace leading space with indent
          ;; string.
          (forward-line 1)
          (while (and (not (eobp)) (eq (char-after) ?\s))
            (put-text-property (point) (1+ (point))
                               'display dmsg-message-continuation-indent)
            (forward-line 1)))
        ;; Backtrace block: walk to the next header (or eob).  bt-end is
        ;; simply (point) after the walk. Do NOT use (line-end-position) here,
        ;; which would refer to the next entry.
        (let* ((bt-start (point))
               (bt-end   (or (when (re-search-forward "^\\*" nil t)
                               (1- (point)))
                             (point-max)))
               (chain    (dmsg--compact-chain bt-start bt-end))
               (chain-str (if chain
                              (let* ((arrow (if (char-displayable-p ?←)
                                                " ← " " < "))
                                     (sep    (propertize arrow 'face
                                                         'dmsg-compact-bt-face))
                                     (indent "  "))
                                (with-temp-buffer
                                  (insert indent)
                                  (cl-loop
                                   for (fn . rest) on chain do
                                   (let* ((name (symbol-name fn))
                                          (item (dmsg--buttonify-fn
                                                 ;; Copy string to avoid
                                                 ;; modifying symbol-name
                                                 fn (substring name))))
                                     (when (and fill-column
                                                (> (+ (current-column)
                                                      (length sep)
                                                      (length name))
                                                   fill-column))
                                       (insert "\n" indent indent))
                                     (insert item)
                                     (when rest
                                       (insert sep))))
                                  (insert "\n")
                                  (buffer-string)))
                            (propertize "  (no frames)\n" 'face
                                        'dmsg-compact-bt-face))))
          (dmsg--make-ov bt-start bt-end
                         'dmsg-bt chain-str
                         'invisible t
                         'category  'dmsg-bt)
          ;; Caller tag: zero-width overlay appending a button after the
          ;; timestamp.  Uses the shared keymap via `dmsg-fn' text property.
          (when-let* ((caller (and dmsg-show-caller (car chain))))
            (dmsg--make-ov (1+ ts-e) (+ ts-e 2)
                           'display
                           (concat " "
                                   (dmsg--buttonify-fn
                                    caller
                                    (format "[%s]" (symbol-name caller)))
                                   " ")
                           'category 'dmsg-caller)))))))

(defun dmsg--compact-chain (bt-start bt-end)
  "Return a list of non-skipped function symbols from BT-START to BT-END.
Frames are stored innermost-first; the returned list is also innermost-first,
so `(car chain)' is the direct caller of `dmsg'."
  (let (fns)
    (save-excursion
      (goto-char bt-start)
      (while (re-search-forward "^(\\([^ )]+\\)" bt-end t)
        (let ((fn (intern-soft (match-string 1))))
          (when (and fn (not
                         (cl-some (lambda (pat)
                                    (cond
                                     ((eq (car-safe pat) 'pred)
                                      (funcall (cadr pat) fn))
                                     ((stringp pat)
                                      (string-match-p pat (symbol-name fn)))
                                     (t
                                      (eq fn pat))))
                                  dmsg-compact-skip-functions)))
            (push fn fns)))))
    (nreverse fns)))

;;;; Predicate-based filter system
(defun dmsg--set-predicate (key pred-or-nil)
  "Add or replace predicate KEY in `dmsg--hide-predicates', then refresh.
If PRED-OR-NIL is nil, remove KEY instead.  Triggers `dmsg--refresh-visibility'."
  (if pred-or-nil
      (setf (alist-get key dmsg--hide-predicates) pred-or-nil)
    (setq dmsg--hide-predicates (assq-delete-all key dmsg--hide-predicates)))
  (dmsg--refresh-visibility))

(defun dmsg--entry-hidden-p (pos)
  "Return non-nil if the entry at POS already has a hide overlay."
  (cl-loop for ov being overlays from pos to pos
	   thereis (eq (overlay-get ov 'category) 'dmsg-hide)))

(defun dmsg--should-hide-p (pos)
  "Return non-nil if the entry at POS should be hidden.
Checks min-level and all active predicates; does NOT check max-entries."
  (let ((level (get-text-property pos 'dmsg-level)))
    (or (not (memq level (memq dmsg-min-level dmsg--level-order)))
        (cl-some (lambda (kp) (funcall (cdr kp) pos))
                 dmsg--hide-predicates))))

(defun dmsg--refresh-visibility ()
  "Recompute all hide overlays from scratch.
Called when predicates or settings change, not on individual new
entries.  Uses `remove-overlays' to delete existing hide
overlays (tagged `category dmsg-hide'), then rebuilds them by scanning
the buffer."
  (remove-overlays (point-min) (point-max) 'category 'dmsg-hide)
  (setq dmsg--visible-count 0)
  (let ((n 0))
    (save-excursion
      (goto-char (point-min))
      (while (not (eobp))
        (when (get-text-property (point) 'dmsg-entry)
          (cl-incf n)
          (let* ((pos    (point))
                 (hidden (or (and dmsg-max-entries
                                  (<= n (- dmsg--entry-count dmsg-max-entries)))
                             (dmsg--should-hide-p pos))))
            (if hidden
                (dmsg--make-ov pos (dmsg--entry-end pos)
                               'invisible t 'category 'dmsg-hide)
              (cl-incf dmsg--visible-count))))
        (forward-line 1))))
  (dmsg--update-header))

(defun dmsg--on-new-entry (entry-pos)
  "Apply visibility to the newly appended entry at ENTRY-POS.
Only this entry is examined, existing entries are unaffected.  Also
hides the oldest newly-excess entry when `dmsg-max-entries' is active."
  ;; Step 1: hide the new entry if it fails level or predicate checks.
  (if (dmsg--should-hide-p entry-pos)
      (dmsg--make-ov entry-pos (dmsg--entry-end entry-pos)
                     'invisible t 'category 'dmsg-hide)
    (cl-incf dmsg--visible-count))
  ;; Step 2: if max-entries is exceeded, hide the oldest non-hidden excess entry.
  ;; The entry that just became excess is at 1-based index
  ;; (entry-count - max-entries) from the start of the buffer.
  (when (and dmsg-max-entries (> dmsg--entry-count dmsg-max-entries))
    (dmsg--hide-nth-entry (- dmsg--entry-count dmsg-max-entries)))
  (dmsg--update-header))

(defun dmsg--hide-nth-entry (n)
  "Hide the Nth entry (1-based, oldest first) if it is not already hidden.
Decrements `dmsg--visible-count' when the entry was previously visible."
  (catch 'done
    (save-excursion
      (goto-char (point-min))
      (let ((count 0))
        (while (not (eobp))
          (when (get-text-property (point) 'dmsg-entry)
            (cl-incf count)
            (when (= count n)
              (let ((pos (point)))
                (unless (dmsg--entry-hidden-p pos)
                  (dmsg--make-ov pos (dmsg--entry-end pos)
                                 'invisible t 'category 'dmsg-hide)
                  (cl-decf dmsg--visible-count))
                (throw 'done nil))))
          (forward-line 1))))))

(defun dmsg--update-header ()
  "Set `header-line-format' from buffer-local counts and active conditions."
  (setq header-line-format
        (concat
         (propertize (format "[%d/%d]" dmsg--visible-count dmsg--entry-count)
                     'face 'dmsg-timestamp-face)
         (and dmsg--filter-regexp
              (propertize (format "  filter: %s" dmsg--filter-regexp)))
         (and (assq 'clear dmsg--hide-predicates)
              (propertize "  [cleared. Press c to restore]"))
         (and (not (eq dmsg-min-level 'debug))
              (propertize (format "  min-level: %s"
                                  (dmsg--level-label
                                   (dmsg--level dmsg-min-level))))))))

;;;###autoload
(defun dmsg-toggle-compact ()
  "Toggle the compact fn <- fn <- fn chain for the entry at point.
The chain is derived on-the-fly from the hidden backtrace lines.
Press <tab> again to hide it."
  (interactive)
  (let ((entry-pos (dmsg--entry-at-point)))
    (unless entry-pos (user-error "No dmsg entry at point"))
    (when-let* ((entry-end (dmsg--entry-end entry-pos))
                (bt-ov  (cl-find-if (lambda (ov) (eq
                                                  (overlay-get ov 'category)
                                                  'dmsg-bt))
                                    (overlays-in entry-pos entry-end))))
      (overlay-put bt-ov
                   'display
                   (and (not (overlay-get bt-ov 'display))
                        (overlay-get bt-ov 'dmsg-bt))))))

;;;###autoload
(defun dmsg-show-backtrace ()
  "Open the detailed backtrace for the entry at point in a side window."
  (interactive)
  (let ((entry-pos (dmsg--entry-at-point)))
    (unless entry-pos (user-error "No dmsg entry at point"))
    (let* ((hdr      (save-excursion
                       (goto-char entry-pos)
                       (buffer-substring-no-properties (point) (line-end-position))))
           (level     (and (string-match dmsg--header-re hdr)
                           (dmsg--label->level (match-string 1 hdr))))
           (timestamp (and (string-match dmsg--header-re hdr)
                           (match-string 2 hdr)))
           (msg-text  (dmsg--entry-message entry-pos))
           (bt-s      (dmsg--bt-start entry-pos))
           (bt-e      (dmsg--entry-end entry-pos))
           (bt-text (string-trim (buffer-substring-no-properties bt-s bt-e))))
      (with-current-buffer (get-buffer-create dmsg-backtrace-buffer-name)
        (let ((inhibit-read-only t))
          (erase-buffer)
          (insert (propertize (format "Timestamp : %s\n" timestamp)
                              'face 'dmsg-timestamp-face))
          (insert (propertize (format "Level     : %s\n"
                                      (dmsg--level-label level))
                              'face (dmsg--level-face level)))
          (insert (format "Message   : %s\n" msg-text))
          (insert (make-string fill-column ?-) "\n\n")
          (if (string-empty-p bt-text)
              (insert "(no frames captured)\n")
            (let ((bt-start (point)))
              (insert bt-text "\n")
              ;; Make every function name in the BT block a clickable button.
              (save-excursion
                (goto-char bt-start)
                (while (re-search-forward "^(\\([^ )]+\\)" nil t)
                  (dmsg--buttonify-fn
                   (intern-soft (match-string 1))
                   nil (match-beginning 1) (match-end 1))))))
          (goto-char (point-min)))
        (view-mode 1))
      (pop-to-buffer dmsg-backtrace-buffer-name))))

(defun dmsg--buttonify-fn (fn object &optional start end)
  "Make the text from START to END in OBJECT a function-jump button.
FN is the symbol to jump to.  OBJECT is either a string (when building
a display string) or nil (when annotating an existing buffer region via
START/END buffer positions).  Returns OBJECT."
  (when fn
    (add-text-properties
     (or start 0) (or end (length object))
     (list 'face       'dmsg-caller-face
           'mouse-face 'highlight
           'help-echo
           (let ((overriding-local-map dmsg--fn-keymap))
             (substitute-command-keys
              (format "\\[dmsg-jump-to-def]: jump to `%s'" fn)))
           'dmsg-fn    fn
           'keymap     dmsg--fn-keymap)
     object)
    object))

(defmacro dmsg--imessage (&rest args)
  "Call `message' with ARGS when the calling function was called interactively."
  `(when (called-interactively-p 'any)
     (message ,@args)))

;;;###autoload
(defun dmsg-clear ()
  "Toggle hiding all entries without modifying buffer text.
Call again to restore.  Use `dmsg-erase' to truly delete content."
  (interactive)
  (if (assq 'clear dmsg--hide-predicates)
      (progn (dmsg--set-predicate 'clear nil)
             (dmsg--imessage "dmsg: entries restored"))
    (dmsg--set-predicate 'clear (let ((cur-max (point-max)))
                                  (lambda (pos) (< pos cur-max))))
    (dmsg--imessage "dmsg: all entries hidden. Press c to restore, e to erase")))

;;;###autoload
(defun dmsg-erase ()
  "Erase all buffer content.
Destructive, use `dmsg-clear' to hide only."
  (interactive)
  (when (or (not (called-interactively-p 'any))
            (y-or-n-p "Erase all dmsg entries? "))
    (let ((inhibit-read-only t))
      (erase-buffer))
    (dmsg--teardown)
    (dmsg--update-header)
    (dmsg--imessage "dmsg: buffer erased")))

;;;###autoload
(defun dmsg-filter (regexp)
  "Show only entries whose message matches REGEXP.  Empty input clears filter."
  (interactive
   (list (let ((input (read-regexp
                       (format "Filter%s: "
                               (if dmsg--filter-regexp
                                   (format " (current: %s)"
                                           dmsg--filter-regexp)
                                 "")))))
           (if (string-empty-p input) nil input))))
  (setq dmsg--filter-regexp regexp)
  (dmsg--set-predicate
   'regexp
   (when-let* ((re regexp))
     (lambda (pos) (not (string-match-p re (dmsg--entry-message pos))))))
  (if regexp
      (dmsg--imessage "dmsg filter: %s" regexp)
    (dmsg--imessage "dmsg filter cleared")))

;;;###autoload
(defun dmsg-set-min-level (level)
  "Set `dmsg-min-level' to LEVEL and refresh visibility.
Interactively, prompts for the level with completion.."
  (interactive
   (list (intern (completing-read
                  "Min level: "
                  (mapcar #'dmsg--level-symbol dmsg--levels)
                  nil t nil nil (symbol-name dmsg-min-level)))))
  (setq dmsg-min-level level)
  (dmsg--refresh-visibility)
  (dmsg--imessage "dmsg: min-level is %s" (dmsg--level-label
                                           (dmsg--level level))))

;;;###autoload
(defun dmsg-snapshot (file &optional all)
  "Write currently visible entries to FILE."
  (interactive
   (list (let* ((default-name (format-time-string "dmsg-%Y%m%d-%H%M%S.log"))
                (path (read-file-name
                       (format "Snapshot to [default: %s]: " default-name)
                       default-directory
                       (expand-file-name default-name default-directory)
                       nil)))
           (if (file-directory-p path)
               (expand-file-name default-name path)
             path))
         current-prefix-arg))
  (unless (derived-mode-p 'dmsg-mode)
    (user-error "Not in a dmsg buffer"))
  (let (chunks)
    (save-excursion
      (goto-char (point-min))
      (while (not (eobp))
        (if (get-text-property (point) 'dmsg-entry)
            (let* ((pos (point))
                   (end (dmsg--entry-end pos)))
              (when (or all (not (dmsg--entry-hidden-p pos)))
                (push (buffer-substring-no-properties pos end) chunks))
              (goto-char end))
          (goto-char (next-char-property-change (point))))))
    (let ((count (length chunks)))
      (with-temp-buffer
        (dolist (chunk (nreverse chunks))
          (insert chunk))
        (write-region (point-min) (point-max) file nil 'silent))
      (dmsg--imessage "dmsg: %d %sentries exported to %s"
                      count (if all "" "visible ") file))))

;;;; Backtrace capture
(defun dmsg--format-arg (arg)
  "Format ARG, truncating to `dmsg-detailed-arg-max-length' characters."
  (let ((s (format "%S" arg)))
    (if (length> s dmsg-detailed-arg-max-length)
        (concat (substring s 0 (1- dmsg-detailed-arg-max-length))
                (truncate-string-ellipsis))
      s)))

(defun dmsg--capture-bt ()
  "Return the current backtrace as a list of strings, innermost first.
All dmsg internal frames are excluded: the accumulator is reset to nil
each time a dmsg-prefixed function is encountered, so only frames that
are strictly outer to all dmsg machinery are returned."
  (let ((lines '()))
    (mapbacktrace
     (lambda (evald func args _flags)
       (if (and (symbolp func)
                (string-match "dmsg.*" (symbol-name func)))
           ;; Ignore all functions in dmsg, or those called by them
           (setq lines nil)
         (push (if evald
                   (format "(%s%s)" func
                           (if args
                               (concat " " (mapconcat #'dmsg--format-arg args " "))
                             ""))
                 (format "(%s %s)" func (truncate-string-ellipsis)))
               lines))))
    ;; innermost-first
    (nreverse lines)))

;;;; Main entry point and message formatting

;;;###autoload
(defun dmsg-write (level str &optional no-bt)
  "Insert a timestamped debug entry into `dmsg-buffer-name'.
LEVEL is the log level symbol (debug/info/warn/error).
STR is the fully formatted message string.
When NO-BT is non-nil, no backtrace frames are written.

This is the low-level entry point.  Prefer the `dmsg' macro, which
automatically captures the call site, formats the message with `%='
label support, and provides a convenient syntax for specifying the level."
  (let* ((timestamp (format-time-string "%Y-%m-%d %H:%M:%S.%3N"))
         (msg-lines (split-string str "\n")))
    (with-current-buffer (get-buffer-create dmsg-buffer-name)
      (unless (derived-mode-p 'dmsg-mode) (dmsg-mode))
      (let ((inhibit-read-only t)
            (entry-start       (point-max)))
        (goto-char entry-start)
        ;; Entry header
        ;; "[LVL] [TIMESTAMP] " + first message line
        (insert (format "* %s [%s] %s\n"
                        (dmsg--level-label (dmsg--level level))
                        timestamp
                        (car msg-lines)))
        ;; Continuation lines with leading space
        (dolist (line (cdr msg-lines))
          (insert " " line "\n"))
        ;; Backtrace frame lines
        (if-let* ((frames (and (not no-bt) (dmsg--capture-bt))))
            (dolist (frame frames)
              (insert frame "\n"))
          (insert "\n"))
        ;; Apply display to this entry
        (cl-incf dmsg--entry-count)
        (dmsg--apply-entry-display entry-start)
        ;; Update visibility for this entry only
        (dmsg--on-new-entry entry-start))
      (goto-char (point-max))
      (when-let* ((win (get-buffer-window (current-buffer))))
        (set-window-point win (point-max))))))

(defun dmsg--format (fmt args args-labels)
  "Format FMT like `format', with support for the `%=' labelled specifier.

`%=SPEC' formats its argument as \"label=value\" where \"value\" is taken
from ARGS and \"label\" taken from ARGS-LABELS.
`%N$=SPEC' does the same with a positional argument reference.
All other specifiers behave exactly as in `format'."
  (let ((i 0) start (len (length fmt)) (seq-idx 1) parts)
    (while (< i len)
      (let ((c (aref fmt i)))
        (if (/= c ?%)
            (progn
              (unless start (setq start i))
              (cl-incf i))
          (when start
            (push (substring fmt start i) parts)
            (setq start nil))
          (cl-incf i)
          (when (>= i len)
            (error "Trailing `%%%%' in format string: %S" fmt))
          (if (= (aref fmt i) ?%)
              ;; %% -> %%%%
              (progn (push "%%%%" parts) (cl-incf i))
            ;; Real specifier.  Scan optional leading digits.
            (let ((d0 i))
              (while (and (< i len) (<= ?0 (aref fmt i) ?9)) (cl-incf i))
              (let* (;; Positional if digits are followed by $ (and non-empty)
                     (pos-p   (and (> i d0) (< i len) (= (aref fmt i) ?$)))
                     (_       (when pos-p
                                (cl-incf i)))
                     ;; Labelled if next char is =
                     (labeled (and (< i len) (= (aref fmt i) ?=)))
                     (_       (when labeled (cl-incf i))))
                (if labeled
                    (if pos-p
                        (progn
                          (let ((pos (substring fmt d0 (1- i))))
                            (push (concat "%" pos "s=%%" pos) parts)))
                      (push (concat "%" (number-to-string seq-idx) "$s=%%")
                            parts)
                      (cl-incf seq-idx))
                  (push (concat "%%" (substring fmt d0 i)) parts)
                  (unless pos-p
                    (cl-incf seq-idx)))))))))
    (when start
      (push (substring fmt start len) parts))

    (apply #'format
           (apply #'format ;; First pass for labels
                  (apply #'concat (nreverse parts)) ;; Transformed fmt
                  args-labels)
           args)))

(defun dmsg--dispatch (args args-label)
  "Dispatcher called by the `dmsg' macro.
ARGS is the evaluated argument list.  ARGS-LABEL is a parallel list of
argument labels.  First argument could be a symbol which is used as
level. The argument after that is the string format."
  (let* ((level (if (memq (car args) dmsg--level-order)
                    (progn
                      (pop args-label)       ; discard label for level symbol
                      (pop args))
                  'debug))
         (fmt (pop args)))
    (pop args-label)                         ; discard label for fmt string
    (dmsg-write level (dmsg--format fmt args args-label))))

(defmacro dmsg (&rest args)
  "Insert a timestamped, levelled debug entry into `dmsg-buffer-name'.

Syntax:
\(dmsg LEVEL FMT [ARGS])   explicit level symbol first
\(dmsg FMT [ARGS])         defaults to `debug' level

In FMT, `%=X' (where X is a conversion character) formats the corresponding
argument as \"label=value\", where the label is the unevaluated argument name."
  `(dmsg--dispatch (list ,@args)
                   (list ,@(mapcar (apply-partially #'format "%S") args))))

;;;; dmsg Injection
(defvar dmsg--on-message nil
  "When non-nil, intercept `message' calls and copy matching ones to dmsg.
This variable is an internal and should not be set directly.  Instead,
call `dmsg-on-message'.")

(defun dmsg--message-advice (fmt &rest args)
  "Advice to reproduce messages on dmsg.
`dmsg-write' is called whenever `dmsg--on-message' is non-nil (format
FMT ARGS) matches `dmsg--on-message'."
  (when (and dmsg--on-message fmt)
    (let ((regex dmsg--on-message)
          dmsg--on-message)  ;; Prevent potential infinite loops
      (let ((msg (apply 'format fmt args)))
        (when (and msg (string-match-p regex msg))
          (dmsg-write 'debug msg))))))

(defun dmsg-on-message (regex)
  "Advise `message' to log matching output via `dmsg'.

If REGEX is non-nil, any call to `message' whose formatted output
matches REGEX is also logged using `dmsg' at debug level.

Replaces any existing `dmsg' advice on `message'"
  (interactive
   (list (let ((s (read-regexp
                   "Log `message' matching regexp (empty to disable): ")))
           (unless (string-empty-p s) s))))
  (advice-remove #'message 'dmsg)
  (setq dmsg--on-message regex)
  (when regex
    (advice-add #'message
                :after #'dmsg--message-advice
                '((name . dmsg)
                  (depth . -99)))))

(defun dmsg-log-debugger (symb type &optional sig-args &rest _)
  "Debugger function that logs errors via `dmsg' before re-signalling.
SYMB is the name of the function being debugged (a string).
TYPE is the debug event symbol (typically `error').
SIG-ARGS is the error condition cons cell (ERROR-SYMBOL . DATA)."
  (dmsg-write 'error (format "%s: %s" symb
                             (error-message-string sig-args)))
  (when (eq type 'error)
    (let ((debug-on-error nil)
          (debugger nil))
      (signal (car sig-args) (cdr sig-args)))))

(defun dmsg--function-advice (oldfn &rest args)
  "Logs any error signalled by (apply OLDFN ARGS) via `dmsg'.

Install on a function with:
  (advice-add \\='SYMBOL :around #\\='dmsg--function-advice)
or interactively with `dmsg-on-error'."
  (let ((debug-on-error t)
        (debugger
         (apply-partially #'dmsg-log-debugger (symbol-name oldfn))))
    ;; This is needed for functions in `post-command-hook'
    ;; See https://lists.gnu.org/archive/html/emacs-devel/2010-07/msg01410.html
    (condition-case err
        (apply oldfn args)
      ((debug error)
       (signal
        (car err)
        (cdr err))))))

(defun dmsg-on-error (symbol &optional action)
  "Add, remove, or toggle error-logging advice on SYMBOL.
ACTION controls what happens:
  t        add the advice unconditionally
  nil      remove the advice unconditionally
  `toggle' flip the current state (default when called interactively)

When the advice is active, any error signalled by SYMBOL is logged via
`dmsg' at ERROR level and then re-signalled normally, preserving existing
error handling behaviour.

Returns t if the advice is now active, nil if it was removed."
  (interactive
   (list (intern (completing-read "Callable: " obarray #'fboundp t))
         'toggle))
  (let* ((active (advice-member-p 'dmsg--function-advice symbol))
         (add    (pcase action
                   ('toggle (not active))
                   ('t      t)
                   (_       nil))))
    (if add
        (progn
          (advice-add symbol :around #'dmsg--function-advice)
          (dmsg--imessage "dmsg: error advice added to `%s'" symbol)
          t)
      (advice-remove symbol #'dmsg--function-advice)
      (dmsg--imessage "dmsg: error advice removed from `%s'" symbol)
      nil)))

(defun dmsg-debug-on-error ()
  "Toggle debug on error.
When enabled, output uncaught errors to dmsg."
  (interactive)
  (setq
   debugger (if debug-on-error
                #'debug
              (apply-partially #'dmsg-log-debugger "debugger"))
   debug-on-error (not debug-on-error))
  (dmsg--imessage
   (format "dmsg on Error %s globally"
           (if debug-on-error "enabled"
             "disabled"))))

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