;;; efinger.el --- Finger client and .plan feed reader -*- lexical-binding: t; -*-

;; Copyright (C) 2026  Andros Fenollosa

;; Author: Andros Fenollosa <hi@andros.dev>
;; Maintainer: Andros Fenollosa <hi@andros.dev>
;; Package-Version: 20260822.1753
;; Package-Revision: 175b0d027da9
;; Package-Requires: ((emacs "28.1"))
;; Keywords: comm
;; URL: https://git.andros.dev/andros/efinger.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:

;; Efinger is a client for the Finger user information protocol
;; (RFC 1288) with an Elfeed-inspired reader.  Configure a list of
;; accounts once and browse their `.plan' files from a two-pane
;; interface: a list of accounts on one side and the fingered content
;; on the other.
;;
;; Finger, born at Stanford in 1971, is arguably the first social
;; network: your whole profile lives in a plain-text `.plan' file that
;; anyone can read with `finger you@your-host'.  John Carmack famously
;; kept his development diary this way.
;;
;; Usage:
;;
;;   (setq efinger-accounts
;;         '(("random" "random@happynetbox.com")
;;           ("Andros" "me@andros.dev")))
;;
;;   M-x efinger         - open the two-pane reader
;;   M-x efinger-finger  - finger a single account
;;   M-x efinger-search  - search a finger service (see below)
;;
;; In the account list, move with `n' and `p' to preview each `.plan'
;; in the other pane, press RET to jump into the content, `l' to go
;; back to the list and `q' to close both panes.
;;
;; Inside a fingered reply, every "user@host" address (the RFC 1288
;; query form) becomes a link: press TAB to move between addresses,
;; RET to finger the one at point and `l' to go back to the address
;; you came from.  This works both in the reader and in a standalone
;; `efinger-finger' buffer.
;;
;; Set `efinger-search-host' to a finger-based search service, such
;; as "crossed-fingers.andros.dev", to enable `efinger-search' (bound
;; to `s').  It fingers the search terms wrapped with
;; `efinger-search-query-format' (\"search?TERMS\" by default) and
;; shows the matching addresses as links; a blank query fingers
;; "help@HOST" for the service's own instructions.  Searching stays
;; disabled until the variable is set.

;;; Code:

(require 'subr-x)
(require 'button)

;;;; Customization

(defgroup efinger nil
  "Finger protocol client and .plan feed reader."
  :group 'communication
  :prefix "efinger-")

(defcustom efinger-accounts nil
  "List of finger accounts shown in the `efinger' reader.
Each entry is either a string of the form \"[USER@]HOST[:PORT]\",
or a list (LABEL TARGET) where LABEL is the text shown in the
account list and TARGET is such a string.

If USER is omitted the server is asked to list every user.  If
PORT is omitted `efinger-port' is used."
  :type '(repeat (choice
                  (string :tag "user@host")
                  (list :tag "Labelled account"
                        (string :tag "Label")
                        (string :tag "user@host")))))

(defcustom efinger-port 79
  "Default TCP port used to reach the finger service."
  :type 'natnum)

(defcustom efinger-connection-timeout 10
  "Seconds to wait for a finger connection before giving up.
If the connection is still being established after this many
seconds, it is aborted and the user is notified.  A value of nil
means wait indefinitely."
  :type '(choice (natnum :tag "Seconds")
                 (const :tag "No timeout" nil)))

(defcustom efinger-forward-host nil
  "Non-nil means send \"USER@HOST\" as the finger query.
The default sends only USER, which is what modern finger hosts
such as happynetbox.com expect.  Enable this for old daemons that
rely on finger forwarding."
  :type 'boolean)

(defcustom efinger-search-host nil
  "Finger host queried by the `efinger-search' command.
When nil, searching is disabled.  Set it to a finger-based search
service to enable searching; for example
\"crossed-fingers.andros.dev\", where fingering
\"help@crossed-fingers.andros.dev\" returns usage instructions and
\"TERMS@crossed-fingers.andros.dev\" returns matching addresses."
  :type '(choice (const :tag "Disabled" nil)
                 (string :tag "Finger host")))

(defcustom efinger-search-query-format "search?%s"
  "Format string turning search terms into a finger query.
`efinger-search' builds the query it fingers at
`efinger-search-host' by passing the terms through `format' with
this string.  The default matches crossed-fingers.andros.dev,
whose search command is \"search?TERMS\".  A blank query is sent
as \"help\" instead, so the service returns its own instructions."
  :type 'string)

(defcustom efinger-layout 'horizontal
  "How the account list and the .plan content are arranged.
`horizontal' places the list on the left and the content on the
right; `vertical' places the list on top and the content below."
  :type '(choice (const :tag "List left, content right" horizontal)
                 (const :tag "List top, content bottom" vertical)))

(defcustom efinger-list-width 40
  "Width in columns of the account list with a `horizontal' layout."
  :type 'natnum)

(defcustom efinger-list-height 12
  "Height in lines of the account list with a `vertical' layout."
  :type 'natnum)

;;;; Faces

(defface efinger-account-face
  '((t :inherit default))
  "Face for account labels in the list.")

(defface efinger-header-face
  '((t :inherit bold))
  "Face for the header of a fingered .plan.")

(defface efinger-status-face
  '((t :inherit shadow))
  "Face for transient status messages in the content pane.")

(defface efinger-error-face
  '((t :inherit error))
  "Face for connection errors in the content pane.")

;;;; Internal state

(defconst efinger-list-buffer-name "*efinger*"
  "Name of the buffer holding the account list.")

(defconst efinger-plan-buffer-name "*efinger .plan*"
  "Name of the buffer holding the fingered .plan content.")

(defvar efinger--list-buffer nil
  "The live account-list buffer, or nil.")

(defvar-local efinger--accounts nil
  "Normalized accounts rendered in the current list buffer.")

(defvar-local efinger--owner nil
  "Network process currently allowed to write to this buffer.")

(defvar-local efinger--received nil
  "Non-nil once the current fetch has written its first output.")

(defvar-local efinger--body-start nil
  "Marker where the fingered content begins in this buffer.")

(defvar-local efinger--account nil
  "Account plist currently rendered in this .plan buffer.")

(defvar-local efinger--history nil
  "Stack of accounts previously fingered in this buffer.
Following an address link pushes the current account here; going
back with `efinger-back' pops it.")

(defconst efinger--address-regexp
  (rx (group (any "0-9A-Za-z._%+-") (* (any "0-9A-Za-z._%+-"))
             "@"
             (any "0-9A-Za-z-") (* (any "0-9A-Za-z-"))
             (+ "." (any "0-9A-Za-z-") (* (any "0-9A-Za-z-")))
             (opt ":" (+ (any "0-9")))))
  "Regexp matching an RFC 1288 \"user@host[:port]\" finger address.
Bare hostnames are deliberately not matched, so ordinary words
containing a dot are left alone.")

;;;; Account parsing

(defun efinger--parse-target (target label)
  "Parse TARGET, a \"[user@]host[:port]\" string, into an account plist.
LABEL is stored as the account's display label."
  (let* ((at (string-search "@" target))
         (user (if at (substring target 0 at) ""))
         (hostport (if at (substring target (1+ at)) target))
         (colon (string-search ":" hostport))
         (host (if colon (substring hostport 0 colon) hostport))
         (port (if colon
                   (string-to-number (substring hostport (1+ colon)))
                 efinger-port)))
    (list :label label :user user :host host :port port)))

(defun efinger--parse-account (entry)
  "Normalize ENTRY from `efinger-accounts' into an account plist."
  (pcase entry
    ((pred stringp) (efinger--parse-target entry entry))
    (`(,label ,target) (efinger--parse-target target label))
    (_ (error "Invalid efinger account: %S" entry))))

(defun efinger--account-title (account)
  "Return a human-readable \"user@host\" title for ACCOUNT."
  (let ((user (plist-get account :user))
        (host (plist-get account :host))
        (port (plist-get account :port)))
    (concat (if (string= user "") "" (concat user "@"))
            host
            (if (eql port efinger-port) "" (format ":%d" port)))))

(defun efinger--finger-query (account)
  "Return the query line to send to the finger server for ACCOUNT."
  (let ((user (plist-get account :user)))
    (if (and efinger-forward-host (not (string= user "")))
        (concat user "@" (plist-get account :host))
      user)))

;;;; Networking

(defun efinger--strip-cr (string)
  "Return STRING with every carriage return removed."
  (replace-regexp-in-string "\r" "" string))

(defun efinger--cancel-timer (process)
  "Cancel the connection-timeout timer stored on PROCESS, if any."
  (when-let* ((timer (process-get process 'efinger-timer)))
    (cancel-timer timer)
    (process-put process 'efinger-timer nil)))

(defun efinger--owned-p (process)
  "Return non-nil if PROCESS still owns its target buffer."
  (let ((buffer (process-get process 'efinger-buffer)))
    (and (buffer-live-p buffer)
         (eq process (buffer-local-value 'efinger--owner buffer)))))

(defun efinger--report (process message)
  "Display MESSAGE from PROCESS in its content buffer and the echo area."
  (let ((buffer (process-get process 'efinger-buffer)))
    (when (efinger--owned-p process)
      (with-current-buffer buffer
        (let ((inhibit-read-only t))
          (unless efinger--received
            (setq efinger--received t)
            (delete-region efinger--body-start (point-max)))
          (goto-char (point-max))
          (insert (propertize message 'face 'efinger-error-face) "\n")))))
  (message "efinger: %s" message))

(defun efinger--filter (process string)
  "Insert STRING from PROCESS into its content buffer, stripping CRs."
  (when (efinger--owned-p process)
    (with-current-buffer (process-get process 'efinger-buffer)
      (let ((inhibit-read-only t))
        (unless efinger--received
          (setq efinger--received t)
          (delete-region efinger--body-start (point-max)))
        (save-excursion
          (goto-char (point-max))
          (insert (efinger--strip-cr string)))))))

(defun efinger--sentinel (process _event)
  "Drive PROCESS through its connection state changes."
  (pcase (process-status process)
    ('open
     (efinger--cancel-timer process)
     (process-send-string
      process (concat (process-get process 'efinger-query) "\r\n")))
    ('failed
     (efinger--cancel-timer process)
     (efinger--report
      process (format "Could not connect to %s"
                      (process-get process 'efinger-host))))
    ('closed
     (efinger--cancel-timer process)
     (when (efinger--owned-p process)
       (if (buffer-local-value 'efinger--received
                               (process-get process 'efinger-buffer))
           (efinger--buttonize-reply process)
         (efinger--report process "(no reply)"))))))

(defun efinger--timeout (process)
  "Abort PROCESS if it is still trying to connect."
  (when (and (process-live-p process)
             (eq (process-status process) 'connect))
    ;; Silence the sentinel so the forced deletion is not reported twice.
    (set-process-sentinel process #'ignore)
    (delete-process process)
    (efinger--report
     process (format "Connection to %s timed out after %d seconds"
                     (process-get process 'efinger-host)
                     efinger-connection-timeout))))

(defun efinger--fetch (host port query buffer)
  "Finger QUERY at HOST on PORT, streaming the reply into BUFFER.
Return the network process.  The connection is asynchronous, so
Emacs never blocks while the handshake is retried."
  (let ((process (make-network-process
                  :name (format "efinger %s@%s" query host)
                  :host host
                  :service port
                  :nowait t
                  :coding 'utf-8
                  :filter #'efinger--filter
                  :sentinel #'efinger--sentinel)))
    (process-put process 'efinger-buffer buffer)
    (process-put process 'efinger-query query)
    (process-put process 'efinger-host host)
    (when efinger-connection-timeout
      (process-put process 'efinger-timer
                   (run-at-time efinger-connection-timeout nil
                                #'efinger--timeout process)))
    process))

(defun efinger--start (buffer account &optional raw keep-history)
  "Finger ACCOUNT and render the reply into BUFFER.
When RAW is non-nil, stream the reply verbatim without the title
header, so the buffer shows exactly what the server sent.  Unless
KEEP-HISTORY is non-nil, the buffer's address history is cleared,
marking ACCOUNT as a fresh browsing origin."
  (with-current-buffer buffer
    (when (process-live-p efinger--owner)
      (efinger--cancel-timer efinger--owner)
      (set-process-sentinel efinger--owner #'ignore)
      (delete-process efinger--owner))
    (unless keep-history
      (setq efinger--history nil))
    (let ((inhibit-read-only t)
          (title (efinger--account-title account)))
      (erase-buffer)
      (unless raw
        (insert (propertize title 'face 'efinger-header-face) "\n")
        (insert (make-string (max 12 (length title)) ?─) "\n\n"))
      (setq efinger--account account)
      (setq efinger--received nil)
      (setq efinger--body-start (copy-marker (point)))
      (insert (propertize "Fingering…" 'face 'efinger-status-face))
      (goto-char (point-min)))
    (setq efinger--owner
          (efinger--fetch (plist-get account :host)
                           (plist-get account :port)
                           (efinger--finger-query account)
                           buffer))))

;;;; Address links

(define-button-type 'efinger-address
  'follow-link t
  'help-echo "mouse-1, RET: finger this address"
  'action #'efinger--follow-button)

(defun efinger--buttonize (start end)
  "Turn every finger address between START and END into a button."
  (save-excursion
    (goto-char start)
    (while (re-search-forward efinger--address-regexp end t)
      (make-text-button (match-beginning 0) (match-end 0)
                        :type 'efinger-address
                        'efinger-target (match-string-no-properties 0)))))

(defun efinger--buttonize-reply (process)
  "Buttonize the fingered reply that PROCESS wrote into its buffer."
  (let ((buffer (process-get process 'efinger-buffer)))
    (when (buffer-live-p buffer)
      (with-current-buffer buffer
        (when (and efinger--body-start efinger--received)
          (let ((inhibit-read-only t))
            (efinger--buttonize efinger--body-start (point-max))))))))

(defun efinger--follow-button (button)
  "Finger the address stored in BUTTON, remembering the current one."
  (efinger--visit (button-get button 'efinger-target)))

(defun efinger--visit (target)
  "Finger TARGET in the current buffer, pushing the current account.
TARGET is a \"[user@]host[:port]\" string.  The buffer keeps its
rendering style, raw or headed, so links can be followed both in
the reader and in a standalone `efinger-finger' buffer."
  (let ((account (efinger--parse-target target target))
        (raw (derived-mode-p 'efinger-finger-mode)))
    (when efinger--account
      (push efinger--account efinger--history))
    (efinger--start (current-buffer) account raw t)))

;;;; Content buffer (.plan)

(defvar efinger-plan-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "l" #'efinger-back)
    (define-key map "n" #'efinger-plan-next)
    (define-key map "p" #'efinger-plan-previous)
    (define-key map (kbd "TAB") #'efinger-next-address)
    (define-key map [tab] #'efinger-next-address)
    (define-key map (kbd "<backtab>") #'efinger-previous-address)
    (define-key map (kbd "RET") #'efinger-follow-address)
    (define-key map "s" #'efinger-search)
    (define-key map "g" #'efinger-plan-refresh)
    (define-key map "q" #'efinger-quit)
    map)
  "Keymap for `efinger-plan-mode'.")

(define-derived-mode efinger-plan-mode special-mode "Efinger-Plan"
  "Major mode for reading a fingered .plan."
  (setq-local truncate-lines nil)
  (setq-local header-line-format
              (concat " .plan — TAB address · RET finger · l back"
                      " · s search · n/p next/prev · g refresh · q quit"))
  (buffer-disable-undo))

(defun efinger--plan-buffer ()
  "Return the .plan content buffer, creating it if needed."
  (let ((buffer (get-buffer-create efinger-plan-buffer-name)))
    (with-current-buffer buffer
      (unless (derived-mode-p 'efinger-plan-mode)
        (efinger-plan-mode)))
    buffer))

;;;; Raw finger buffer

(defvar efinger-finger-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "TAB") #'efinger-next-address)
    (define-key map [tab] #'efinger-next-address)
    (define-key map (kbd "<backtab>") #'efinger-previous-address)
    (define-key map (kbd "RET") #'efinger-follow-address)
    (define-key map "l" #'efinger-back)
    (define-key map "s" #'efinger-search)
    map)
  "Keymap for `efinger-finger-mode'.")

(define-derived-mode efinger-finger-mode special-mode "Finger"
  "Major mode for a standalone `efinger-finger' reply.
Shows the server reply verbatim.  Finger addresses in the reply
become links: TAB moves between them, RET fingers the one at
point and \\`l' goes back to the previous address."
  (setq-local truncate-lines nil)
  (setq-local header-line-format
              " Finger — TAB address · RET finger · l back · s search")
  (buffer-disable-undo))

;;;; Account list

(defvar efinger-list-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "n" #'efinger-next-account)
    (define-key map "p" #'efinger-previous-account)
    (define-key map (kbd "RET") #'efinger-select)
    (define-key map "s" #'efinger-search)
    (define-key map "g" #'efinger-refresh)
    (define-key map "G" #'efinger-reload)
    (define-key map "q" #'efinger-quit)
    map)
  "Keymap for `efinger-list-mode'.")

(define-derived-mode efinger-list-mode special-mode "Efinger"
  "Major mode for the Efinger account list."
  (setq-local truncate-lines t)
  (setq-local header-line-format
              " Efinger — n/p move · RET open · s search · g refresh · q quit")
  (buffer-disable-undo)
  (hl-line-mode))

(defun efinger--render-list ()
  "Render `efinger--accounts' into the current list buffer."
  (let ((inhibit-read-only t))
    (erase-buffer)
    (if (null efinger--accounts)
        (insert (propertize
                 "No accounts configured.  Customize `efinger-accounts'."
                 'face 'efinger-status-face))
      (dolist (account efinger--accounts)
        (insert (propertize (plist-get account :label)
                            'efinger-account account
                            'face 'efinger-account-face)
                "\n")))
    (goto-char (point-min))))

(defun efinger--account-at-point ()
  "Return the account on the current list line, or nil."
  (get-text-property (line-beginning-position) 'efinger-account))

(defun efinger--ensure-plan-window ()
  "Ensure the .plan buffer is shown next to the account list."
  (let* ((plan-buffer (efinger--plan-buffer))
         (list-window (and (buffer-live-p efinger--list-buffer)
                           (get-buffer-window efinger--list-buffer))))
    (or (get-buffer-window plan-buffer)
        (if list-window
            (with-selected-window list-window
              (let ((window (if (eq efinger-layout 'vertical)
                                (split-window-below efinger-list-height)
                              (split-window-right efinger-list-width))))
                (set-window-buffer window plan-buffer)
                window))
          (display-buffer plan-buffer)))))

(defun efinger--show-current ()
  "Finger the account on the current list line into the .plan pane."
  (let ((account (efinger--account-at-point)))
    (when account
      (efinger--ensure-plan-window)
      (efinger--start (efinger--plan-buffer) account))))

;;;; Commands

(defun efinger-next-account ()
  "Move to the next account and preview its .plan."
  (interactive)
  (forward-line 1)
  (if (efinger--account-at-point)
      (efinger--show-current)
    (forward-line -1)))

(defun efinger-previous-account ()
  "Move to the previous account and preview its .plan."
  (interactive)
  (when (> (line-number-at-pos) 1)
    (forward-line -1)
    (when (efinger--account-at-point)
      (efinger--show-current))))

(defun efinger-select ()
  "Move into the .plan pane for the account at point.
The account is only fingered again when its .plan is not already
shown; otherwise this just switches to the content window."
  (interactive)
  (let ((account (efinger--account-at-point)))
    (when account
      (efinger--ensure-plan-window)
      (let ((buffer (efinger--plan-buffer)))
        (unless (equal account (buffer-local-value 'efinger--account buffer))
          (efinger--start buffer account))
        (let ((window (get-buffer-window buffer)))
          (when window (select-window window)))))))

(defun efinger-refresh ()
  "Re-finger the account at point."
  (interactive)
  (efinger--show-current))

(defun efinger-reload ()
  "Rebuild the account list from `efinger-accounts'."
  (interactive)
  (setq efinger--accounts (mapcar #'efinger--parse-account efinger-accounts))
  (efinger--render-list)
  (efinger--show-current))

(defun efinger-back-to-list ()
  "Return focus to the account list."
  (interactive)
  (if (buffer-live-p efinger--list-buffer)
      (let ((window (get-buffer-window efinger--list-buffer)))
        (if window
            (select-window window)
          (switch-to-buffer efinger--list-buffer)))
    (message "efinger: no account list")))

(defun efinger-next-address ()
  "Move point to the next finger address, wrapping to the first."
  (interactive)
  (condition-case nil
      (forward-button 1 t t)
    (error (message "efinger: no addresses in this reply"))))

(defun efinger-previous-address ()
  "Move point to the previous finger address, wrapping to the last."
  (interactive)
  (condition-case nil
      (forward-button -1 t t)
    (error (message "efinger: no addresses in this reply"))))

(defun efinger-follow-address ()
  "Finger the address under point, if any."
  (interactive)
  (if (button-at (point))
      (push-button (point))
    (message "efinger: point is not on an address")))

(defun efinger-back ()
  "Finger the previously visited address again.
With no address history, fall back to the account list when in
the reader, or report that there is nowhere to go back to."
  (interactive)
  (cond
   (efinger--history
    (let ((account (pop efinger--history))
          (raw (derived-mode-p 'efinger-finger-mode)))
      (efinger--start (current-buffer) account raw t)))
   ((derived-mode-p 'efinger-plan-mode)
    (efinger-back-to-list))
   (t (message "efinger: no previous address"))))

(defun efinger--plan-move (n)
  "Move N accounts in the list window while staying in the .plan pane."
  (let ((window (and (buffer-live-p efinger--list-buffer)
                     (get-buffer-window efinger--list-buffer))))
    (if window
        (with-selected-window window
          (if (> n 0)
              (efinger-next-account)
            (efinger-previous-account)))
      (message "efinger: no account list"))))

(defun efinger-plan-next ()
  "Preview the next account without leaving the .plan pane."
  (interactive)
  (efinger--plan-move 1))

(defun efinger-plan-previous ()
  "Preview the previous account without leaving the .plan pane."
  (interactive)
  (efinger--plan-move -1))

(defun efinger-plan-refresh ()
  "Re-finger the account currently shown in the .plan pane."
  (interactive)
  (when (buffer-live-p efinger--list-buffer)
    (with-current-buffer efinger--list-buffer
      (efinger--show-current))))

(defun efinger-quit ()
  "Close the account list and the .plan pane."
  (interactive)
  (let ((plan-buffer (get-buffer efinger-plan-buffer-name))
        (list-buffer (get-buffer efinger-list-buffer-name)))
    (when plan-buffer
      (with-current-buffer plan-buffer
        (when (process-live-p efinger--owner)
          (efinger--cancel-timer efinger--owner)
          (set-process-sentinel efinger--owner #'ignore)
          (delete-process efinger--owner)))
      (let ((window (get-buffer-window plan-buffer)))
        (when (and window (not (one-window-p)))
          (delete-window window)))
      (kill-buffer plan-buffer))
    (when list-buffer
      (let ((window (get-buffer-window list-buffer)))
        (when (and window (not (one-window-p)))
          (delete-window window)))
      (kill-buffer list-buffer)))
  (setq efinger--list-buffer nil))

;;;; Entry points

;;;###autoload
(defun efinger ()
  "Open the Efinger reader over `efinger-accounts'."
  (interactive)
  (let ((list-buffer (get-buffer-create efinger-list-buffer-name)))
    (setq efinger--list-buffer list-buffer)
    (with-current-buffer list-buffer
      (efinger-list-mode)
      (setq efinger--accounts
            (mapcar #'efinger--parse-account efinger-accounts))
      (efinger--render-list))
    (switch-to-buffer list-buffer)
    (delete-other-windows)
    (goto-char (point-min))
    (efinger--show-current)))

;;;###autoload
(defun efinger-finger (target)
  "Finger TARGET, a \"[user@]host[:port]\" string, in its own buffer."
  (interactive
   (list (read-string (format-prompt "Finger" nil))))
  (when (string-empty-p (string-trim target))
    (user-error "No account given"))
  (let* ((account (efinger--parse-target (string-trim target)
                                          (string-trim target)))
         (buffer (get-buffer-create
                  (format "*finger %s*" (efinger--account-title account)))))
    (with-current-buffer buffer
      (unless (derived-mode-p 'efinger-finger-mode)
        (efinger-finger-mode)))
    (efinger--start buffer account t)
    (switch-to-buffer buffer)
    (delete-other-windows)))

(defun efinger--search-account (query)
  "Return the account plist for searching QUERY at `efinger-search-host'.
The finger query is built from QUERY with `efinger-search-query-format'.
A blank QUERY is sent as \"help\", so the service is asked for its
own usage instructions instead."
  (let* ((terms (string-trim query))
         (blank (string-empty-p terms)))
    (list :label (if blank "search: help" (format "search: %s" terms))
          :user (if blank "help" (format efinger-search-query-format terms))
          :host efinger-search-host
          :port efinger-port)))

;;;###autoload
(defun efinger-search (query)
  "Finger QUERY at `efinger-search-host' and show the results.
This is meant for finger-based search services such as
crossed-fingers.andros.dev.  QUERY is wrapped with
`efinger-search-query-format' before being fingered; a blank QUERY
fingers \"help@HOST\" for the service's own instructions.  Every
result address in the reply becomes a link you can follow with RET.

Searching is disabled until `efinger-search-host' is set."
  (interactive
   (progn
     (unless efinger-search-host
       (user-error "Set `efinger-search-host' to enable searching"))
     (list (read-string
            (format-prompt "Search %s (blank for help)"
                           nil efinger-search-host)))))
  (unless efinger-search-host
    (user-error "Set `efinger-search-host' to enable searching"))
  (let* ((account (efinger--search-account query))
         (buffer (get-buffer-create
                  (format "*finger %s*" (plist-get account :label)))))
    (with-current-buffer buffer
      (unless (derived-mode-p 'efinger-finger-mode)
        (efinger-finger-mode)))
    (efinger--start buffer account t)
    (switch-to-buffer buffer)
    (delete-other-windows)))

(provide 'efinger)

;;; efinger.el ends here
