;; -*- lexical-binding: t -*-
;;; ob-ein.el --- org-babel functions for template evaluation

;; Copyright (C) John M. Miller

;; Author: John M. Miller <millejoh at mac.com>
;;

;;; License:


;; This file is NOT part of GNU Emacs.

;; ob-ein.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.

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

;;; Commentary:

;; Support executing org-babel source blocks using EIN worksheets.

;;; Credits:

;; Uses code from https://github.com/gregsexton/ob-ipython (MIT License)

;;; Code:
(require 'ob)
(require 'ein-utils)
(require 'ein-cell)
(require 'anaphora)

(autoload 'org-element-property "org-element")
(autoload 'org-element-context "org-element")
(autoload 'org-element-type "org-element")
(autoload 'org-id-new "org-id")
(autoload 'org-redisplay-inline-images "org" nil t)
(autoload 'ein:notebooklist-new-notebook-with-name "ein-notebooklist")
(autoload 'ein:notebooklist-canonical-url-or-port "ein-notebooklist")
(autoload 'ein:notebooklist-login "ein-notebooklist" nil t)
(autoload 'ein:notebook-get-opened-notebook "ein-notebook")
(autoload 'ein:notebook-url "ein-notebook")
(autoload 'ein:notebook-open "ein-notebook")
(autoload 'ein:notebook-close "ein-notebook")
(autoload 'ein:process-url-or-port "ein-process")
(autoload 'ein:process-url-match "ein-process")
(autoload 'ein:process-refresh-processes "ein-process")
(autoload 'ein:jupyter-my-url-or-port "ein-jupyter")
(autoload 'ein:jupyter-server-start "ein-jupyter" nil t)
(autoload 'ein:shared-output-get-cell "ein-shared-output")
(autoload 'ein:shared-output-eval-string "ein-shared-output")
(autoload 'ein:kernel-live-p "ein-kernel")
(autoload 'ein:query-singleton-ajax "ein-query")
(autoload 'ein:output-area-case-type "ein-output-area")
(autoload 'ein:log "ein-log")

(defvar *ob-ein-sentinel* "[....]"
  "Placeholder string replaced after async cell execution")

(defcustom ob-ein-timeout-seconds 600
  "Maximum seconds to wait for block to finish (for synchronous operations)."
  :type 'integer
  :group 'ein)

(defcustom ob-ein-languages
  '(("ein" . python)
    ("ein-python" . python)
    ("ein-R" . R)
    ("ein-r" . R)
    ("ein-julia" . julia))
  "ob-ein has knowledge of these (ein-LANG . LANG-MODE) pairs."
  :type '(repeat (cons string symbol))
  :group 'ein)

(defcustom ob-ein-anonymous-path ".%s.ipynb"
  "Applies when session header doesn't specify ipynb.
Prosecute all interactions for a given language in this throwaway
notebook (substitute %s with language)."
  :type '(string)
  :group 'ein)

(defun ob-ein-anonymous-p (path)
  "Return t if PATH looks like ob-ein-anonymous-path.  Fragile"
  (string-match (replace-regexp-in-string "%s" ".+"
                  (replace-regexp-in-string "\\." "\\\\." ob-ein-anonymous-path))
                path))

(defcustom ob-ein-inline-image-directory "ein-images"
  "Store ob-ein images here."
  :group 'ein
  :type 'directory)

(defcustom ob-ein-default-header-args:ein nil
  "No documentation."
  :group 'ein
  :type '(repeat string))

(defun ob-ein--inline-image-info (value)
  (let* ((f (md5 value))
         (d ob-ein-inline-image-directory)
         (tf (concat d "/ob-ein-" f ".png")))
    (unless (file-directory-p d)
      (make-directory d 'parents))
    tf))

(defun ob-ein--write-base64-image (img-string file)
  (with-temp-file file
    (let ((buffer-read-only nil)
          (buffer-file-coding-system 'binary)
          (require-final-newline nil)
          (file-precious-flag t))
      (insert img-string)
      (base64-decode-region (point-min) (point-max)))))

(defun ob-ein--proxy-images (json explicit-file)
  (let (result
        (ein:output-area-case-types '(:image/svg+xml :image/png :image/jpeg :text/plain :application/latex :application/tex :application/javascript)))
    (ein:output-area-case-type
     json
     (cl-case type
       ((:image/svg+xml :image/png :image/jpeg)
        (let ((file (or explicit-file (ob-ein--inline-image-info value))))
          (ob-ein--write-base64-image value file)
          (setq result (format "[[file:%s]]" file))))
       (otherwise
        (setq result value))))
    result))

(defun ob-ein--process-outputs (result-type cell params)
  (let* ((session (aand (cdr (assoc :session params))
			(unless (string= "none" it)
			  (format "%s" it))))
	 (render (let ((stdout-p
			(lambda (out)
			  (and (equal "stream" (plist-get out :output_type))
			       (equal "stdout" (plist-get out :name))))))
		   (if (eq result-type 'output)
		       (lambda (out)
			 (if (funcall stdout-p out)
			     (plist-get out :text)
			   (when session ;; should aways be true under ob-ein
			     (concat (ob-ein--proxy-images
				       out (cdr (assoc :image params)))
				     "\n"))))
		     (lambda (out)
		       (and (not (funcall stdout-p out))
			    (concat (ob-ein--proxy-images
				       out (cdr (assoc :image params)))
				     "\n"))))))
	 (outputs (cl-loop for out in (ein:oref-safe cell 'outputs)
			   collect (funcall render out))))
    (when outputs
      (ansi-color-apply (ein:join-str "" outputs)))))

(defun ob-ein--get-name-create (src-block-info)
  "Get the name of a src block or add a uuid as the name."
  (if-let ((name (cl-fifth src-block-info)))
      name
    (save-excursion
      (let ((el (org-element-context))
            (id (org-id-new 'none)))
        (goto-char (org-element-property :begin el))
        (back-to-indentation)
        (split-line)
        (insert (format "#+NAME: %s" id))
        id))))

(defun ob-ein--babelize-lang (lang-name lang-mode)
  "Stand-up LANG-NAME as a babelized language with LANG-MODE syntax table.

Based on ob-ipython--configure-kernel."
  (add-to-list 'org-src-lang-modes `(,lang-name . ,lang-mode))
  (defvaralias (intern (concat "org-babel-default-header-args:" lang-name))
    'ob-ein-default-header-args:ein)
  (fset (intern (concat "org-babel-execute:" lang-name))
        `(lambda (body params)
           "Should get rid of accommodating org-babel-variable-assignments.
We don't test it, and finding a module named ob-LANG-MODE won't work generally,
e.g., ob-c++ is not ob-C.el."
           (require (quote ,(intern (format "ob-%s" lang-mode))) nil t)
           ;; hack because ob-ein loads independently of ein
           (custom-set-variables '(python-indent-guess-indent-offset-verbose nil))
           (let ((parser
                  (quote
                   ,(intern (format "org-babel-variable-assignments:%s" lang-mode)))))
             (ob-ein--execute-body
              (if (fboundp parser)
                  (org-babel-expand-body:generic
                   body params (funcall (symbol-function parser) params))
                body)
              params)))))

(defun ob-ein--execute-body (body params)
  (let* ((buffer (current-buffer))
	 (result-type (cdr (assq :result-type params)))
	 (result-params (cdr (assq :result-params params)))
         (session (or (aand (cdr (assoc :session params))
                            (unless (string= "none" it)
                              (format "%s" it)))
                      ein:url-localhost))
         (lang (nth 0 (org-babel-get-src-block-info)))
         (kernelspec (or (cdr (assoc :kernelspec params))
                         (aif (cdr (assoc lang org-src-lang-modes))
                             (cons 'language (format "%s" it))
                           (error "ob-ein--execute-body: %s not among %s"
                                  lang (mapcar #'car org-src-lang-modes)))))
         (name (ob-ein--get-name-create (org-babel-get-src-block-info)))
         (callback (lambda (notebook)
                    (ob-ein--execute-async
                     buffer
                     body
                     (ein:$notebook-kernel notebook)
                     params
		     result-type
                     result-params
                     name))))
    (save-excursion
      (cl-assert (not (stringp (org-babel-goto-named-src-block name))))
      (org-babel-insert-result *ob-ein-sentinel* result-params))
    (ob-ein--initiate-session session kernelspec callback)
    (if (ein:eval-if-bound 'org-current-export-file)
        (save-excursion
          (cl-loop with interval = 2000
                with pending = t
                repeat (/ (* ob-ein-timeout-seconds 1000) interval)
                do (progn
                     (org-babel-goto-named-result name)
                     (forward-line 1)
                     (setq pending (re-search-forward
                                    (regexp-quote *ob-ein-sentinel*)
                                    (org-babel-result-end) t)))
                until (not pending)
                do (sleep-for 0 interval)
                finally return
                (if pending
                    (prog1 ""
                      (ein:log 'error "ob-ein--execute-body: %s timed out" name))
                  (ob-ein--process-outputs result-type
					   (ein:shared-output-get-cell)
					   params))))
      (org-babel-remove-result)
      *ob-ein-sentinel*)))

(defun ob-ein--execute-async-callback (buffer params result-type result-params name)
  "Return callback of 1-arity (the shared output cell) to update org buffer when
`ein:shared-output-eval-string' completes.

The callback returns t if results containt RESULT-TYPE outputs, nil otherwise."
  (apply-partially
   (lambda (buffer* params* result-type* result-params* name* cell)
     (when-let ((raw (aif (ein:oref-safe cell 'traceback)
			 (ansi-color-apply (ein:join-str "\n" it))
		       (ob-ein--process-outputs result-type* cell params*))))
       (prog1 t
	 (let ((result
		(let ((tmp-file (org-babel-temp-file "ein-")))
		  (with-temp-file tmp-file (insert raw))
		  (org-babel-result-cond result-params*
		    raw (org-babel-import-elisp-from-file tmp-file '(16)))))
	       (info (org-babel-get-src-block-info 'light)))
	   (ein:log 'debug "ob-ein--execute-async-callback %s \"%s\" %s"
		    name* result buffer*)
	   (save-excursion
	     (save-restriction
	       (with-current-buffer buffer*
		 (unless (stringp (org-babel-goto-named-src-block name*)) ;; stringp=error
		   (when (version-list-< (version-to-list (org-release)) '(9))
		     (when info ;; kill #+RESULTS: (no-name)
		       (setf (nth 4 info) nil)
		       (org-babel-remove-result info))
		     (org-babel-remove-result)) ;; kill #+RESULTS: name
		   (org-babel-insert-result
		    result
		    (cdr (assoc :result-params
				(cl-third (org-babel-get-src-block-info)))))
		   (org-redisplay-inline-images)))))))))
   buffer params result-type result-params name))

(defun ob-ein--execute-async-clear (buffer result-params name)
  "Return function of 0-arity to clear *ob-ein-sentinel*."
  (apply-partially
   (lambda (buffer* result-params* name*)
     (let ((info (org-babel-get-src-block-info 'light)))
       (save-excursion
	 (save-restriction
	   (with-current-buffer buffer*
	     (unless (stringp (org-babel-goto-named-src-block name*)) ;; stringp=error
	       (when info ;; kill #+RESULTS: (no-name)
		 (setf (nth 4 info) nil)
		 (org-babel-remove-result info))
	       (org-babel-remove-result) ;; kill #+RESULTS: name
	       (org-babel-insert-result "" result-params*)
	       (org-redisplay-inline-images)))))))
   buffer result-params name))

(defun ob-ein--execute-async (buffer body kernel params result-type result-params name)
  "As `ein:shared-output-get-cell' is a singleton, ob-ein can only execute blocks
one at a time.  Further, we do not order the queued up blocks!"
  (deferred:$
    (deferred:next
      (deferred:lambda ()
        (let ((cell (ein:shared-output-get-cell)))
          (if (eq (slot-value cell 'callback) #'ignore)
              (let ((callback (ob-ein--execute-async-callback
			       buffer params result-type
			       result-params name))
		    (clear (ob-ein--execute-async-clear buffer result-params name)))
                (setf (slot-value cell 'callback) callback)
		(setf (slot-value cell 'clear) clear))
            ;; still pending previous callback
            (deferred:nextc (deferred:wait 1200) self)))))
    (deferred:nextc it
      (lambda (_x)
        (ein:shared-output-eval-string kernel body)))))

(defun ob-ein--parse-session (session)
  (let* ((url-or-port (ein:jupyter-my-url-or-port))
         (tokens (split-string session "/"))
         (parsed-url (url-generic-parse-url session))
         (url-host (url-host parsed-url)))
    (cond ((null url-host)
           (let* ((candidate (apply #'ein:url (car tokens) (cdr tokens)))
                  (parsed-candidate (url-generic-parse-url candidate))
                  (missing (url-scheme-get-property
                            (url-type parsed-candidate)
                            'default-port)))
             (if (and url-or-port
                      (= (url-port parsed-candidate) missing))
                 (apply #'ein:url url-or-port (cdr tokens))
               candidate)))
          (t (ein:url session)))))

(defun ob-ein--initiate-session (session kernelspec callback)
  "Retrieve notebook of SESSION path and KERNELSPEC.
Start jupyter instance if necessary.
Install CALLBACK (i.e., cell execution) upon notebook retrieval."
  (let* ((nbpath (ob-ein--parse-session session))
         (info (org-babel-get-src-block-info))
         (anonymous-path (format ob-ein-anonymous-path (nth 0 info)))
         (parsed-url (url-generic-parse-url nbpath))
         (slash-path (car (url-path-and-query parsed-url)))
         (_ (awhen (cdr (url-path-and-query parsed-url))
              (error "Cannot handle :session `%s`" it)))
         (ipynb-p (file-name-extension (file-name-nondirectory slash-path)))
         (path (if ipynb-p
                   (file-name-nondirectory slash-path)
                 anonymous-path))
         (url-or-port (directory-file-name
                       (if ipynb-p
                           (cl-subseq nbpath 0 (- (length path)))
                         nbpath)))
         (notebook (ein:notebook-get-opened-notebook url-or-port path))
         (callback-nbopen (lambda (nb _created)
                            (cl-loop repeat 50
                                  for live-p = (ein:kernel-live-p (ein:$notebook-kernel nb))
                                  until live-p
                                  do (sleep-for 0 300)
                                  finally
                                  do (if (not live-p)
                                         (ein:log 'error
                                           "Kernel for %s failed to launch"
                                           (ein:$notebook-notebook-name nb))
                                       (funcall callback nb)))))
         (errback-nbopen (lambda (url-or-port status-code)
                           (if (eq status-code 404)
                               (ein:notebooklist-new-notebook-with-name
                                url-or-port kernelspec path callback-nbopen t))))
         (callback-login (lambda (_buffer url-or-port)
                           (ein:notebook-open url-or-port path kernelspec
                                              callback-nbopen errback-nbopen t))))
    (cond ((and notebook
                (string= path anonymous-path)
                (stringp kernelspec)
                (not (equal (ein:$kernelspec-name (ein:$notebook-kernelspec notebook))
                            kernelspec)))
           (ein:log 'debug "ob-ein--initiate-session: switching %s from %s to %s"
                    path (ein:$kernelspec-name (ein:$notebook-kernelspec notebook))
                    kernelspec)
           (cl-letf (((symbol-function 'y-or-n-p) #'ignore))
             (ein:notebook-close notebook))
           (ein:query-singleton-ajax (ein:notebook-url notebook)
            :type "DELETE")
           (cl-loop repeat 8
                    with fullpath = (concat (file-name-as-directory nbpath) path)
                    for extant = (file-exists-p fullpath)
                    until (not extant)
                    do (sleep-for 0 500)
                    finally do (if extant
                                   (ein:display-warning
                                    (format "cannot delete path=%s nbpath=%s"
                                            fullpath nbpath))
                                 (ob-ein--initiate-session session kernelspec callback))))
          (notebook (funcall callback notebook))
          ((string= (url-host parsed-url) ein:url-localhost)
           (ein:process-refresh-processes)
           (aif (ein:process-url-match nbpath)
               (ein:notebooklist-login (ein:process-url-or-port it) callback-login)
             (ein:jupyter-server-start
              (executable-find (or (ein:eval-if-bound 'ein:jupyter-server-command)
                                   "jupyter"))
              (read-directory-name "Notebook directory: " default-directory)
              nil
              callback-login
              (let* ((port (url-port parsed-url))
                     (avoid (url-scheme-get-property (url-type parsed-url) 'default-port)))
                (cond ((= port avoid) nil)
                      (t (url-port parsed-url)))))))
          (t (ein:notebooklist-login url-or-port callback-login)))))

(cl-loop for (lang . mode) in ob-ein-languages
         do (ob-ein--babelize-lang lang mode))

(defun ob-ein-kernel-interrupt ()
  "Interrupt kernel associated with session."
  (interactive)
  (org-babel-when-in-src-block
   (-if-let* ((info (org-babel-get-src-block-info))
              (pparams (cl-callf org-babel-process-params (nth 2 info)))
              (params (nth 2 info))
              (session (or (aand (cdr (assoc :session params))
                                 (unless (string= "none" it)
                                   (format "%s" it)))
                           ein:url-localhost))
              (nbpath (ob-ein--parse-session session))
              (anonymous-path (format ob-ein-anonymous-path (nth 0 info)))
              (parsed-url (url-generic-parse-url nbpath))
              (slash-path (car (url-path-and-query parsed-url)))
              (path (if (string= slash-path "") anonymous-path
                      (substring slash-path 1)))
              (url-or-port (if (string= slash-path "")
                               nbpath
                             (substring nbpath 0 (- (length slash-path)))))
              (notebook (ein:notebook-get-opened-notebook url-or-port path))
              (kernel (ein:$notebook-kernel notebook)))
       (ein:kernel-interrupt kernel)
     (ein:log 'info "ob-ein-kernel-interrupt: nothing to interrupt"))))

(define-key org-babel-map "\C-k" 'ob-ein-kernel-interrupt)

;;;###autoload
(when (featurep 'org)
  (let* ((orig (get 'org-babel-load-languages 'custom-type))
         (orig-cdr (cdr orig))
         (choices (plist-get orig-cdr :key-type)))
    (push '(const :tag "Ein" ein) (nthcdr 1 choices))
    (put 'org-babel-load-languages 'custom-type
         (cons (car orig) (plist-put orig-cdr :key-type choices)))))

(provide 'ob-ein)