;;; sx-question-list.el --- major-mode for navigating questions list  -*- lexical-binding: t; -*-

;; Copyright (C) 2014  Artur Malabarba

;; Author: Artur Malabarba <bruce.connor.am@gmail.com>

;; 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 <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Provides question list logic (as used in e.g. `sx-tab-frontpage').

;;; Code:
(require 'tabulated-list)
(require 'cl-lib)

(require 'sx)
(require 'sx-switchto)
(require 'sx-time)
(require 'sx-tag)
(require 'sx-site)
(require 'sx-question)
(require 'sx-question-mode)
(require 'sx-favorites)

(defgroup sx-question-list nil
  "Customization group for sx-question-list."
  :prefix "sx-question-list-"
  :tag "SX Question List"
  :group 'sx)

(defgroup sx-question-list-faces nil
  "Customization group for the faces of `sx-question-list'."
  :prefix "sx-question-list-"
  :tag "SX Question List Faces"
  :group 'sx-question-list)


;;; Customization
(defcustom sx-question-list-height 12
  "Height, in lines, of SX's *question-list* buffer."
  :type 'integer
  :group 'sx-question-list)

(defcustom sx-question-list-excluded-tags nil
  "List of tags (strings) to be excluded from the question list."
  :type '(repeat string)
  :group 'sx-question-list)

(defface sx-question-list-parent
  '((t :inherit default))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-answers
  '((((background light)) :foreground "SeaGreen4"
     :height 1.0 :inherit sx-question-list-parent)
    (((background dark))  :foreground "#D1FA71"
     :height 1.0 :inherit sx-question-list-parent)
    (t :inherit sx-question-list-parent))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-answers-accepted
  '((t :box 1 :inherit sx-question-list-answers))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-score
  '((t :height 1.0 :inherit sx-question-list-parent))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-score-upvoted
  '((t :weight bold
       :inherit sx-question-list-score))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-date
  '((t :inherit font-lock-comment-face))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-read-question
  '((t :height 1.0 :inherit sx-question-list-parent))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-unread-question
  '((t :weight bold :inherit sx-question-list-read-question))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-favorite
  '((t :inherit sx-question-list-score-upvoted))
  ""
  :group 'sx-question-list-faces)

(defface sx-question-list-bounty
  '((t :inherit font-lock-warning-face))
  ""
  :group 'sx-question-list-faces)


;;; Backend variables

(defvar sx-question-list--site nil
  "Site being displayed in the *question-list* buffer.")
(make-variable-buffer-local 'sx-question-list--site)

(defvar sx-question-list--order nil
  "Order being displayed in the *question-list* buffer.
This is also affected by `sx-question-list--descending'.")
(make-variable-buffer-local 'sx-question-list--order)

(defvar sx-question-list--descending t
  "In which direction should `sx-question-list--order' be sorted.
If non-nil (default), descending.
If nil, ascending.")
(make-variable-buffer-local 'sx-question-list--order)

(defvar sx-question-list--print-function #'sx-question-list--print-info
  "Function to convert a question alist into a tabulated-list entry.
Used by `sx-question-list-mode', the default value is
`sx-question-list--print-info'.

If this is set to a different value, it may be necessary to
change `tabulated-list-format' accordingly.")
(make-variable-buffer-local 'sx-question-list--print-function)

(defcustom sx-question-list-ago-string " ago"
  "String appended to descriptions of the time since something happened.
Used in the questions list to indicate a question was updated
\"4d ago\"."
  :type 'string
  :group 'sx-question-list)

(defun sx-question-list--print-info (question-data)
  "Convert `json-read' QUESTION-DATA into tabulated-list format.

This is the default printer used by `sx-question-list'. It
assumes QUESTION-DATA is an alist containing (at least) the
elements:
 `question_id', `site_par', `score', `upvoted', `answer_count',
 `title', `bounty_amount', `bounty_amount', `bounty_amount',
 `last_activity_date', `tags', `owner'.

Also see `sx-question-list-refresh'."
  (sx-assoc-let question-data
    (let ((favorite (if (member .question_id
                                (assoc .site_par
                                       sx-favorites--user-favorite-list))
                        (if (char-displayable-p ?\x2b26) "\x2b26" "*") " ")))
      (list
       question-data
       (vector
        (list (int-to-string .score)
              'face (if .upvoted 'sx-question-list-score-upvoted
                      'sx-question-list-score))
        (list (int-to-string .answer_count)
              'face (if (sx-question--accepted-answer-id question-data)
                        'sx-question-list-answers-accepted
                      'sx-question-list-answers))
        (concat
         ;; First line
         (propertize
          .title
          'face (if (sx-question--read-p question-data)
                    'sx-question-list-read-question
                  'sx-question-list-unread-question))
         (propertize " " 'display "\n   ")
         ;; Second line
         (propertize favorite 'face 'sx-question-list-favorite)
         (if (and (numberp .bounty_amount) (> .bounty_amount 0))
             (propertize (format "%4d" .bounty_amount)
                         'face 'sx-question-list-bounty)
           "    ")
         " "
         (propertize (format "%3s%s"
                       (sx-time-since .last_activity_date)
                       sx-question-list-ago-string)
                     'face 'sx-question-list-date)
         " "
         ;; @TODO: Make this width customizable. (Or maybe just make
         ;; the whole thing customizable)
         (format "%-40s" (sx-tag--format-tags .tags sx-question-list--site))
         " "
         (sx-user--format "%15d %4r" .owner)
         (propertize " " 'display "\n")))))))

(defvar sx-question-list--pages-so-far 0
  "Number of pages currently being displayed.
This variable gets reset to 0 before every refresh.
It should be used by `sx-question-list--next-page-function'.")
(make-variable-buffer-local 'sx-question-list--pages-so-far)

(defvar sx-question-list--refresh-function nil 
  "Function used to refresh the list of questions to be displayed.
Used by `sx-question-list-mode', this is a function, called with
no arguments, which returns a list questions to be displayed,
like the one returned by `sx-question-get-questions'.

If this is not set, the value of `sx-question-list--dataset' is
used, and the list is simply redisplayed.")
(make-variable-buffer-local 'sx-question-list--refresh-function)

(defvar sx-question-list--next-page-function nil
  "Function used to fetch the next page of questions to be displayed.
Used by `sx-question-list-mode'. This is a function, called with
no arguments, which returns a list questions to be displayed,
like the one returned by `sx-question-get-questions'.

This function will be called when the user presses \\<sx-question-list-mode-map>\\[sx-question-list-next] at the end
of the question list. It should either return nil (indicating
\"no more questions\") or return a list of questions which will
appended to the currently displayed list.

If this is not set, it's the same as a function which always
returns nil.")
(make-variable-buffer-local 'sx-question-list--next-page-function)

(defvar sx-question-list--dataset nil
  "The logical data behind the displayed list of questions.
This dataset contains even questions that are hidden by the user,
and thus not displayed in the list of questions.

This is ignored if `sx-question-list--refresh-function' is set.")
(make-variable-buffer-local 'sx-question-list--dataset)

(defconst sx-question-list--key-definitions
  '(
    ;; S-down and S-up would collide with `windmove'.
    ("<down>" sx-question-list-next)
    ("<up>" sx-question-list-previous)
    ("RET" sx-display "Display")
    ("n" sx-question-list-next "Navigate")
    ("p" sx-question-list-previous "Navigate")
    ("j" sx-question-list-view-next "Navigate")
    ("k" sx-question-list-view-previous "Navigate")
    ("N" sx-question-list-next-far)
    ("P" sx-question-list-previous-far)
    ("J" sx-question-list-next-far)
    ("K" sx-question-list-previous-far)
    ("g" sx-question-list-refresh)
    ("t" sx-tab-switch "tab")
    ("a" sx-ask "ask")
    ("S" sx-search "Search")
    ("s" sx-switchto-map "switch-to")
    ("v" sx-visit-externally "visit")
    ("u" sx-upvote)
    ("d" sx-downvote)
    ("h" sx-question-list-hide "hide")
    ("m" sx-question-list-mark-read "mark-read")
    ("*" sx-favorite)
    )
  "List of key definitions for `sx-question-list-mode'.
This list must follow the form described in
`sx--key-definitions-to-header-line'.")

(defconst sx-question-list--header-line
  (sx--key-definitions-to-header-line
   sx-question-list--key-definitions)
  "Header-line used on the question list.")

(defvar sx-question-list--order-methods
  '(("Recent Activity" . activity)
    ("Creation Date"   . creation)
    ("Most Voted"      . votes)
    ("Score"           . votes))
  "Alist of possible values to be passed to the `sort' keyword.")
(make-variable-buffer-local 'sx-question-list--order-methods)

(defun sx-question-list--interactive-order-prompt (&optional prompt)
  "Interactively prompt for a sorting order.
PROMPT is displayed to the user.  If it is omitted, a default one
is used."
  (let ((order (sx-completing-read
                (or prompt "Order questions by: ")
                (mapcar #'car sx-question-list--order-methods))))
    (cdr-safe (assoc-string order sx-question-list--order-methods))))

(defun sx-question-list--make-pager (method &optional submethod)
  "Return a function suitable for use as a question list pager.
Meant to be used as `sx-question-list--next-page-function'."
  (lambda (page)
    (sx-method-call method
      :keywords `((page . ,page)
                  ,@(when sx-question-list--order
                      `((order . ,(if sx-question-list--descending 'desc 'asc))
                        (sort . ,sx-question-list--order))))
      :site sx-question-list--site
      :auth t
      :submethod submethod
      :filter sx-browse-filter)))


;;; Mode Definition

(defvar sx-question-list--current-tab "Latest"
  ;; @TODO Other values (once we implement them) are "Top Voted",
  ;; "Unanswered", etc.
  "Variable describing current tab being viewed.")

(defconst sx-question-list--mode-line-format
  '("   "
    (:propertize
     (:eval (sx--pretty-site-parameter sx-question-list--site))
     face mode-line-buffer-id)
    " " mode-name ": "
    (:propertize sx-question-list--current-tab
                 face mode-line-buffer-id)
    " ["
    "Unread: "
    (:propertize
     (:eval (sx-question-list--unread-count))
     face mode-line-buffer-id)
    ", "
    "Total: "
    (:propertize
     (:eval (int-to-string (length tabulated-list-entries)))
     face mode-line-buffer-id)
    "] ")
  "Mode-line construct to use in question-list buffers.")

(define-derived-mode sx-question-list-mode
  tabulated-list-mode "Question List"
  "Major mode for browsing a list of questions from StackExchange.
Letters do not insert themselves; instead, they are commands.

The recommended way of using this mode is to activate it and then
set `sx-question-list--next-page-function'. The return value of
this function is mapped with `sx-question-list--print-function',
so you may need to customize the latter if the former does not
return a list of questions.

The full list of variables which can be set is:
 1. `sx-question-list--site'
      Set this to the name of the site if that makes sense. If it
      doesn't leave it as nil.
 2. `sx-question-list--print-function'
      Change this if the data you're dealing with is not strictly a
      list of questions (see the doc for details).
 3. `sx-question-list--refresh-function'
      This is used to populate the initial list. It is only necessary
      if item 4 does not fit your needs.
 4. `sx-question-list--next-page-function'
      This is used to fetch further questions. If item 3 is nil, it is
      also used to populate the initial list.
 5. `sx-question-list--dataset'
      This is only used if both 3 and 4 are nil. It can be used to
      display a static list.
 6. `sx-question-list--order'
      Set this to the `sort' method that should be used when
      requesting the list, if that makes sense. If it doesn't
      leave it as nil.
\\<sx-question-list-mode-map>
If none of these is configured, the behaviour is that of a
\"Frontpage\", for the site given by
`sx-question-list--site'.

Item 2 is mandatory, but it also has a sane default which is
usually enough.

As long as one of 3, 4, or 5 is provided, the other two are
entirely optional. Populating or refreshing the list of questions
is done in the following way:
 - Set `sx-question-list--pages-so-far' to 1.
 - Call function 2.
 - If function 2 is not given, call function 3 with argument 1.
 - If 3 is not given use the value of 4.

Adding further questions to the bottom of the list is done by:
 - Increment `sx-question-list--pages-so-far'.
 - Call function 3 with argument `sx-question-list--pages-so-far'.
 - If it returns anything, append to the dataset and refresh the
   display; otherwise, decrement `sx-question-list--pages-so-far'.

If `sx-question-list--site' is given, items 3 and 4 should take it
into consideration.  The same holds for `sx-question-list--order'.

\\{sx-question-list-mode-map}"
  (hl-line-mode 1)
  (setq mode-line-format
        sx-question-list--mode-line-format)
  (setq sx-question-list--pages-so-far 0)
  (setq tabulated-list-format
        [("  V" 3 t :right-align t)
         ("  A" 3 t :right-align t)
         ("Title" 0 sx-question-list--date-more-recent-p)])
  (setq tabulated-list-padding 1)
  ;; Sorting by title actually sorts by date. It's what we want, but
  ;; it's not terribly intuitive.
  (setq tabulated-list-sort-key nil)
  (add-hook 'tabulated-list-revert-hook
            #'sx-question-list-refresh nil t)
  ;; This is the default value, but we'll error out if the user has
  ;; set it to nil.
  (setq tabulated-list-use-header-line t)
  (tabulated-list-init-header)
  (setq header-line-format sx-question-list--header-line))

(defcustom sx-question-list-date-sort-method 'last_activity_date
  "Parameter which controls date sorting."
  ;; This should be made into a (choice ...) of constants.
  :type 'symbol
  ;; Add a setter to protect the value.
  :group 'sx-question-list)

(sx--create-comparator sx-question-list--date-more-recent-p
  "Non-nil if tabulated-entry A is newer than B."
  #'> (lambda (x)
        (cdr (assq sx-question-list-date-sort-method (car x)))))


;;; Keybinds
;; We need this quote+eval combo because `kbd' was a macro in 24.2.
(mapc (lambda (x) (eval `(define-key sx-question-list-mode-map
                      (kbd ,(car x)) #',(cadr x))))
  sx-question-list--key-definitions)

(sx--define-conditional-key sx-question-list-mode-map "O" #'sx-question-list-order-by
  (and (boundp 'sx-question-list--order) sx-question-list--order))

(defun sx-question-list-hide (data)
  "Hide question under point.
Non-interactively, DATA is a question alist."
  (interactive
   (list (if (derived-mode-p 'sx-question-list-mode)
             (tabulated-list-get-id)
           (sx-user-error "Not in `sx-question-list-mode'"))))
  (sx-question--mark-hidden data)
  ;; The current entry will not be present after the list is
  ;; redisplayed. To avoid `tabulated-list-mode' getting lost (and
  ;; sending us to the top) we move to the next entry before
  ;; redisplaying.
  (forward-line 1)
  (when (called-interactively-p 'any)
    (sx-question-list-refresh 'redisplay 'noupdate)))

(defun sx-question-list-mark-read (data)
  "Mark as read question under point.
Non-interactively, DATA is a question alist."
  (interactive
   (list (if (derived-mode-p 'sx-question-list-mode)
             (tabulated-list-get-id)
           (sx-user-error "Not in `sx-question-list-mode'"))))
  (sx-question--mark-read data)
  (sx-question-list-next 1)
  (when (called-interactively-p 'any)
    (sx-question-list-refresh 'redisplay 'noupdate)))

(defun sx-question-list--unread-count ()
  "Number of unread questions in current dataset, as a string."
  (int-to-string
   (cl-count-if-not
    #'sx-question--read-p sx-question-list--dataset)))

(defun sx-question-list--remove-excluded-tags (question-list)
  "Return QUESTION-LIST, with some questions removed.
Removes all questions hidden by the user, as well as those
containing a tag in `sx-question-list-excluded-tags'."
  (cl-remove-if (lambda (q)
                  (or (sx-question--hidden-p q)
                      (cl-intersection (let-alist q .tags)
                                       sx-question-list-excluded-tags
                                       :test #'string=)))
                question-list))

(defun sx-question-list-refresh (&optional redisplay no-update)
  "Update the list of questions.
If REDISPLAY is non-nil (or if interactive), also call `tabulated-list-print'.
If the prefix argument NO-UPDATE is nil, query StackExchange for
a new list before redisplaying."
  (interactive "p\nP")
  ;; Reset the mode-line unread count (we rebuild it here).
  (unless no-update
    (setq sx-question-list--pages-so-far 1))
  (let* ((question-list
          (or (and no-update sx-question-list--dataset)
              (and (functionp sx-question-list--refresh-function)
                   (funcall sx-question-list--refresh-function))
              (and (functionp sx-question-list--next-page-function)
                   (funcall sx-question-list--next-page-function 1))
              sx-question-list--dataset))
         ;; Preserve window positioning.
         (window (get-buffer-window (current-buffer)))
         (old-start (when window (window-start window))))
    ;; The dataset contains everything. Hiding and filtering is done
    ;; on the `tabulated-list-entries' below.
    (setq sx-question-list--dataset question-list)
    ;; Print the result.
    (setq tabulated-list-entries
          (mapcar sx-question-list--print-function
                  (sx-question-list--remove-excluded-tags
                   sx-question-list--dataset)))
    (when redisplay
      (tabulated-list-print 'remember))
    (when window
      (set-window-start window old-start)))
  (sx-message "Done."))

(defun sx-question-list-view-previous (n)
  "Move cursor up N questions up and display this question.
Displayed in `sx-question-mode--window', replacing any question
that may currently be there."
  (interactive "p")
  (sx-question-list-view-next (- n)))

(defun sx-question-list-view-next (n)
  "Move cursor down N questions and display this question.
Displayed in `sx-question-mode--window', replacing any question
that may currently be there."
  (interactive "p")
  (sx-question-list-next n)
  (sx-question-mode--display
   (tabulated-list-get-id) 
   (sx-question-list--create-question-window)))

(defun sx-question-list--create-question-window ()
  "Create or find a window where a question can be displayed.

If any current window displays a question, that window is
returned. If none do, a new one is created such that the
question-list window remains `sx-question-list-height' lines
high (if possible)."
  (or (sx-question-mode--get-window)
      ;; Create a proper window.
      (let ((window
             (condition-case er
                 (split-window (selected-window) sx-question-list-height 'below)
               (error
                ;; If the window is too small to split, use any one.
                (if (string-match
                     "Window #<window .*> too small for splitting"
                     (car (cdr-safe er)))
                    (next-window)
                  (error (cdr er)))))))
        ;; Configure the window to be closed on `q'.
        (set-window-prev-buffers window nil)
        (set-window-parameter
         window 'quit-restore
         ;; See (info "(elisp) Window Parameters")
         `(window window ,(selected-window) ,sx-question-mode--buffer))
        window)))

(defvar sx-question-list--last-refresh (current-time)
  "Time of the latest refresh.")

(defun sx-question-list-next (n)
  "Move cursor down N questions.
This does not update `sx-question-mode--window'."
  (interactive "p")
  (if (and (< n 0) (bobp))
      (when (> (time-to-seconds
                (time-subtract (current-time) sx-question-list--last-refresh))
               1)
        (sx-question-list-refresh 'redisplay)
        (setq sx-question-list--last-refresh (current-time)))
    (forward-line n)
    ;; If we were trying to move forward, but we hit the end.
    (when (eobp)
      ;; Try to get more questions.
      (sx-question-list-next-page))
    (sx-question-list--ensure-line-good-line-position)))

(defun sx-question-list--ensure-line-good-line-position ()
  "Scroll window such that current line is a good place.
Check if we're at least 6 lines from the bottom. Scroll up if
we're not. Do the same for 3 lines from the top."
  ;; At least one entry below us.
  (let ((lines-to-bottom (count-screen-lines (point) (window-end))))
    (unless (>= lines-to-bottom 6)
      (recenter (- 6))))
  ;; At least one entry above us.
  (let ((lines-to-top (count-screen-lines (point) (window-start))))
    (unless (>= lines-to-top 3)
      (recenter 3))))

(defun sx-question-list-next-page ()
  "Fetch and display the next page of questions."
  (interactive)
  ;; Stay at the last line.
  (goto-char (point-max))
  (forward-line -1)
  (when (functionp sx-question-list--next-page-function)
    ;; Try to get more questions
    (let ((list
           (funcall sx-question-list--next-page-function
             (1+ sx-question-list--pages-so-far))))
      (if (null list)
          (message "No further questions.")
        ;; If it worked, increment the variable.
        (cl-incf sx-question-list--pages-so-far)
        ;; And update the dataset.
        ;; @TODO: Check for duplicates.
        (setq sx-question-list--dataset
              (append sx-question-list--dataset list))
        (sx-question-list-refresh 'redisplay 'no-update)
        (forward-line 1)))))

(defun sx-question-list-previous (n)
  "Move cursor up N questions.
This does not update `sx-question-mode--window'."
  (interactive "p")
  (sx-question-list-next (- n)))

(defcustom sx-question-list-far-step-size 5
  "How many questions `sx-question-list-next-far' skips."
  :type 'integer
  :group 'sx-question-list
  :package-version '(sx-question-list . ""))

(defun sx-question-list-next-far (n)
  "Move cursor up N*`sx-question-list-far-step-size' questions.
This does not update `sx-question-mode--window'."
  (interactive "p")
  (sx-question-list-next (* n sx-question-list-far-step-size)))

(defun sx-question-list-previous-far (n)
  "Move cursor up N questions.
This does not update `sx-question-mode--window'."
  (interactive "p")
  (sx-question-list-next-far (- n)))

(defun sx-question-list-switch-site (site)
  "Switch the current site to SITE and display its questions.
Retrieve completions from `sx-site-get-api-tokens'.
Sets `sx-question-list--site' and then call
`sx-question-list-refresh' with `redisplay'."
  (interactive
   (list (sx-completing-read
           "Switch to site: " (sx-site-get-api-tokens)
           (lambda (site) (not (equal site sx-question-list--site)))
           t)))
  (when (and (stringp site) (> (length site) 0))
    (setq sx-question-list--site site)
    (sx-question-list-refresh 'redisplay)))

(defun sx-question-list-order-by (sort &optional ascend)
  "Order questions in the current list by the method SORT.
Sets `sx-question-list--order' and then calls
`sx-question-list-refresh' with `redisplay'.

With a prefix argument or a non-nil ASCEND, invert the sorting
order."
  (interactive
   (list (when sx-question-list--order
           (sx-question-list--interactive-order-prompt))
         current-prefix-arg))
  (unless sx-question-list--order
    (sx-user-error "This list can't be reordered"))
  (when (and sort (symbolp sort))
    (setq sx-question-list--order sort)
    (setq sx-question-list--descending (not ascend))
    (sx-question-list-refresh 'redisplay)))

(provide 'sx-question-list)
;;; sx-question-list.el ends here

;; Local Variables:
;; indent-tabs-mode: nil
;; End:
