;;; chatgpt.el --- Simple ChatGPT client -*- lexical-binding: t; -*-
;;
;; Copyright (C) 2025 Tony Aldon
;;
;; Author: Tony Aldon <tony@tonyaldon.com>
;; Version: 1.0
;; Package-Requires: ((emacs "25.1"))
;; Homepage: https://tonyaldon.com
;;
;;; Commentary:
;;
;;;; Overview
;;
;; chatgpt.el is a simple Emacs package that allows you to interact
;; with OpenAI's ChatGPT directly from within Emacs.  It leverages the
;; OpenAI API to send prompts and receive responses.
;;
;;;; Key Features
;;
;; - Secure API Key Handling: Automatically retrieves your OpenAI API key
;;   from ~/.authinfo.gpg for secure storage or from the plaintext
;;   ~/.authinfo file.
;; - Prompt History: Keeps track of your previous prompts, allowing you
;;   to navigate back and forth through your request history pressing `M-p'
;;   and `M-n' in the prompt buffer.
;; - Request Logging: Saves all requests and responses in the
;;   `chatgpt-dir' directory (by default, ~/.emacs.d/chatgpt-requests/).
;;
;;;; Get started in minutes
;;
;; 1) Add the directory containing chatgpt.el to your `load-path' and
;;    require the chatgpt.el package by adding the following lines to
;;    your init file, ensuring to replace /path/to/chatgpt/ with the
;;    appropriate directory:
;;
;;        (add-to-list 'load-path "/path/to/chatgpt/")
;;        (require 'eden)
;;
;; 2) Store your OpenAI API key in either the ~/.authinfo.gpg file
;;    (encrypted with gpg) or the ~/.authinfo file (plaintext):
;;
;;    - After funding your OpenAI account (https://platform.openai.com)
;;      ($5.00 is enough to get started), create an OpenAI API key
;;      visiting https://platform.openai.com/api-keys.
;;    - Add the API key in the selected file as follows:
;;
;;          machine openai password <openai-api-key>
;;
;;      where <openai-api-key> is your API key.
;;
;;    - Restart Emacs to apply this change.
;;
;; 3) Call the command `chatgpt' to switch to *chatgpt* prompt buffer,
;; 4) Enter your prompt,
;; 5) Press C-c C-c to send your prompt to OpenAI API,
;; 6) Finally, the response will asynchronously show up in a dedicated
;;    buffer upon receipt.


(require 'json)
(require 'markdown-mode)

;;; Code:

(defvar chatgpt-api-key nil "OpenAI API key.")

(defvar chatgpt-dir
  (expand-file-name (concat user-emacs-directory "chatgpt-requests/"))
  "Request directory.

This directory path must be absolute and end with a forward slash
like this:

    \"/home/tony/chatgpt-emacs/requests/\"")

(defun chatgpt-json-encode (object)
  "Return a JSON representation of OBJECT as a string."
  (let ((json-encoding-pretty-print t))
    (json-encode object)))

(defun chatgpt-json-read ()
  "Parse and return the JSON object following point."
  (let ((json-key-type 'keyword)
        (json-object-type 'plist)
        (json-array-type 'vector))
    (json-read)))

(defun chatgpt-command (req-path)
  "Return the curl command with REQ-PATH request data for OpenAI API call.

Also retrieve OpenAI API key from `~/.authinfo.gpg' (encrypted
with gpg) or `~/.authinfo' files looking for a line like this

       machine openai password <openai-api-key>"
  (when (null chatgpt-api-key)
    (setq chatgpt-api-key
          (auth-source-pick-first-password :host "openai")))
  (format
   (concat "curl https://api.openai.com/v1/chat/completions "
           "-H 'Content-Type: application/json' "
           "-H 'Authorization: Bearer %s' "
           "-d @%s")
   chatgpt-api-key req-path))

(defvar chatgpt-model "gpt-4o" "OpenAI model.")

(defun chatgpt-request (prompt)
  "Return an OpenAI request with PROMPT."
  `(:model ,chatgpt-model
    :messages ,(vector `(:role "user" :content ,prompt))))

(defun chatgpt-callback (prompt response req-dir)
  "Append PROMPT and RESPONSE to the prompt buffer with a link to REQ-DIR.

Also display the response buffer."
  (let ((buff (get-buffer-create "*chatgpt[requests]*")))
    (with-current-buffer buff
      (markdown-mode)
      (goto-char (point-max))
      (insert "# Request\n\n"
              "<!-- [](" req-dir ") -->\n\n"
              "## Prompt\n\n" prompt "\n\n"
              "## Response\n\n" response "\n\n"))
    (with-selected-window (display-buffer buff nil)
      (goto-char (point-max))
      (re-search-backward "^## Response")
      (recenter-top-bottom 0))
    (message "Response received from OpenAI.")))

(defvar chatgpt-timer nil "Timer for waiting widget in mode line.")

(defun chatgpt-mode-line-waiting (action)
  "Start or stop a waiting widget in mode line.

Accepted values for ACTION includes `start' and `stop'."
  (pcase action
    ('start
     (setq chatgpt-timer
           (run-with-timer
            0 0.66
            (let ((idx 0))
              (lambda ()
                (progn
                  (setq global-mode-string
                        `(:eval ,(concat "| ChatGPT." (make-string (mod idx 3) ?.))))
                  (force-mode-line-update 'all)
                  (cl-incf idx)))))))
    ('stop
     (cancel-timer chatgpt-timer)
     (setq chatgpt-timer nil)
     (setq global-mode-string nil)
     (force-mode-line-update 'all))))

(defun chatgpt-send-request (prompt)
  "Send the request with PROMPT to OpenAI."
  (let* ((req (chatgpt-request prompt))
         (temporary-file-directory chatgpt-dir)
         (req-dir (file-name-as-directory (make-temp-file nil t)))
         (req-path (concat req-dir "request.json"))
         (timestamp-path (format "%stimestamp-%s" req-dir (time-to-seconds)))
         (command (chatgpt-command req-path)))
    (message "chatgpt: %s" req-dir)
    (write-region (chatgpt-json-encode req) nil req-path)
    (write-region "" nil timestamp-path)
    (chatgpt-push req-dir)
    (chatgpt-mode-line-waiting 'start)
    (make-process
     :name "chatgpt"
     :buffer (generate-new-buffer-name "chatgpt")
     :command (list "sh" "-c" command)
     :sentinel
     (lambda (process event)
       (chatgpt-mode-line-waiting 'stop)
       (if (not (string= event "finished\n"))
           (let ((err `(:type "process-error" :error (:event ,event)))
                 (err-path (concat req-dir "error.json")))
             (write-region (chatgpt-json-encode err) nil err-path)
             (error "%S" err))
         (let* ((resp (with-current-buffer (process-buffer process)
                        (goto-char (point-min))
                        (chatgpt-json-read))))
           (if-let ((api-error (plist-get resp :error)))
               (let ((err `(:type "api-error" :error ,api-error))
                     (err-path (concat req-dir "error.json")))
                 (write-region (chatgpt-json-encode err) nil err-path)
                 (error "%S" err))
             (let ((response (map-nested-elt resp [:choices 0 :message :content]))
                   (resp-path (concat req-dir "response.json")))
               (write-region (chatgpt-json-encode resp) nil resp-path)
               (chatgpt-callback prompt response req-dir))))
         (kill-buffer (process-buffer process)))))))

(defun chatgpt-send ()
  "Send the current prompt to OpenAI."
  (interactive)
  (chatgpt-send-request (buffer-string))
  (erase-buffer)
  (when (> (length (window-list)) 1)
    (delete-window))
  (message "Request sent to OpenAI."))

(defun chatgpt-timestamp (file)
  "Return the timestamp number associated with timestamp FILE."
  (string-to-number (nth 1 (split-string file "timestamp-"))))

(defun chatgpt-requests ()
  "Return a sorted list of the requests in `chatgpt-dir'.

The most recent requests are listed first."
  (let ((files (directory-files-recursively chatgpt-dir "timestamp.*")))
    (mapcar (lambda (f) (string-trim-right f "timestamp.*"))
            (seq-sort
             (lambda (f1 f2) (> (chatgpt-timestamp f1) (chatgpt-timestamp f2)))
             files))))

(defvar chatgpt-request-dir nil
  "Hold request directory of the current prompt.")

(defvar chatgpt-history (make-ring 0)
  "Ring of the request directories.")

(defun chatgpt-history-set ()
  "Set `chatgpt-history' with request in `chatgpt-dir'."
  (when (file-exists-p chatgpt-dir)
    (setq chatgpt-history (ring-convert-sequence-to-ring (chatgpt-requests)))))

(defun chatgpt-push (req-dir)
  "Insert REQ-DIR into `chatgpt-history' ring."
  (setq chatgpt-request-dir nil)
  (ring-insert+extend chatgpt-history req-dir t))

(defun chatgpt-prompt (direction)
  "Replace current buffer content with DIRECTION prompt."
  (interactive)
  (if (ring-empty-p chatgpt-history)
      (message "`chatgpt-history' empty.  Send a request first.")
    (let* ((req-dir (if (null chatgpt-request-dir)
                        (ring-ref chatgpt-history 0)
                      (if (eq direction 'previous)
                          (ring-next chatgpt-history chatgpt-request-dir)
                        (ring-previous chatgpt-history chatgpt-request-dir))))
           (req (with-temp-buffer
                  (insert-file-contents (concat req-dir "request.json"))
                  (chatgpt-json-read)))
           (prompt (map-nested-elt req [:messages 0 :content])))
      (setq chatgpt-request-dir req-dir)
      (erase-buffer)
      (save-excursion (insert prompt)))))

(defun chatgpt-previous ()
  "Replace current buffer content with next prompt."
  (interactive)
  (chatgpt-prompt 'previous))

(defun chatgpt-next ()
  "Replace current buffer content with next prompt."
  (interactive)
  (chatgpt-prompt 'next))

(defvar chatgpt-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "M-p") 'chatgpt-previous)
    (define-key map (kbd "M-n") 'chatgpt-next)
    (define-key map (kbd "C-c C-c") 'chatgpt-send)
    map)
  "Keymap of `chatgpt-mode'.")

(define-derived-mode chatgpt-mode markdown-mode "ChatGPT"
  "ChatGPT mode."
  (setq mode-line-format
        '(" "
          mode-line-buffer-identification
          " "
          chatgpt-model
          " "
          mode-line-misc-info))
  (make-directory chatgpt-dir t)
  (chatgpt-history-set))

(defun chatgpt ()
  "Display and Select the prompt buffer.

Once in that buffer you can enter your prompt and send it
to OpenAI with `chatgpt-send' command bound by default to `C-c C-c'.

Your OpenAI API key will be retrieved in from `~/.authinfo.gpg'
\(encrypted with gpg) or `~/.authinfo' files looking for a line
like this

       machine openai password <openai-api-key>"
  (interactive)
  (let* ((buff-name "*chatgpt*")
         (buff-p (get-buffer buff-name))
         (buff (get-buffer-create buff-name)))
    (select-window
     (display-buffer-at-bottom
      buff '(display-buffer-below-selected (window-height . 6))))
    (when (not buff-p) (chatgpt-mode))))

(provide 'chatgpt)
;;; chatgpt.el ends here
