;;;; SPDX-FileCopyrightText: Atlas Engineer LLC
;;;; SPDX-License-Identifier: BSD-3-Clause

(nyxt:define-package :nyxt/mode/file-manager
  (:documentation "Package for `file-manager-mode', mode for file management
from the prompt buffer."))
(in-package :nyxt/mode/file-manager)

(nyxt/mode/prompt-buffer::define-command-prompt directory-up (prompt-buffer)
  "Move one level up from the current PROMPT-BUFFER input."
  (let* ((input (prompter:input prompt-buffer))
         (path (uiop:parse-native-namestring input))
         (parent (if (uiop:directory-pathname-p path)
                     (uiop:pathname-parent-directory-pathname path)
                     (uiop:pathname-directory-pathname path))))
    (nyxt:set-prompt-buffer-input (namestring parent) prompt-buffer)))

(define-mode file-manager-mode (nyxt/mode/prompt-buffer:prompt-buffer-mode)
  "Prompt buffer mode to manage file systems.

Provides a handful of prompt buffer return actions such as deleting, renaming or
opening files with external programs."
  ((visible-in-status-p nil)
   (keyscheme-map
    (define-keyscheme-map "file-manager-mode" ()
      keyscheme:default
      (list
       "C-backspace" 'directory-up)
      keyscheme:emacs
      (list
       "C-l" 'directory-up)))))

(export-always 'directory-elements)
(-> directory-elements (types:pathname-designator) (list-of pathname))
(defun directory-elements (directory)
  "Return list of all the files and subdirectories inside DIRECTORY."
  (let ((directory (pathname directory)))
    (append (uiop:subdirectories directory)
            (uiop:directory-files directory))))

(export-always 'recursive-directory-elements)
(-> recursive-directory-elements (types:pathname-designator &key
                                  (:include-directories-p boolean))
    (list-of pathname))
(defun recursive-directory-elements (directory &key include-directories-p)
  "Get contents of DIRECTORY and all of its recursive subdirectories.
When INCLUDE-DIRECTORIES-P, include both directories and files,
otherwise (default) only include files."
  (loop with included-directories = '()
        with files = (directory-elements directory)
        for directories = (sera:filter #'uiop:directory-pathname-p files)
          then (sera:filter #'uiop:directory-pathname-p files)
        while directories
        do (dolist (dir directories)
             (when include-directories-p
               (push dir included-directories))
             (setf files (delete dir (append files (directory-elements dir))
                                 :test #'uiop:pathname-equal)))
        finally (return (if include-directories-p
                            (append files included-directories)
                            files))))

(defun current-user ()
  #+sbcl (sb-posix:passwd-name (sb-posix:getpwuid (sb-posix:getuid))))

(defun group-id (user)
  "Return the group ID of USER name."
  #+sbcl (sb-posix:passwd-gid (sb-posix:getpwnam user)))

(defun file-group-id (file)
  #+sbcl (sb-posix:stat-gid (sb-posix:lstat file)))

(export-always 'mtime)
(defun mtime (path)
  #+sbcl (sb-posix:stat-mtime (sb-posix:stat path)))

(-> executable-p ((or types:pathname-designator) &key (:user string)) boolean)
(defun executable-p (file &key (user (current-user)))
  "Return non-nil if FILE is executable for USER name.
When the user is unspecified, take the current one."
  (sera:true
   (let* ((filename (uiop:native-namestring file))
          (permissions (iolib/os:file-permissions filename)))
     (or (and (string= (file-author file) user)
              (member :user-exec permissions))
         (and (= (file-group-id filename)
                 (group-id user))
              (member :group-exec permissions))
         (member :other-exec permissions)))))

(export-always 'executables)
(defun executables ()
  "List of pathnames of user-executable programs under PATH enviroment variable."
  (let ((paths (str:split ":" (uiop:getenv "PATH") :omit-nulls t)))
    (sera:filter
     #'executable-p
     (remove-if
      #'uiop:hidden-pathname-p
      (mapcar #'uiop:resolve-symlinks
              (mappend #'uiop:directory-files paths))))))

(define-class program-source (prompter:source)
  ((prompter:name "Programs")
   (prompter:constructor (executables))
   (prompter:enable-marks-p t)
   (prompter:filter-preprocessor #'prompter:filter-exact-matches))
  (:export-class-name-p t)
  (:export-accessor-names-p t)
  (:documentation "Prompt source for user-accessible programs.")
  (:metaclass user-class))

(defmethod prompter:object-attributes ((path pathname) (source prompter:source))
  (declare (ignore source))
  `(("Path" ,(uiop:native-namestring path) (:width 2))
    ("Name" ,(if (uiop:directory-pathname-p path)
                 (enough-namestring path (files:parent path))
                 (pathname-name path))
            (:width 1))
    ("Extension" ,(or (files:pathname-type* path) "") (:width 1))
    ("Directory" ,(uiop:native-namestring (files:parent path)) (:width 3))))

(defun match-extension (ext)
  (lambda (pathname)
    (string-equal (pathname-type pathname) ext)))

(defun make-file-source-preprocessor ()
  "Return a preprocessor that lists all files satisfying `extensions' and
`allow-directories'.
It's suitable for `prompter:filter-preprocessor'."
  (lambda (suggestions source input)
    (declare (ignore suggestions))
    (let* ((pathname (uiop:ensure-pathname (if (uiop:emptyp input)
                                               *default-pathname-defaults*
                                               input)))
           (directory (uiop:pathname-directory-pathname pathname)))
      (prompter:filter-exact-matches
       (prompter:ensure-suggestions-list
        source
        (sera:filter
         (or (path-filter source)
             (lambda (path)
               (or (and (uiop:directory-pathname-p path)
                        (allow-directories source))
                   (and (uiop:file-pathname-p path)
                        (or (null (extensions source))
                            (str:s-member (extensions source)
                                          (pathname-type path)))))))
         (directory-elements directory)))
       source
       input))))

(define-class file-source (prompter:source)
  ((prompter:name "Files")
   (prompter:active-attributes-keys
    '("Name" "Extension" "Directory")
    :accessor nil)
   (prompter:filter-preprocessor (make-file-source-preprocessor))
   (prompter:enable-marks-p t)
   (open-file-in-new-buffer-p
    t
    :documentation "Whether to open files and directories in a new buffer.")
   (extensions
    nil
    :type (list-of string)
    :documentation "List of extensions that are displayed.
When nil, all extensions are allow-listed.")
   (allow-directories
    t
    :type boolean
    :documentation "Whether directories are displayed.")
   (path-filter
    nil
    :type (or null (function (pathname) boolean))
    :documentation "Function defining a predicate to filter files.
It takes a pathname and returns a boolean.  For simpler cases, use
`allow-directories'.")
   (supported-media-types
    ;; https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
    ;; and https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
    '("xhtml" "html"
      ;; Images.
      "gif" "avif" "jpg" "jpeg" "jfif" "pjpeg" "pjp" "png" "apng" "svg" "webp"
      ;; Generic container formats.
      "3gp" "aac" "mpg" "mpeg" "ogg" "mp4" "m4p"
      ;; Audio.
      "mp3" "oga" "m4a"
      ;; Video.
      "flac" "ogv" "m4v" "flv" "mov" "wmv" "webm" "mkv"
      ;; Documents
      "pdf" "txt" "org")
    :type (list-of string)
    :documentation "Media types that Nyxt opens.
Other formats are opened relying on the OS.")
   (open-file-function
    #'default-open-file-function
    ;; TODO: Allow `data-path's?
    :type (function ((or string pathname) &key
                     (:supported-p boolean)
                     (:new-buffer-p boolean)))
    :documentation "Function used to open files.
Take the file name as the first argument and accept two keyword arguments:

- :supported-p as to whether the file extension is supported by Nyxt (i.e. its
  extension is one of `supported-media-types');
- :new-buffer-p as to whether the file should be opened in a new buffer."))
  (:export-class-name-p t)
  (:export-accessor-names-p t)
  (:documentation "Prompt source for file(s) on the disk.")
  (:metaclass user-class))

(define-class open-file-source (file-source) ()
  (:metaclass user-class))

(defun supported-media-or-directory
    (filename &optional (file-source (make-instance 'file-source)))
  "Return T if this filename's extension is a media that Nyxt can open
(or a directory).
See `supported-media-types' of `file-mode'."
  (or (and (uiop:directory-pathname-p filename)
           (uiop:directory-exists-p filename))
      (and-let* ((extension (pathname-type filename))
                 (extensions (supported-media-types file-source)))
        (find extension extensions :test #'string-equal))))

(define-command-global edit-file-with-external-editor
    (&optional
     (files (prompt :prompt "File(s) to edit"
                    :input (uiop:native-namestring (uiop:getcwd))
                    :extra-modes 'nyxt/mode/file-manager:file-manager-mode
                    :sources 'file-source)))
  "Edit the FILES using `external-editor-program'.
If FILES are not provided, prompt for them."
  (echo "Issued \"~{~a~^ ~}\" to edit ~s."
        (external-editor-program *browser*) files)
  (with-protect
      ("Failed editing: ~a. See `external-editor-program' slot." :condition)
    (uiop:launch-program `(,@(external-editor-program *browser*)
                           ,@(mapcar #'uiop:native-namestring files)))))

(defmethod initialize-instance :after ((source open-file-source) &key)
  (setf (slot-value source 'prompter:actions-on-return)
        (append
         (list
          (lambda-command open-file* (files)
            "Open files with `open-file-function'."
            ;; Open first file according to `open-file-in-new-buffer-p'
            (let ((file (first files)))
              (funcall (open-file-function source)
                       file
                       :new-buffer-p (open-file-in-new-buffer-p source)
                       :supported-p (supported-media-or-directory file source)))
            ;; Open the rest of the files in new buffers unconditionally.
            (dolist (file (rest files))
              (funcall (open-file-function source)
                       file
                       :new-buffer-p t
                       :supported-p (supported-media-or-directory file source))))
          (lambda-command delete-file* (files)
            "Delete files."
            (mapcar #'delete-file files))
          (lambda-command rename-file* (files)
            "Rename the first chosen file."
            (let* ((file (first files))
                   (name (files:basename file)))
              (rename-file file
                           (prompt1 :prompt (format nil "New name for ~a" name)
                                    :sources 'prompter:raw-source
                                    :input name))))
          (lambda-command edit-file-with-external-editor* (files)
            "Edit files in external editor."
            (edit-file-with-external-editor files))
          ;; TODO: File/directory copying.
          (lambda-command open-with* (files)
            "Open files with the selected program."
            (let ((program (prompt1 :prompt "Choose program"
                                    :sources 'program-source)))
              (uiop:launch-program
               (cons (uiop:native-namestring program)
                     (mapcar #'uiop:native-namestring files)))))))))

(export-always 'default-open-file-function)
(defun default-open-file-function (filename &key supported-p new-buffer-p)
  "Open FILENAME in Nyxt if supported, or externally otherwise.
FILENAME is the full path of the file (or directory).

See `supported-media-types' to customize the file types that are opened in
Nyxt and those that are opened externally.

NEW-BUFFER-P defines whether the file/directory is opened in a new buffer.
SUPPORTED-P says whether the file can be opened by Nyxt.

Can be used as a `open-file-function'."
  (handler-case
      (cond
        (supported-p
         (let ((file-url (quri:make-uri-file :path filename)))
           (if new-buffer-p
               (make-buffer-focus :url file-url)
               (ffi-buffer-load (current-buffer) file-url))))
        (*open-program*
         (let ((process (uiop:launch-program
                         (list *open-program*
                               (uiop:native-namestring filename))
                         :error-output :stream)))
           (nyxt:echo "Opening ~s with ~s." filename *open-program*)
           (run-thread "file opener"
             (let ((status (uiop:wait-process process)))
               (unless (= 0 status)
                 (echo-warning "When opening file ~s with ~s : ~a"
                               filename
                               *open-program*
                               (alex:read-stream-content-into-string
                                (uiop:process-info-error-output process))))))))
        (t (nyxt:echo "Cannot open ~s with an external program." filename)))
    ;; We can probably signal something and display a notification.
    (error (c) (log:error "Opening ~a: ~a~&" filename c))))

(define-command-global open-file
    (&key (default-directory
           (if (quri:uri-file-p (url (current-buffer)))
               (uiop:pathname-directory-pathname
                (quri:url-decode
                 (quri:uri-path (url (current-buffer)))))
               *default-pathname-defaults*)))
  "Open a file from the filesystem.

The user is prompted with the prompt buffer, files are browsable with
fuzzy suggestion.

DEFAULT-DIRECTORY specifies which directory to start from. Defaults to user home
directory.

By default, it uses the `xdg-open' command. The user can override the
`open-file-function' of `file-source' which takes the filename (or
directory name) as parameter.

`file-source' also has `supported-media-types'. You can append new types to
it. Every type in `supported-media-types' will be opened directly in Nyxt."
  (prompt :prompt "Open file"
          :extra-modes 'file-manager-mode
          :input (uiop:native-namestring default-directory)
          :sources 'open-file-source))

(define-command-global download-open-file ()
  "Open file in Nyxt or externally."
  (open-file :default-directory
             (files:expand (download-directory (current-buffer)))))
