;;; oc-basic.el --- basic backend for citations  -*- lexical-binding: t; -*-

;; Copyright (C) 2021-2026 Free Software Foundation, Inc.

;; Author: Nicolas Goaziou <mail@nicolasgoaziou.fr>

;; This file is part of GNU Emacs.

;; GNU Emacs 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.

;; GNU Emacs 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 GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; The `basic' citation processor provides "activate", "follow", "export" and
;; "insert" capabilities.

;; "activate" capability reuses default fontification, but provides additional
;; features on both correct and wrong keys according to the bibliography
;; defined in the document.

;; When the mouse is over a known key, it displays the corresponding
;; bibliography entry.  Any wrong key, however, is highlighted with `error'
;; face.  Moreover, moving the mouse onto it displays a list of suggested correct
;; keys, and pressing <mouse-1> on the faulty key will try to fix it according to
;; those suggestions.

;; On a citation key, "follow" capability moves point to the corresponding entry
;; in the current bibliography.  Elsewhere on the citation, it asks the user to
;; follow any of the keys cited there, with completion.

;; "export" capability supports the following citation styles:
;;
;;   - author (a), including caps (c) variant,
;;   - noauthor (na) including bare (b) variant,
;;   - text (t), including bare (b), caps (c), and bare-caps (bc) variants,
;;   - note (ft, including bare (b), caps (c), and bare-caps (bc) variants,
;;   - nocite (n)
;;   - numeric (nb),
;;   - default, including bare (b), caps (c), and bare-caps (bc) variants.
;;
;; It also supports the following styles for bibliography:
;;   - plain
;;   - numeric
;;   - author-year (default)

;; "insert" capability inserts or edits (with completion) citation style or
;; citation reference keys.  In an appropriate place, it offers to insert a new
;; citation.  With a prefix argument, it removes the one at point.

;; It supports bibliography files in BibTeX (".bibtex"), biblatex (".bib") and
;; JSON (".json") format.

;; Disclaimer: this citation processor is meant to be a proof of concept, and
;; possibly a fall-back mechanism when nothing else is available.  It is too
;; limited for any serious use case.

;;; Code:

(require 'org-macs)
(org-assert-version)

(require 'bibtex)
(require 'json)
(require 'map)
(require 'oc)
(require 'seq)

(declare-function org-open-at-point "org" (&optional arg))
(declare-function org-open-file "org" (path &optional in-emacs line search))

(declare-function org-element-create "org-element-ast" (type &optional props &rest children))
(declare-function org-element-set "org-element-ast" (old new &optional keep-props))

(declare-function org-element-interpret-data "org-element" (data))
(declare-function org-element-parse-secondary-string "org-element" (string restriction &optional parent))
(declare-function org-element-map "org-element"
                  ( data types fun
                    &optional
                    info first-match no-recursion
                    with-affiliated no-undefer))
(declare-function org-element-property "org-element-ast" (property node))
(declare-function org-element-type-p "org-element-ast" (node types))
(declare-function org-element-contents "org-element-ast" (node))

(declare-function org-export-data "ox" (data info))
(declare-function org-export-derived-backend-p "ox" (backend &rest backends))
(declare-function org-export-raw-string "ox" (contents))


;;; Customization
(defcustom org-cite-basic-sorting-field 'author
  "Field used to sort bibliography items as a symbol, or nil."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'symbol
  :safe #'symbolp)

(defcustom org-cite-basic-author-year-separator ", "
  "String used to separate cites in an author-year configuration."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'string
  :safe #'stringp)

(defcustom org-cite-basic-max-key-distance 2
  "Maximum (Levenshtein) distance between a wrong key and its suggestions."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'integer
  :safe #'integerp)

(defcustom org-cite-basic-author-column-end 25
  "Column where author field ends in completion table, as an integer."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'integer
  :safe #'integerp)

(defcustom org-cite-basic-column-separator "  "
  "Column separator in completion table, as a string."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'string
  :safe #'stringp)

(defcustom org-cite-basic-mouse-over-key-face 'highlight
  "Face used when mouse is over a citation key."
  :group 'org-cite
  :package-version '(Org . "9.5")
  :type 'face
  :safe #'facep)

(defcustom org-cite-basic-complete-key-crm-separator nil
  "When non-nil, use `completing-read-multiple' with this as the separator.
When nil, use multiple `completing-read' prompts.  When set to a string,
it should be a regexp to be used as `crm-separator' (which see).  The
regexp string can carry the text properties `separator', which if
present `completing-read-multiple' will show as part of the prompt.
When set to symbol `dynamic', use \";;...\" as a separator with the
number of \";\" sufficient so that none of the completion candidates
contain the separator."
  :group 'org-cite
  :package-version '(Org . "9.8")
  :type
  '(choice
    (const
     :tag
     "Use \";;\" as the separator."
     (propertize "[ \t]*;;[ \t]*" 'separator ";;"))
    (const
     :tag
     "Dynamically compute \";\"+ with the needed length."
     dynamic)
    (string :tag "Custom regexp for the separator.")
    (const :tag "Prompt multiple times." nil))
  :safe (lambda (obj) (or (string-or-null-p obj) (member obj '(dynamic)))))


;;; Internal variables
(defvar org-cite-basic--bibliography-cache nil
  "Cache for parsed bibliography files.

This is an association list following the pattern:

  (FILE-ID . ENTRIES)

FILE-ID is a cons cell (FILE . HASH), with FILE being the absolute file name of
the bibliography file, and HASH a hash of its contents.

ENTRIES is a hash table with citation references as keys and fields alist as
values.")

(defvar org-cite-basic--completion-cache (make-hash-table :test #'equal)
  "Cache for key completion table.

This is an a hash-table.")


;;; Internal functions
(defun org-cite-basic--parse-json ()
  "Parse JSON entries in the current buffer.
Return a hash table with citation references as keys and fields alist as values."
  (let ((entries (make-hash-table :test #'equal)))
    (let ((json-array-type 'list)
          (json-key-type 'symbol))
      (dolist (item (json-read))
        (puthash (cdr (assq 'id item))
                 (mapcar (pcase-lambda (`(,field . ,value))
                           (pcase field
                             ((or 'author 'editor)
                              ;; Author and editors are arrays of
                              ;; objects, each of them designing a
                              ;; person.  These objects may contain
                              ;; multiple properties, but for this
                              ;; basic processor, we'll focus on
                              ;; `given' and `family'.
                              ;;
                              ;; For compatibility with BibTeX, add
                              ;; "and" between authors and editors.
                              (cons field
                                    (mapconcat
                                     (lambda (alist)
                                       (concat (alist-get 'family alist)
                                               " "
                                               (alist-get 'given alist)))
                                     value
                                     " and ")))
                             ('issued
                              ;; Date are expressed as an array
                              ;; (`date-parts') or a "string (`raw'
                              ;; or `literal'). In both cases,
                              ;; extract the year and associate it
                              ;; to `year' field, for compatibility
                              ;; with BibTeX format.
                              (let ((date (or (alist-get 'date-parts value)
                                              (alist-get 'literal value)
                                              (alist-get 'raw value))))
                                (cons 'year
                                      (cond
                                       ((consp date)
                                         (let ((year (caar date)))
                                           (cond
                                             ((numberp year) (number-to-string year))
                                             ((stringp year) year)
                                             (t
                                               (error
                                                 "First element of CSL-JSON date-parts should be a number or string, got %s: %S"
                                                 (type-of year) year)))))
                                       ((stringp date)
                                        (replace-regexp-in-string
                                          (rx
                                            (minimal-match (zero-or-more anything))
                                            (group-n 1 (repeat 4 digit))
                                            (zero-or-more anything))
                                          (rx (backref 1))
                                          date))
                                       (t
                                        (error "Unknown CSL-JSON date format: %S"
                                               value))))))
                             (_
                              (cons field value))))
                         item)
                 entries))
      entries)))

(defun org-cite-basic--parse-bibtex (dialect)
  "Parse BibTeX entries in the current buffer.
DIALECT is the BibTeX dialect used.  See `bibtex-dialect'.
Return a hash table with citation references as keys and fields alist as values."
  (let ((entries (make-hash-table :test #'equal))
        (bibtex-sort-ignore-string-entries t))
    (bibtex-set-dialect dialect t)
    ;; Throw an error if bibliography is malformed.
    (unless (bibtex-validate)
      (user-error "Malformed bibliography at %S"
                  (or (buffer-file-name) (current-buffer))))
    (bibtex-map-entries
     (lambda (key &rest _)
       ;; Normalize entries: field names are turned into symbols
       ;; including special "=key=" and "=type=", and consecutive
       ;; white spaces are removed from values.
       (puthash key
                (mapcar
                 (pcase-lambda (`(,field . ,value))
                   (pcase field
                     ("=key=" (cons 'id key))
                     ("=type=" (cons 'type value))
                     (_
                      (cons
                       (intern (downcase field))
                       (replace-regexp-in-string "[ \t\n]+" " " value)))))
                 ;; Parse, substituting the @string replacements.
                 ;; See Emacs bug#56475 discussion.
                 (let ((bibtex-string-files `(,(buffer-file-name)))
                       (bibtex-expand-strings t))
                   (bibtex-parse-entry t)))
                entries)))
    entries))

(defvar org-cite-basic--file-id-cache nil
  "Hash table linking files to their hash.")
(defun org-cite-basic--parse-bibliography (&optional info)
  "List all entries available in the buffer.

Each association follows the pattern

  (FILE . ENTRIES)

where FILE is the absolute file name of the BibTeX file, and ENTRIES is a hash
table where keys are references and values are association lists between fields,
as symbols, and values as strings or nil.

Optional argument INFO is the export state, as a property list."
  (unless (hash-table-p org-cite-basic--file-id-cache)
    (setq org-cite-basic--file-id-cache (make-hash-table :test #'equal)))
  (if (plist-member info :cite-basic/bibliography)
      (plist-get info :cite-basic/bibliography)
    (let ((results nil))
      (dolist (file (org-cite-list-bibliography-files))
        ;; Follow symlinks, to look into modification time of the
        ;; actual file, not its symlink.
        (setq file (file-truename file))
        (when (file-readable-p file)
          (with-temp-buffer
            (when (or (org-file-has-changed-p file)
                      (not (gethash file org-cite-basic--file-id-cache)))
              (insert-file-contents file)
              (set-visited-file-name file t)
              (puthash file (org-buffer-hash) org-cite-basic--file-id-cache))
            (condition-case nil
                (unwind-protect
	            (let* ((file-id (cons file (gethash file org-cite-basic--file-id-cache)))
                           (entries
                            (or (cdr (assoc file-id org-cite-basic--bibliography-cache))
                                (let ((table
                                       (pcase (file-name-extension file)
                                         ("json" (org-cite-basic--parse-json))
                                         ("bib" (org-cite-basic--parse-bibtex 'biblatex))
                                         ("bibtex" (org-cite-basic--parse-bibtex 'BibTeX))
                                         (ext
                                          (user-error "Unknown bibliography extension: %S"
                                                      ext)))))
                                  (push (cons file-id table) org-cite-basic--bibliography-cache)
                                  table))))
                      (push (cons file entries) results))
                  (set-visited-file-name nil t))
              (error (setq org-cite-basic--file-id-cache nil))))))
      (when info (plist-put info :cite-basic/bibliography results))
      results)))

(defun org-cite-basic--key-number (key info)
  "Return number associated to cited KEY.
INFO is the export state, as a property list."
  (let ((predicate
         (org-cite-basic--field-less-p org-cite-basic-sorting-field info)))
    (org-cite-key-number key info predicate)))

(defun org-cite-basic--all-keys ()
  "List all keys available in current bibliography."
  (seq-mapcat (pcase-lambda (`(,_ . ,entries))
                (map-keys entries))
              (org-cite-basic--parse-bibliography)))

(defun org-cite-basic--get-entry (key &optional info)
  "Return BibTeX entry for KEY, as an association list.
When non-nil, INFO is the export state, as a property list."
  (catch :found
    (pcase-dolist (`(,_ . ,entries) (org-cite-basic--parse-bibliography info))
      (let ((entry (gethash key entries)))
        (when entry (throw :found entry))))
    nil))

(defun org-cite-basic--get-field (field entry-or-key &optional info raw)
  "Return FIELD value for ENTRY-OR-KEY, or nil.

FIELD is a symbol.  ENTRY-OR-KEY is either an association list, as returned by
`org-cite-basic--get-entry', or a string representing a citation key.

Optional argument INFO is the export state, as a property list.

Return value may be nil or a string.  If current export backend is derived
from `latex', return a raw string object instead, unless optional
argument RAW is non-nil.

Throw an error if the field value is non-string and non-nil."
  (let ((value
         (cdr
          (assq field
                (pcase entry-or-key
                  ((pred stringp)
                   (org-cite-basic--get-entry entry-or-key info))
                  ((pred consp)
                   entry-or-key)
                  (_
                   (error "Wrong value for ENTRY-OR-KEY: %S" entry-or-key)))))))
    (when (and value (not (stringp value)))
      (error "Non-string bibliography field value: %S" value))
    (if (and value
             (not raw)
             (org-export-derived-backend-p (plist-get info :back-end) 'latex))
        (org-export-raw-string value)
      value)))

(defun org-cite-basic--shorten-names (names)
  "Return a list of family names from a list of full NAMES.
NAMES can be a string or raw string object.

To better accommodate corporate names, this will only shorten
personal names of the form \"family, given\"."
  (let (names-string raw-p)
    (cond
     ((stringp names) (setq names-string names))
     ((org-element-type-p names 'raw)
      (setq names-string (mapconcat #'identity (org-element-contents names) "")
            raw-p t)))
    (when names-string
      (setq names-string
            (mapconcat
             (lambda (name)
               (if (eq 1 (length name))
                   (cdr (split-string name))
                 (car (split-string name ", "))))
             (split-string names-string " and ")
             ", "))
      (if raw-p (org-export-raw-string names-string)
        names-string))))

(defun org-cite-basic--number-to-suffix (n)
  "Compute suffix associated to number N.
This is used for disambiguation."
  (let ((result nil))
    (apply #'string
           (mapcar (lambda (n) (+ 97 n))
                   (catch :complete
                     (while t
                       (push (% n 26) result)
                       (setq n (/ n 26))
                       (cond
                        ((= n 0) (throw :complete result))
                        ((< n 27) (throw :complete (cons (1- n) result)))
                        ((= n 27) (throw :complete (cons 0 (cons 0 result))))
                        (t nil))))))))

(defun org-cite-basic--get-author (entry-or-key &optional info raw)
  "Return author associated to ENTRY-OR-KEY.

ENTRY-OR-KEY, INFO and RAW arguments are the same arguments as
used in `org-cite-basic--get-field', which see.

Author is obtained from the \"author\" field, if available, or
from the \"editor\" field otherwise."
  (or (org-cite-basic--get-field 'author entry-or-key info raw)
      (org-cite-basic--get-field 'editor entry-or-key info raw)))

(defun org-cite-basic--get-year (entry-or-key info &optional no-suffix)
  "Return year associated to ENTRY-OR-KEY.

ENTRY-OR-KEY is either an association list, as returned by
`org-cite-basic--get-entry', or a string representing a citation
key.  INFO is the export state, as a property list.

Year is obtained from the \"year\" field, if available, or from
the \"date\" field if it starts with a year pattern.

Unlike `org-cite-basic--get-field', this function disambiguates
author-year patterns by adding a letter suffix to the year when
necessary, unless optional argument NO-SUFFIX is non-nil."
  ;; The cache is an association list with the following structure:
  ;;
  ;;    (AUTHOR-YEAR . KEY-SUFFIX-ALIST).
  ;;
  ;; AUTHOR-YEAR is the author year pair associated to current entry
  ;; or key.
  ;;
  ;; KEY-SUFFIX-ALIST is an association (KEY . SUFFIX), where KEY is
  ;; the cite key, as a string, and SUFFIX is the generated suffix
  ;; string, or the empty string.
  (let* ((author (org-cite-basic--get-author entry-or-key info 'raw))
         (year
          (or (org-cite-basic--get-field 'year entry-or-key info 'raw)
              (let ((date
                     (org-cite-basic--get-field 'date entry-or-key info 'raw)))
                (and (stringp date)
                     (string-match (rx string-start
                                       (group (= 4 digit))
                                       (or string-end (not digit)))
                                   date)
                     (match-string 1 date)))))
         (cache-key (cons author year))
         (key
          (pcase entry-or-key
            ((pred stringp) entry-or-key)
            ((pred consp) (cdr (assq 'id entry-or-key)))
            (_ (error "Wrong value for ENTRY-OR-KEY: %S" entry-or-key))))
         (cache (plist-get info :cite-basic/author-date-cache)))
    (pcase (assoc cache-key cache)
      ('nil
       (let ((value (cons cache-key (list (cons key "")))))
         (plist-put info :cite-basic/author-date-cache (cons value cache))
         year))
      (`(,_ . ,alist)
       (let ((suffix
              (or (cdr (assoc key alist))
                  (let ((new (org-cite-basic--number-to-suffix
                              (1- (length alist)))))
                    (push (cons key new) alist)
                    new))))
         (if no-suffix year (concat year suffix)))))))

(defun org-cite-basic--print-bibtex-string (element &optional info)
  "Print Bibtex formatted string ELEMENT, according to Bibtex syntax.
Remove all the {...} that are not a part of LaTeX macros and parse the
LaTeX fragments.  Do nothing when current backend is derived from
LaTeX, according to INFO.

Return updated ELEMENT."
  (if (org-export-derived-backend-p (plist-get info :back-end) 'latex)
      ;; Derived from LaTeX, no need to use manual ad-hoc LaTeX
      ;; parser.
      element
    ;; Convert ELEMENT to anonymous when ELEMENT is string.
    ;; Otherwise, we cannot modify ELEMENT by side effect.
    (when (org-element-type-p element 'plain-text)
      (setq element (org-element-create 'anonymous nil element)))
    ;; Approximately parse LaTeX fragments, assuming Org mode syntax
    ;; (it is close to original LaTeX, and we do not want to
    ;; re-implement complete LaTeX parser here))
    (org-element-map element t
      (lambda (str)
        (when (stringp str)
          (org-element-set
           str
           (org-element-parse-secondary-string
            str '(latex-fragment entity))))))
    ;; Strip the remaining { and }.
    (org-element-map element t
      (lambda (str)
        (when (stringp str)
          (org-element-set str (replace-regexp-in-string "[{}]" "" str)))))
    element))

(defun org-cite-basic--print-entry (entry style &optional info)
  "Format ENTRY according to STYLE string.
ENTRY is an alist, as returned by `org-cite-basic--get-entry'.
Optional argument INFO is the export state, as a property list."
  (let ((author (org-cite-basic--get-author entry info))
        (title (org-cite-basic--get-field 'title entry info))
        (from
         (or (org-cite-basic--get-field 'publisher entry info)
             (org-cite-basic--get-field 'journal entry info)
             (org-cite-basic--get-field 'institution entry info)
             (org-cite-basic--get-field 'school entry info))))
    (org-cite-basic--print-bibtex-string
     (pcase style
       ("plain"
        (let ((year (org-cite-basic--get-year entry info 'no-suffix)))
          (org-cite-concat
           (org-cite-basic--shorten-names author) ". "
           title (and from (list ", " from)) ", " year ".")))
       ("numeric"
        (let ((n (org-cite-basic--key-number (cdr (assq 'id entry)) info))
              (year (org-cite-basic--get-year entry info 'no-suffix)))
          (org-cite-concat
           (format "[%d] " n) author ", "
           (org-cite-emphasize 'italic title)
           (and from (list ", " from)) ", "
           year ".")))
       ;; Default to author-year.  Use year disambiguation there.
       (_
        (let ((year (org-cite-basic--get-year entry info)))
          (org-cite-concat
           author " (" year "). "
           (org-cite-emphasize 'italic title)
           (and from (list ", " from)) "."))))
     info)))


;;; "Activate" capability
(defun org-cite-basic--close-keys (key keys)
  "List cite keys close to KEY in terms of string distance."
  (seq-filter (lambda (k)
                (>= org-cite-basic-max-key-distance
                    (org-string-distance k key)))
              keys))

(defun org-cite-basic--set-keymap (beg end suggestions)
  "Set keymap on citation key between BEG and END positions.

When the key is know, SUGGESTIONS is nil.  Otherwise, it may be
a list of replacement keys, as strings, which will be offered as
substitutes for the unknown key.  Finally, it may be the symbol
`all'."
  (let ((km (make-sparse-keymap)))
    (define-key km (kbd "<mouse-1>")
      (pcase suggestions
        ('nil #'org-open-at-point)
        ('all #'org-cite-insert)
        (_
         (lambda ()
           (interactive)
           (save-excursion
             (goto-char beg)
             (delete-region beg end)
             (insert
              "@"
              (if (= 1 (length suggestions))
                  (car suggestions)
                (completing-read "Did you mean: "
                                 suggestions nil t))))))))
    (put-text-property beg end 'keymap km)))

(defun org-cite-basic-activate (citation)
  "Set various text properties on CITATION object.

Fontify whole citation with `org-cite' face.  Fontify key with `error' face
when it does not belong to known keys.  Otherwise, use `org-cite-key' face.

Moreover, when mouse is on a known key, display the corresponding bibliography.
On a wrong key, suggest a list of possible keys, and offer to substitute one of
them with a mouse click."
  (pcase-let ((`(,beg . ,end) (org-cite-boundaries citation))
              (keys (org-cite-basic--all-keys)))
    (put-text-property beg end 'font-lock-multiline t)
    (add-face-text-property beg end 'org-cite)
    (dolist (reference (org-cite-get-references citation))
      (pcase-let* ((`(,beg . ,end) (org-cite-key-boundaries reference))
                   (key (org-element-property :key reference)))
        ;; Highlight key on mouse over.
        (put-text-property beg end
                           'mouse-face
                           org-cite-basic-mouse-over-key-face)
        (if (member key keys)
            ;; Activate a correct key.  Face is `org-cite-key' and
            ;; `help-echo' displays bibliography entry, for reference.
            ;; <mouse-1> calls `org-open-at-point'.
            (let* ((entry (org-cite-basic--get-entry key))
                   (bibliography-entry
                    (org-element-interpret-data
                     (org-cite-basic--print-entry entry "plain"))))
              (add-face-text-property beg end 'org-cite-key)
              (put-text-property beg end 'help-echo bibliography-entry)
              (org-cite-basic--set-keymap beg end nil))
          ;; Activate a wrong key.  Face is `error', `help-echo'
          ;; displays possible suggestions.
          (add-face-text-property beg end 'error)
          (let ((close-keys (org-cite-basic--close-keys key keys)))
            (when close-keys
              (put-text-property beg end 'help-echo
                                 (concat "Suggestions (mouse-1 to substitute): "
                                         (mapconcat #'identity close-keys " "))))
            ;; When the are close know keys, <mouse-1> provides
            ;; completion to fix the current one.  Otherwise, call
            ;; `org-cite-insert'.
            (org-cite-basic--set-keymap beg end (or close-keys 'all))))))))


;;; "Export" capability
(defun org-cite-basic--format-author-year (citation format-cite format-ref info)
  "Format CITATION object according to author-year format.

FORMAT-CITE is a function of three arguments: the global prefix, the contents,
and the global suffix.  All arguments can be strings or secondary strings.

FORMAT-REF is a function of four arguments: the reference prefix, as a string or
secondary string, the author, the year, and the reference suffix, as a string or
secondary string.

INFO is the export state, as a property list."
  (org-export-data
   (funcall format-cite
            (org-element-property :prefix citation)
            (org-cite-mapconcat
             (lambda (ref)
               (let ((k (org-element-property :key ref))
                     (prefix (org-element-property :prefix ref))
                     (suffix (org-element-property :suffix ref)))
                 (funcall format-ref
                          prefix
                          (or (org-cite-basic--get-author k info) "??")
                          (or (org-cite-basic--get-year k info) "????")
                          suffix)))
             (org-cite-get-references citation)
             org-cite-basic-author-year-separator)
            (org-element-property :suffix citation))
   info))

(defun org-cite-basic--citation-numbers (citation info)
  "Return numbers associated to references in CITATION object.
INFO is the export state as a property list."
  (let* ((numbers
          (sort (mapcar (lambda (k) (org-cite-basic--key-number k info))
                        (org-cite-get-references citation t))
                #'<))
         (last (car numbers))
         (result (list (number-to-string (pop numbers)))))
    ;; Use compact number references, i.e., "1, 2, 3" becomes "1-3".
    (while numbers
      (let ((current (pop numbers))
            (next (car numbers)))
        (cond
         ((and next
               (= current (1+ last))
               (= current (1- next)))
          (unless (equal "-" (car result))
            (push "-" result)))
         ((equal "-" (car result))
          (push (number-to-string current) result))
         (t
          (push (format ", %d" current) result)))
        (setq last current)))
    (apply #'concat (nreverse result))))

(defun org-cite-basic--field-less-p (field info)
  "Return a sort predicate comparing FIELD values for two citation keys.
INFO is the export state, as a property list."
  (and field
       (lambda (a b)
         (org-string<
          (org-cite-basic--get-field field a info 'raw)
          (org-cite-basic--get-field field b info 'raw)
          nil t))))

(defun org-cite-basic--sort-keys (keys info)
  "Sort KEYS by author name.
INFO is the export communication channel, as a property list."
  (let ((predicate (org-cite-basic--field-less-p org-cite-basic-sorting-field info)))
    (if predicate
        (sort keys predicate)
      keys)))

(defun org-cite-basic-export-citation (citation style _ info)
  "Export CITATION object.
STYLE is the expected citation style, as a pair of strings or nil.  INFO is the
export communication channel, as a property list."
  (let ((has-variant-p
         (lambda (variant type)
           ;; Non-nil when style VARIANT has TYPE.  TYPE is either
           ;; `bare' or `caps'.
           (member variant
                   (pcase type
                     ('bare '("bare" "bare-caps" "b" "bc"))
                     ('caps '("caps" "bare-caps" "c" "bc"))
                     (_ (error "Invalid variant type: %S" type)))))))
    (pcase style
      ;; "author" style.
      (`(,(or "author" "a") . ,variant)
       (let ((caps (member variant '("caps" "c"))))
         (org-cite-basic--format-author-year
          citation
          (lambda (p c s) (org-cite-concat p c s))
          (lambda (prefix author _ suffix)
            (org-cite-concat
             prefix
             (if caps (org-cite-capitalize author) author)
             suffix))
          info)))
      ;; "noauthor" style.
      (`(,(or "noauthor" "na") . ,variant)
       (let ((bare? (funcall has-variant-p variant 'bare)))
         (org-cite-basic--format-author-year
          citation
          (lambda (prefix contents suffix)
            (org-cite-concat
             (unless bare? "(")
             prefix
             contents
             suffix
             (unless bare? ")")))
          (lambda (prefix _ year suffix)
            (org-cite-concat prefix year suffix))
          info)))
      ;; "nocite" style.
      (`(,(or "nocite" "n") . ,_) nil)
      ;; "text" and "note" styles.
      (`(,(and (or "text" "note" "t" "ft") style) . ,variant)
       (when (and (member style '("note" "ft"))
                  (not (org-cite-inside-footnote-p citation)))
         (org-cite-adjust-note citation info)
         (org-cite-wrap-citation citation info))
       (let ((bare (funcall has-variant-p variant 'bare))
             (caps (funcall has-variant-p variant 'caps)))
         (org-cite-basic--format-author-year
          citation
          (lambda (p c s) (org-cite-concat p c s))
          (lambda (p a y s)
            (org-cite-concat p
                             (if caps (org-cite-capitalize a) a)
                             (if bare " " " (")
                             y
                             (and (not bare) ")")
                             s))
          info)))
      ;; "numeric" style.
      ;;
      ;; When using this style on citations with multiple references,
      ;; use global affixes and ignore local ones.
      (`(,(or "numeric" "nb") . ,_)
       (pcase-let ((`(,prefix . ,suffix) (org-cite-main-affixes citation)))
         (org-export-data
          (org-cite-concat
           "(" prefix (org-cite-basic--citation-numbers citation info) suffix ")")
          info)))
      ;; Default ("nil") style.
      (`(,_ . ,variant)
       (let ((bare (funcall has-variant-p variant 'bare))
             (caps (funcall has-variant-p variant 'caps)))
         (org-cite-basic--format-author-year
          citation
          (lambda (p c s)
            (org-cite-concat (and (not bare) "(") p c s (and (not bare) ")")))
          (lambda (p a y s)
            (org-cite-concat p (if caps (org-cite-capitalize a) a) ", " y s))
          info)))
      ;; This should not happen.
      (_ (error "Invalid style: %S" style)))))

(defun org-cite-basic-export-bibliography (keys _files style _props backend info)
  "Generate bibliography.
KEYS is the list of cited keys, as strings.  STYLE is the expected bibliography
style, as a string.  BACKEND is the export backend, as a symbol.  INFO is the
export state, as a property list."
  (mapconcat
   (lambda (entry)
     (org-export-data
      (org-cite-make-paragraph
       (and (org-export-derived-backend-p backend 'latex)
            (org-export-raw-string "\\noindent\n"))
       (org-cite-basic--print-entry entry style info))
      info))
   (delq nil
         (mapcar
          (lambda (k) (org-cite-basic--get-entry k info))
          (org-cite-basic--sort-keys keys info)))
   "\n"))


;;; "Follow" capability
(defun org-cite-basic-goto (datum _)
  "Follow citation or citation reference DATUM.
When DATUM is a citation reference, open bibliography entry referencing
the citation key.  Otherwise, select which key to follow among all keys
present in the citation."
  (let* ((key
          (if (org-element-type-p datum 'citation-reference)
              (org-element-property :key datum)
            (pcase (org-cite-get-references datum t)
              (`(,key) key)
              (keys
               (or (completing-read "Select citation key: " keys nil t)
                   (user-error "Aborted"))))))
         (file
          (pcase (seq-find (pcase-lambda (`(,_ . ,entries))
                             (gethash key entries))
                           (org-cite-basic--parse-bibliography))
            (`(,f . ,_) f)
            (_  (user-error "Cannot find citation key: %S" key)))))
    (org-open-file file '(4))
    (pcase (file-name-extension file)
      ("json"
       ;; `rx' can not be used with Emacs <27.1 since `literal' form
       ;; is not supported.
       (let ((regexp (rx-to-string `(seq "\"id\":" (0+ (any "[ \t]")) "\"" ,key "\"") t)))
         (goto-char (point-min))
         (re-search-forward regexp)
         (search-backward "{")))
      (_
       (bibtex-set-dialect)
       (bibtex-search-entry key)))))


;;; "Insert" capability
(defun org-cite-basic--complete-style (_)
  "Offer completion for style.
Return chosen style as a string."
  (let* ((styles
          (mapcar (pcase-lambda (`((,style . ,_) . ,_))
                    style)
                  (org-cite-supported-styles))))
    (pcase styles
      (`(,style) style)
      (_ (completing-read "Style (\"\" for default): " styles nil t)))))

(defun org-cite-basic--key-completion-table ()
  "Return completion table for cite keys, as a hash table.

In this hash table, keys are a strings with author, date, and
title of the reference.  Values are the cite keys.

Return nil if there are no bibliography files or no entries."
  ;; Populate bibliography cache.
  (let ((entries (org-cite-basic--parse-bibliography)))
    (cond
     ((null entries) nil)               ;no bibliography files
     ((gethash entries org-cite-basic--completion-cache)
      org-cite-basic--completion-cache)
     (t
      (clrhash org-cite-basic--completion-cache)
      (dolist (key (org-cite-basic--all-keys))
        (let* ((entry (org-cite-basic--get-entry
                       key
                       ;; Supply pre-calculated bibliography to avoid
                       ;; performance degradation.
                       (list :cite-basic/bibliography entries)))
               (completion
                (concat
                 (let ((author (org-cite-basic--get-author entry nil 'raw)))
                   (if author
                       (truncate-string-to-width
                        (replace-regexp-in-string " and " "; " author)
                        org-cite-basic-author-column-end nil ?\s)
                     (make-string org-cite-basic-author-column-end ?\s)))
                 org-cite-basic-column-separator
                 (let ((date (org-cite-basic--get-year entry nil 'no-suffix)))
                   (format "%4s" (or date "")))
                 org-cite-basic-column-separator
                 (org-cite-basic--get-field 'title entry nil 'raw))))
          (puthash completion key org-cite-basic--completion-cache)))
      (unless (map-empty-p org-cite-basic--completion-cache) ;no key
        (puthash entries t org-cite-basic--completion-cache)
        org-cite-basic--completion-cache)))))

(defun org-cite-basic--complete-key-dynamic-crm-separator
    (completion-candidates separator)
  "Return a repeated version of SEPARATOR as needed.
The number of appeared SEPARATORs in the returned string is sufficient
so that none of COMPLETION-CANDIDATES contains it.  SEPARATOR should be
a literal string."
  (let* ((dyn-sep separator)
         (consecutive-sep-regexp
          (format "%s+" (regexp-opt (list separator)))))
    (with-temp-buffer
      (dolist (cand completion-candidates)
        (when (stringp cand)
          (insert cand "\n")))
      (goto-char (point-min))
      (while (re-search-forward consecutive-sep-regexp nil t)
        (while (<= (length dyn-sep) (length (match-string 0)))
          (setq dyn-sep (concat dyn-sep separator)))))
    dyn-sep))

(defvar crm-separator) ; defined in crm.el

(defun org-cite-basic--crm-indicate-prompt (orig-prompt)
  "Return annotated ORIG-PROMPT with `crm-separator'.
For Emacs version 31 and above, just return ORIG-PROMPT."
  (cond
   ((boundp 'crm-prompt)
    orig-prompt)
   (t
    (let ((sep
           (or (get-text-property 0 'separator crm-separator)
               (string-replace "[ \t]*" "" crm-separator))))
      (format "[list separated by %s] %s" sep orig-prompt)))))

(defun org-cite-basic--complete-key (&optional multiple)
  "Prompt for a reference key and return a citation reference string.

When optional argument MULTIPLE is non-nil, prompt for multiple
keys, until one of them is nil.  Then return the list of
reference strings selected.

Raise an error when no bibliography is set in the buffer."
  (let* ((table
          (or (org-cite-basic--key-completion-table)
              (user-error "No bibliography set")))
         (prompt-single
          (lambda (text) (completing-read text table nil t)))
         (choice-to-citation
          (lambda (choice)
            (let ((key (gethash choice table)))
              (org-string-nw-p key)))))
    (cond
     ((null multiple)
      (funcall choice-to-citation
               (completing-read "Key: " table nil t)))
     (org-cite-basic-complete-key-crm-separator
      (let*
          ((crm-separator
            (pcase org-cite-basic-complete-key-crm-separator
              ((pred stringp)
               org-cite-basic-complete-key-crm-separator)
              (`dynamic
               (let
                   ((repeated-sep
                     (regexp-quote
                      (org-cite-basic--complete-key-dynamic-crm-separator
                       (hash-table-keys table) ";"))))
                 (propertize (format "[ \t]*%s[ \t]*" repeated-sep)
                             'separator
                             repeated-sep)))))
           (prompt (org-cite-basic--crm-indicate-prompt "Keys: ")))
        ;; FIXME: Use `seq-keep' after we drop Emacs 28 support.
        (delq
         nil
         (seq-map
          choice-to-citation
          (completing-read-multiple prompt table nil t)))))
     (t
      (let* ((keys nil)
             (build-prompt
              (lambda ()
                (if keys
                    (format "Key (empty input exits) %s: "
                            (mapconcat #'identity (reverse keys) ";"))
                  "Key (empty input exits): "))))
        (let ((key (funcall prompt-single (funcall build-prompt))))
          (while (org-string-nw-p key)
            (push (gethash key table) keys)
            (setq key
                  (funcall prompt-single (funcall build-prompt)))))
        keys)))))


;;; Register processor
(org-cite-register-processor 'basic
  :activate #'org-cite-basic-activate
  :export-citation #'org-cite-basic-export-citation
  :export-bibliography #'org-cite-basic-export-bibliography
  :follow #'org-cite-basic-goto
  :insert (org-cite-make-insert-processor #'org-cite-basic--complete-key
                                          #'org-cite-basic--complete-style)
  :cite-styles
  '((("author" "a") ("caps" "c"))
    (("noauthor" "na") ("bare" "b"))
    (("nocite" "n"))
    (("note" "ft") ("bare-caps" "bc") ("caps" "c"))
    (("numeric" "nb"))
    (("text" "t") ("bare-caps" "bc") ("caps" "c"))
    (("nil") ("bare" "b") ("bare-caps" "bc") ("caps" "c"))))

(provide 'oc-basic)
;;; oc-basic.el ends here
