;;; ein-contents-api.el --- Interface to Jupyter's Contents API  -*- lexical-binding:t -*-

;; Copyright (C) 2015 - John Miller

;; Authors: Takafumi Arakaki <aka.tkf at gmail.com>
;;          John M. Miller <millejoh at mac.com>

;; This file is NOT part of GNU Emacs.

;; ein-contents-api.el 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.

;; ein-contents-api.el 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 ein-notebooklist.el.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:
;;;
;;; An interface to the Jupyter Contents API as described in
;;; https://github.com/ipython/ipython/wiki/IPEP-27%3A-Contents-Service.
;;;

;;

;;; Code:

(require 'ein-core)
(require 'ein-classes)
(require 'ein-utils)
(require 'ein-log)
(require 'ein-query)

(declare-function ein:notebook-to-json "ein-notebook")
(declare-function ein:notebooklist-url "ein-notebooklist")

(defcustom ein:content-query-max-depth 2
  "Don't recurse the directory tree deeper than this."
  :type 'integer
  :group 'ein)

(defcustom ein:content-query-max-branch 6
  "Don't descend into more than this number of directories per depth.
The total number of parallel queries should therefore be
O({max_branch}^{max_depth})."
  :type 'integer
  :group 'ein)

(make-obsolete-variable 'ein:content-query-timeout nil "0.17.0")

(defcustom ein:force-sync nil
  "When non-nil, force synchronous http requests."
  :type 'boolean
  :group 'ein)

(defun ein:content-query-contents (url-or-port path &optional callback errback iteration)
  "Register CALLBACK of arity 1 for the contents at PATH from the URL-OR-PORT.
ERRBACK of arity 1 for the contents."
  (setq callback (or callback #'ignore))
  (setq errback (or errback #'ignore))
  (setq iteration (or iteration 0))
  (ein:query-singleton-ajax
   (ein:notebooklist-url url-or-port path)
   :type "GET"
   :parser #'ein:json-read
   :complete (apply-partially #'ein:content-query-contents--complete url-or-port path)
   :success (apply-partially #'ein:content-query-contents--success url-or-port path callback)
   :error (apply-partially #'ein:content-query-contents--error url-or-port path callback errback iteration)))

(cl-defun ein:content-query-contents--complete
    (_url-or-port _path
     &key data _symbol-status response &allow-other-keys
     &aux (resp-string (format "STATUS: %s DATA: %s" (request-response-status-code response) data)))
  (ein:log 'debug "ein:query-contents--complete %s" resp-string))

(cl-defun ein:content-query-contents--error
    (url-or-port path callback errback iteration
     &key symbol-status response error-thrown data &allow-other-keys
     &aux
     (response-status (request-response-status-code response))
     (hub-p (request-response-header response "x-jupyterhub-version")))
  (cl-case response-status
    (404 (ein:log 'error "ein:content-query-contents--error %s %s"
                  response-status (plist-get data :message))
         (when errback (funcall errback url-or-port response-status)))
    (t (if (< iteration 3)
           (if (and hub-p data (eq response-status 405))
               (ein:content-query-contents--success url-or-port path callback :data data)
             (ein:log 'verbose "Retry content-query-contents #%s in response to %s"
                      iteration response-status)
             (sleep-for 0 (* (1+ iteration) 500))
             (ein:content-query-contents url-or-port path callback errback (1+ iteration)))
         (ein:log 'error "ein:content-query-contents--error %s REQUEST-STATUS %s DATA %s"
                  (concat (file-name-as-directory url-or-port) path)
                  symbol-status (cdr error-thrown))
         (when errback (funcall errback url-or-port response-status))))))

(cl-defun ein:content-query-contents--success
    (url-or-port path callback
     &key data _symbol-status _response &allow-other-keys)
  (when callback
    (funcall callback (ein:new-content url-or-port path data))))

(defun ein:content-to-json (content)
  (let ((path (if (>= (ein:$content-notebook-api-version content) 3)
                  (ein:$content-path content)
                (substring (ein:$content-path content)
                           0
                           (or (cl-position ?/ (ein:$content-path content) :from-end t)
                               0)))))
    (ignore-errors
      (ein:json-encode `((type . ,(ein:$content-type content))
                         (name . ,(ein:$content-name content))
                         (path . ,path)
                         (format . ,(or (ein:$content-format content) "json"))
                         (content ,@(ein:$content-raw-content content)))))))

(defun ein:content-from-notebook (nb)
  (let ((nb-content (ein:notebook-to-json nb)))
    (make-ein:$content :name (ein:$notebook-notebook-name nb)
                       :path (ein:$notebook-notebook-path nb)
                       :url-or-port (ein:$notebook-url-or-port nb)
                       :type "notebook"
                       :notebook-api-version (ein:$notebook-api-version nb)
                       :raw-content (append nb-content nil))))

;;; Managing/listing the content hierarchy

(defvar *ein:content-hierarchy* (make-hash-table :test #'equal)
  "Content tree keyed by URL-OR-PORT.")

(defun ein:content-need-hierarchy (url-or-port)
  "Callers assume ein:content-query-hierarchy succeeded.  If not, nil."
  (aif (gethash url-or-port *ein:content-hierarchy*) it
    (ein:log 'warn "No recorded content hierarchy for %s" url-or-port)
    nil))

(defun ein:new-content (url-or-port path data)
  ;; data is like (:size 72 :content nil :writable t :path Untitled7.ipynb :name Untitled7.ipynb :type notebook)
  (let ((content (make-ein:$content
                  :url-or-port url-or-port
                  :notebook-api-version (ein:notebook-api-version-numeric url-or-port)
                  :path path))
        (raw-content (if (vectorp (plist-get data :content))
                         (append (plist-get data :content) nil)
                       (plist-get data :content))))
    (setf (ein:$content-name content) (plist-get data :name)
          (ein:$content-path content) (plist-get data :path)
          (ein:$content-type content) (plist-get data :type)
          (ein:$content-created content) (plist-get data :created)
          (ein:$content-last-modified content) (plist-get data :last_modified)
          (ein:$content-format content) (plist-get data :format)
          (ein:$content-writable content) (plist-get data :writable)
          (ein:$content-mimetype content) (plist-get data :mimetype)
          (ein:$content-raw-content content) raw-content)
    content))

(defun ein:content-query-hierarchy* (url-or-port path callback sessions depth content)
  "Returns list (tree) of content objects.  CALLBACK accepts tree."
  (let* ((url-or-port url-or-port)
         (path path)
         (callback callback)
         (items (ein:$content-raw-content content))
         (directories (if (< depth ein:content-query-max-depth)
                          (cl-loop for item in items
                                   until (>= (length result) ein:content-query-max-branch)
                                   if (string= "directory" (plist-get item :type))
                                   collect (ein:new-content url-or-port path item)
                                   into result
                                   end
                                   finally return result)))
         (others (cl-loop for item in items
                          with c0
                          if (not (string= "directory" (plist-get item :type)))
                          do (setf c0 (ein:new-content url-or-port path item))
                          (setf (ein:$content-session-p c0)
                                (gethash (ein:$content-path c0) sessions))
                          and collect c0
                          end)))
    (deferred:$
      (apply
       #'deferred:parallel
       (cl-loop for c0 in directories
             collect
             (let ((c0 c0)
                   (d0 (deferred:new #'identity)))
               (ein:content-query-contents
                url-or-port
                (ein:$content-path c0)
                (apply-partially #'ein:content-query-hierarchy*
                                 url-or-port
                                 (ein:$content-path c0)
                                 (lambda (tree)
                                   (deferred:callback-post d0 (cons c0 tree)))
                                 sessions (1+ depth))
                (lambda (&rest _args) (deferred:callback-post d0 (cons c0 nil))))
               d0)))
      (deferred:nextc it
        (lambda (tree)
          (let ((result (append others tree)))
            (when (string= path "")
              (setf (gethash url-or-port *ein:content-hierarchy*) (-flatten result)))
            (funcall callback result)))))))

(defun ein:content-query-hierarchy (url-or-port &optional callback)
  "Get hierarchy of URL-OR-PORT with CALLBACK arity 1 for which hierarchy."
  (setq callback (or callback #'ignore))
  (ein:content-query-sessions
   url-or-port
   (apply-partially (lambda (url-or-port* callback* sessions)
                      (ein:content-query-contents url-or-port* ""
                       (apply-partially #'ein:content-query-hierarchy*
                                        url-or-port*
                                        ""
                                        callback* sessions 0)
                       (lambda (&rest _ignore)
                         (when callback* (funcall callback* nil)))))
                    url-or-port callback)
   callback))

;;; Save Content

(defsubst ein:content-url (content)
  (ein:notebooklist-url (ein:$content-url-or-port content)
                        (ein:$content-path content)))

(defun ein:content-save (content &optional callback cbargs errcb errcbargs)
  (ein:query-singleton-ajax
   (ein:content-url content)
   :type "PUT"
   :headers '(("Content-Type" . "application/json"))
   :data (encode-coding-string (ein:content-to-json content) buffer-file-coding-system)
   :success (apply-partially #'ein:content-save-success callback cbargs)
   :error (apply-partially #'ein:content-save-error
                           (ein:content-url content) errcb errcbargs)))

(cl-defun ein:content-save-success (callback cbargs &key _status _response &allow-other-keys)
  (when callback
    (apply callback cbargs)))

(cl-defun ein:content-save-error (url errcb errcbargs &key response &allow-other-keys)
  (ein:log 'error
    "ein:content-save-error: %s %s."
    url (error-message-string (request-response-error-thrown response)))
  (when errcb
    (apply errcb errcbargs)))

(defun ein:content-rename (content new-path &optional callback cbargs)
  (ein:query-singleton-ajax
   (ein:content-url content)
   :type "PATCH"
   :data (ein:json-encode `((path . ,new-path)))
   :parser #'ein:json-read
   :success (apply-partially #'update-content-path content callback cbargs)
   :error (apply-partially #'ein:content-rename-error (ein:$content-path content))))

(defun ein:session-rename (url-or-port session-id new-path)
  (ein:query-singleton-ajax
   (ein:url url-or-port "api/sessions" session-id)
   :type "PATCH"
   :data (ein:json-encode `((path . ,new-path)))
   :complete #'ein:session-rename--complete))

(cl-defun ein:session-rename--complete (&key data response _symbol-status &allow-other-keys
                                        &aux (resp-string (format "STATUS: %s DATA: %s" (request-response-status-code response) data)))
  (ein:log 'debug "ein:session-rename--complete %s" resp-string))

(cl-defun update-content-path (content callback cbargs &key data &allow-other-keys)
  (setf (ein:$content-path content) (plist-get data :path)
        (ein:$content-name content) (plist-get data :name)
        (ein:$content-last-modified content) (plist-get data :last_modified))
  (when callback
    (apply callback cbargs)))

(cl-defun ein:content-rename-error (path &key response data &allow-other-keys)
  (ein:log 'error
    "Renaming content %s failed %s %s."
    path (request-response-error-thrown response) (plist-get data :message)))

;;; Sessions

(defun ein:content-query-sessions (url-or-port &optional callback errback iteration)
  "Register CALLBACK of arity 1 to retrieve the sessions.
Call ERRBACK of arity 1 (contents) upon failure."
  (setq callback (or callback #'ignore))
  (setq errback (or errback #'ignore))
  (setq iteration (or iteration 0))
  (ein:query-singleton-ajax
   (ein:url url-or-port "api/sessions")
   :type "GET"
   :parser #'ein:json-read
   :complete (apply-partially #'ein:content-query-sessions--complete url-or-port callback)
   :success (apply-partially #'ein:content-query-sessions--success url-or-port callback)
   :error (apply-partially #'ein:content-query-sessions--error url-or-port callback errback iteration)))

(cl-defun ein:content-query-sessions--success (url-or-port callback &key data &allow-other-keys)
  (cl-flet ((read-name (nb-json)
                       (if (< (ein:notebook-api-version-numeric url-or-port) 3)
                           (if (string= (plist-get nb-json :path) "")
                               (plist-get nb-json :name)
                             (format "%s/%s" (plist-get nb-json :path) (plist-get nb-json :name)))
                         (plist-get nb-json :path))))
    (let ((session-hash (make-hash-table :test 'equal)))
      (dolist (s (append data nil) (funcall callback session-hash))
        (setf (gethash (read-name (plist-get s :notebook)) session-hash)
              (cons (plist-get s :id) (plist-get s :kernel)))))))

(cl-defun ein:content-query-sessions--error
    (url-or-port callback errback iteration
     &key data response error-thrown &allow-other-keys
     &aux
     (response-status (request-response-status-code response))
     (hub-p (request-response-header response "x-jupyterhub-version")))
  (if (< iteration 3)
      (if (and hub-p data (eq response-status 405))
          (ein:content-query-sessions--success url-or-port callback :data data)
        (ein:log 'verbose "Retry sessions #%s in response to %s %S" iteration response-status response)
        (sleep-for 0 (* (1+ iteration) 500))
        (ein:content-query-sessions url-or-port callback errback (1+ iteration)))
    (ein:log 'error "ein:content-query-sessions--error %s: ERROR %s DATA %s" url-or-port (car error-thrown) (cdr error-thrown))
    (when errback (funcall errback nil))))

(cl-defun ein:content-query-sessions--complete
    (_url-or-port _callback
     &key data response &allow-other-keys
     &aux (resp-string (format "STATUS: %s DATA: %s" (request-response-status-code response) data)))
  (ein:log 'debug "ein:query-sessions--complete %s" resp-string))

;;; Uploads


(defun ein:get-local-file (path)
  "Get contents of PATH.
Guess type of file (one of file, notebook, or directory)
and content format (one of json, text, or base64)."
  (unless (file-readable-p path)
    (error "File %s is not accessible and cannot be uploaded." path))
  (let ((name (file-name-nondirectory path))
        (type (file-name-extension path)))
    (with-temp-buffer
      (insert-file-contents path)
      (cond ((string= type "ipynb")
             (list name "notebook" "json" (buffer-string)))
            ((eql buffer-file-coding-system 'no-conversion)
             (list name "file" "base64" (buffer-string)))
            (t (list name "file" "text" (buffer-string)))))))

(provide 'ein-contents-api)