;;; eselect-news.el --- Read Gentoo eselect news in Emacs -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Marcin Kolenda
;;
;; Author: Marcin Kolenda
;; Maintainer: Marcin Kolenda
;; Package-Version: 0.1.0
;; Package-Revision: v0.1.0-0-gab4ecb45eef2
;; Package-Requires: ((emacs "27.1"))
;; Keywords: tools, convenience
;; URL: https://github.com/marcin/emacs-eselect-news
;; SPDX-License-Identifier: GPL-3.0-or-later
;;
;; This file is not part of GNU Emacs.
;;
;; eselect-news 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.
;;
;; eselect-news 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 eselect-news.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Minimal TUI for `eselect news read all`.
;;
;; Entry point:
;;   M-x eselect-news
;;
;; Keys in *eselect-news* buffer:
;;   q - quit window
;;   g - refresh data from eselect
;;   n - next section
;;   p - previous section
;;   r - toggle read/unread for active section

;;; Code:

(require 'cl-lib)
(require 'subr-x)

(defgroup eselect-news nil
  "Read Gentoo eselect news from Emacs."
  :group 'applications
  :prefix "eselect-news-")

(defcustom eselect-news-state-file
  (locate-user-emacs-file "eselect-news-state.el")
  "File used to persist read/unread state."
  :type 'file
  :group 'eselect-news)

(defcustom eselect-news-heading-font-family nil
  "Font family for section heading faces.
Nil means keep the current face family."
  :type '(choice (const :tag "Unchanged" nil) string)
  :group 'eselect-news)

(defcustom eselect-news-body-font-family nil
  "Font family for metadata/body faces.
Nil means keep the current face family."
  :type '(choice (const :tag "Unchanged" nil) string)
  :group 'eselect-news)

(defcustom eselect-news-heading-font-height 1.1
  "Height for heading faces."
  :type 'number
  :group 'eselect-news)

(defface eselect-news-heading-face
  '((t :weight bold :height 1.1))
  "Face for section headings."
  :group 'eselect-news)

(defface eselect-news-active-heading-face
  '((t :weight bold :height 1.1 :underline t))
  "Face for active section heading."
  :group 'eselect-news)

(defface eselect-news-unread-face
  '((t :foreground "orange red" :weight bold))
  "Face for unread marker."
  :group 'eselect-news)

(defface eselect-news-read-face
  '((t :foreground "forest green" :weight bold))
  "Face for read marker."
  :group 'eselect-news)

(defface eselect-news-meta-key-face
  '((t :foreground "steel blue" :weight bold))
  "Face for metadata keys."
  :group 'eselect-news)

(defface eselect-news-meta-value-face
  '((t :foreground "gray50"))
  "Face for metadata values."
  :group 'eselect-news)

(defface eselect-news-date-face
  '((t :foreground "goldenrod3" :weight bold))
  "Face for section date near read/unread marker."
  :group 'eselect-news)

(defvar eselect-news--sections nil
  "Parsed news sections.")

(defvar eselect-news--active-index 0
  "Index of active section.")

(defvar eselect-news--expanded-index nil
  "Index of expanded section or nil.")

(defvar eselect-news--section-positions nil
  "List of marker positions for section starts.")

(defvar eselect-news--read-ids nil
  "List of section IDs marked as read.")

(defvar-local eselect-news--fold-overlays nil
  "Overlays hiding collapsed section details.")

(defvar eselect-news-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "q") #'quit-window)
    (define-key map (kbd "g") #'eselect-news-refresh)
    (define-key map (kbd "n") #'eselect-news-next)
    (define-key map (kbd "p") #'eselect-news-previous)
    (define-key map (kbd "r") #'eselect-news-toggle-read)
    (define-key map (kbd "RET") #'eselect-news-toggle-expand)
    (define-key map (kbd "TAB") #'eselect-news-toggle-expand)
    (define-key map (kbd "SPC") #'eselect-news-toggle-expand)
    map)
  "Keymap for `eselect-news-mode'.")

(define-derived-mode eselect-news-mode special-mode "eselect-news"
  "Major mode for browsing Gentoo eselect news."
  (setq-local truncate-lines nil)
  (setq-local buffer-read-only t)
  (setq-local search-invisible 'open)
  (add-to-invisibility-spec 'eselect-news)
  (eselect-news--apply-fonts))

(defun eselect-news--apply-fonts ()
  "Apply user-configured fonts for eselect-news faces."
  (let ((heading-family (or eselect-news-heading-font-family 'unspecified))
        (body-family (or eselect-news-body-font-family 'unspecified)))
    (set-face-attribute 'eselect-news-heading-face nil
                        :family heading-family
                        :height eselect-news-heading-font-height)
    (set-face-attribute 'eselect-news-active-heading-face nil
                        :family heading-family
                        :height eselect-news-heading-font-height)
    (set-face-attribute 'eselect-news-meta-key-face nil :family body-family)
    (set-face-attribute 'eselect-news-meta-value-face nil :family body-family)
    (set-face-attribute 'eselect-news-date-face nil :family body-family)))

(defun eselect-news--clear-fold-overlays ()
  "Remove and clear fold overlays."
  (mapc #'delete-overlay eselect-news--fold-overlays)
  (setq eselect-news--fold-overlays nil))

(defun eselect-news--isearch-open-overlay (ov)
  "Make hidden text visible for isearch."
  (overlay-put ov 'invisible nil))

(defun eselect-news--isearch-open-overlay-temporary (ov hide-p)
  "Temporarily expose OV during isearch.
When HIDE-P is non-nil, hide it again."
  (overlay-put ov 'invisible (and hide-p 'eselect-news)))

(defun eselect-news--section-start-p (lines idx)
  "Return non-nil when LINES at IDX starts a section."
  (and (< idx (length lines))
       (string-match-p "^[^ \t\n].*" (nth idx lines))
       (< (1+ idx) (length lines))
       (string-match-p
        "^  \\(Title\\|Author\\|Posted\\|Revision\\)\\s-+"
        (nth (1+ idx) lines))))

(defun eselect-news--parse-sections (text)
  "Parse eselect output TEXT into section plists."
  (let* ((lines (split-string text "\n"))
         (idx 0)
         (max (length lines))
         sections)
    (while (< idx max)
      (if (not (eselect-news--section-start-p lines idx))
          (setq idx (1+ idx))
        (let* ((subject (string-trim (nth idx lines)))
               (title "")
               (author "")
               (posted "")
               (revision ""))
          (setq idx (1+ idx))
          (let ((last-key nil))
            ;; Parse header attributes until the first blank line.
            ;; Some values (notably Title) may wrap to continuation lines.
            (while (and (< idx max)
                        (not (string-empty-p (nth idx lines))))
              (let ((line (nth idx lines)))
                (if (string-match "^  \\([A-Za-z-]+\\)\\s-+\\(.*\\)$" line)
                    (let ((key (match-string 1 line))
                          (val (string-trim (match-string 2 line))))
                      (setq last-key key)
                      (pcase key
                        ("Title" (setq title val))
                        ("Author" (setq author val))
                        ("Posted" (setq posted val))
                        ("Revision" (setq revision val))))
                  (when (and last-key (string-match-p "^\\s-+\\S-" line))
                    (let ((cont (string-trim line)))
                      (pcase last-key
                        ("Title" (setq title (string-join (list title cont) " ")))
                        ("Author" (setq author (string-join (list author cont) " ")))
                        ("Posted" (setq posted (string-join (list posted cont) " ")))
                        ("Revision" (setq revision (string-join (list revision cont) " "))))))))
              (setq idx (1+ idx))))
          (when (and (< idx max) (string-empty-p (nth idx lines)))
            (setq idx (1+ idx)))
          (let ((body-lines nil))
            (while (and (< idx max)
                        (not (eselect-news--section-start-p lines idx)))
              (push (nth idx lines) body-lines)
              (setq idx (1+ idx)))
            (while (and body-lines (string-empty-p (car body-lines)))
              (setq body-lines (cdr body-lines)))
            (setq sections
                  (append sections
                          (list (list :id subject
                                      :subject subject
                                      :title title
                                      :author author
                                      :posted posted
                                      :revision revision
                                      :body (string-join (nreverse body-lines) "\n")))))))))
    sections))

(defun eselect-news--run-command ()
  "Return stdout from `eselect news read all` or signal an error."
  (with-temp-buffer
    (let ((exit-code (call-process "eselect" nil t nil "news" "read" "all")))
      (unless (eq exit-code 0)
        (error "Command failed: eselect news read all (exit %s)" exit-code))
      (buffer-string))))

(defun eselect-news--load-state ()
  "Load persisted read state."
  (setq eselect-news--read-ids nil)
  (when (file-readable-p eselect-news-state-file)
    (condition-case nil
        (with-temp-buffer
          (insert-file-contents eselect-news-state-file)
          (let ((data (read (current-buffer))))
            (setq eselect-news--read-ids
                  (cl-remove-duplicates
                   (copy-sequence (plist-get data :read-ids))
                   :test #'string=))))
      (error (setq eselect-news--read-ids nil)))))

(defun eselect-news--save-state ()
  "Persist read state."
  (let ((dir (file-name-directory eselect-news-state-file)))
    (unless (file-directory-p dir)
      (make-directory dir t)))
  (with-temp-file eselect-news-state-file
    (prin1 (list :read-ids (sort (copy-sequence eselect-news--read-ids) #'string<))
           (current-buffer))
    (insert "\n")))

(defun eselect-news--section-read-p (section)
  "Return non-nil if SECTION is marked as read."
  (member (plist-get section :id) eselect-news--read-ids))

(defun eselect-news--section-heading (section)
  "Return heading text for SECTION."
  (let ((title (plist-get section :title))
        (subject (plist-get section :subject)))
    (if (string-empty-p title) subject title)))

(defun eselect-news--insert-meta-line (key value)
  "Insert metadata line with KEY and VALUE."
  (insert "  ")
  (insert (propertize (format "%-8s" key) 'face 'eselect-news-meta-key-face))
  (insert " ")
  (insert (propertize value 'face 'eselect-news-meta-value-face))
  (insert "\n"))

(defun eselect-news--first-unread-index ()
  "Return index of first unread section, or nil if all are read."
  (cl-position-if-not #'eselect-news--section-read-p eselect-news--sections))

(defun eselect-news--initial-index ()
  "Return initial active index for fresh `M-x eselect-news`."
  (or (eselect-news--first-unread-index)
      (when eselect-news--sections
        (1- (length eselect-news--sections)))))

(defun eselect-news--render (&optional align)
  "Render the current `eselect-news` buffer.
ALIGN controls viewport after rendering:
- nil or `top': active section at window top
- `middle': active section centered in window
- `no-recenter': jump to active section without recenter."
  (let ((inhibit-read-only t))
    (eselect-news--clear-fold-overlays)
    (erase-buffer)
    (setq eselect-news--section-positions nil)
    (if (null eselect-news--sections)
        (insert "No sections found.\n")
      (cl-loop for section in eselect-news--sections
               for idx from 0 do
               (let* ((start (point))
                      (active (eq idx eselect-news--active-index))
                      (expanded (eq idx eselect-news--expanded-index))
                      (readp (eselect-news--section-read-p section))
                      (status (if readp "[R]" "[U]"))
                      (status-face (if readp
                                       'eselect-news-read-face
                                     'eselect-news-unread-face))
                      (heading-face (if active
                                        'eselect-news-active-heading-face
                                      'eselect-news-heading-face))
                      (posted (or (plist-get section :posted) "---- -- --"))
                      (expander (if expanded "[-]" "[+]"))
                      (heading (eselect-news--section-heading section)))
                 (push start eselect-news--section-positions)
                 (insert (propertize status 'face status-face))
                 (insert " ")
                 (insert (propertize posted 'face 'eselect-news-date-face))
                 (insert " ")
                 (insert expander)
                 (insert " ")
                 (insert (propertize heading 'face heading-face))
                 (insert "\n")
                 (let ((details-start (point)))
                   (eselect-news--insert-meta-line "Subject" (plist-get section :subject))
                   (eselect-news--insert-meta-line "Author" (or (plist-get section :author) ""))
                   (eselect-news--insert-meta-line "Posted" (or (plist-get section :posted) ""))
                   (eselect-news--insert-meta-line "Revision" (or (plist-get section :revision) ""))
                   (insert "\n")
                   (insert (or (plist-get section :body) ""))
                   (unless (bolp) (insert "\n"))
                   (let ((details-end (point)))
                     (unless expanded
                       (let ((ov (make-overlay details-start details-end)))
                         (overlay-put ov 'invisible 'eselect-news)
                         (overlay-put ov 'isearch-open-invisible
                                      #'eselect-news--isearch-open-overlay)
                         (overlay-put ov 'isearch-open-invisible-temporary
                                      #'eselect-news--isearch-open-overlay-temporary)
                         (push ov eselect-news--fold-overlays)))))
                 (when expanded
                   (insert "\n")))))
    (setq eselect-news--section-positions (nreverse eselect-news--section-positions))
    (goto-char (point-min))
    (eselect-news--goto-active-top align)))

(defun eselect-news--goto-active-top (&optional align)
  "Move point to active section and place it in window based on ALIGN."
  (let ((pos (nth eselect-news--active-index eselect-news--section-positions)))
    (when pos
      (goto-char pos)
      (pcase align
        ('no-recenter nil)
        ('middle (recenter))
        (_ (recenter 0))))))

(defun eselect-news--next-index-from-point ()
  "Return index of next section relative to point."
  (cl-position-if (lambda (pos) (> pos (point)))
                  eselect-news--section-positions))

(defun eselect-news--previous-index-from-point ()
  "Return index of previous section relative to point."
  (let ((idx nil)
        (i 0))
    (dolist (pos eselect-news--section-positions idx)
      (when (< pos (point))
        (setq idx i))
      (setq i (1+ i)))))

(defun eselect-news--reload (&optional keep-active-id align)
  "Reload sections from eselect.
When KEEP-ACTIVE-ID is non-nil, preserve active section by ID.
ALIGN controls viewport alignment after render."
  (let ((old-id keep-active-id))
    (setq eselect-news--sections
          (eselect-news--parse-sections (eselect-news--run-command)))
    (unless eselect-news--sections
      (setq eselect-news--active-index 0
            eselect-news--expanded-index nil))
    (when eselect-news--sections
      (if old-id
          (let ((new-index
                 (cl-position old-id eselect-news--sections
                              :test #'string=
                              :key (lambda (s) (plist-get s :id)))))
            (setq eselect-news--active-index (or new-index 0)))
        (setq eselect-news--active-index
              (or (eselect-news--initial-index) 0)))
      (setq eselect-news--expanded-index nil)))
  (eselect-news--render align))

;;;###autoload
(defun eselect-news ()
  "Open *eselect-news* buffer and show Gentoo news."
  (interactive)
  (let ((buf (get-buffer-create "*eselect-news*")))
    (switch-to-buffer buf)
    (unless (derived-mode-p 'eselect-news-mode)
      (eselect-news-mode))
    (eselect-news--load-state)
    (condition-case err
        (eselect-news--reload nil 'middle)
      (error
       (let ((inhibit-read-only t))
         (erase-buffer)
         (insert (format "Failed to load news: %s\n" (error-message-string err))))))
    (message "eselect-news: n/p move, r toggle read, g refresh, q quit")))

(defun eselect-news-refresh ()
  "Refresh sections from `eselect`."
  (interactive)
  (let* ((active (nth eselect-news--active-index eselect-news--sections))
         (active-id (and active (plist-get active :id))))
    (condition-case err
        (eselect-news--reload active-id)
      (error (message "eselect-news refresh failed: %s" (error-message-string err))))))

(defun eselect-news-next ()
  "Move to next section relative to current point."
  (interactive)
  (when eselect-news--sections
    (let ((target (eselect-news--next-index-from-point)))
      (when (numberp target)
        (setq eselect-news--active-index target)
        ;; Navigation always returns to collapsed overview mode.
        (setq eselect-news--expanded-index nil)
        (eselect-news--render 'middle)))))

(defun eselect-news-previous ()
  "Move to previous section relative to current point."
  (interactive)
  (when eselect-news--sections
    (let ((target (eselect-news--previous-index-from-point)))
      (when (numberp target)
        (setq eselect-news--active-index target)
        ;; Navigation always returns to collapsed overview mode.
        (setq eselect-news--expanded-index nil)
        (eselect-news--render 'middle)))))

(defun eselect-news-toggle-read ()
  "Toggle read/unread status for active section."
  (interactive)
  (let* ((section (nth eselect-news--active-index eselect-news--sections))
         (id (and section (plist-get section :id))))
    (when id
      (if (member id eselect-news--read-ids)
          (setq eselect-news--read-ids (delete id eselect-news--read-ids))
        (push id eselect-news--read-ids))
      (eselect-news--save-state)
      (eselect-news--render 'no-recenter))))

(defun eselect-news-toggle-expand ()
  "Toggle expansion for active section.
Only one section can be expanded at a time."
  (interactive)
  (if (eq eselect-news--expanded-index eselect-news--active-index)
      (progn
        (setq eselect-news--expanded-index nil)
        (eselect-news--render 'middle))
    (setq eselect-news--expanded-index eselect-news--active-index)
    (eselect-news--render 'top)))

(provide 'eselect-news)

;;; eselect-news.el ends here
