;;;; -*- Mode: Lisp; Base: 10; Syntax: ANSI-Common-Lisp; Package: kira; Coding: utf-8 -*-
;;;; Copyright © 2024 David Mullen. All Rights Reserved. Origin: <https://cl-pdx.com/kira/>

(in-package :kira)

(eval-always 
  (defvar *html-output* *standard-output*)
  (defvar *html-processor* nil)
  (defvar *in-attribute-p* nil))

(defun process-attributes (attributes)
  (loop for html-operator = '(:progn)
        until (endp attributes)
        do (when (string= (first attributes) "/")
             ;; Special marker for XML self-closing.
             (return (write-char #\/ *html-output*)))
           (destructuring-bind (key value &rest rest) attributes
             (cond ((not (eq key :if*)) (setq attributes rest))
                   (t (destructuring-bind (actual-key actual-value &rest rest) rest
                        (multiple-value-setq (key value) (values actual-key actual-value))
                        (setq html-operator `(:when ,(cadr attributes)) attributes rest))))
             (check-type key (satisfies self-evaluating-p))
             (let* ((no-value-p (and (symbolp value) (zerop (length (symbol-name value)))))
                    (value-sequence (unless no-value-p `("=\"" (:attribute ,value) #\"))))
               (process-html `((,@html-operator #\Space ,key ,@value-sequence)))))))

(defun parse-html-tag (tag)
  (cond ((atom tag) (values tag))
        (t (values (pop tag) tag))))

(defun start-tag (name attributes)
  (write-char #\< *html-output*)
  (values (escape-html name nil)
          (process-attributes attributes)
          (write-char #\> *html-output*)))

(defun end-tag (name)
  (write-char #\< *html-output*)
  (write-char #\/ *html-output*)
  (prog1 (escape-html name nil)
    (write-char #\> *html-output*)))

(defun process-html (forms)
  (loop with (collector . environment) = (or *html-processor* '(print))
        for original-form in forms as form = (html-macroexpand original-form environment)
        do (flet ((emptyp (name) (case name ((:br :col :hr :img :input :link :meta) t))))
             (multiple-value-bind (form form-evaluated-p) (macro-eval form environment)
               (cond ((stringp form) (write-string form *html-output*))
                     ((characterp form) (write-char form *html-output*))
                     (form-evaluated-p (escape-html form *in-attribute-p*))
                     (t (when (symbolp form) ; Not a constant symbol.
                          (setq form `(escape-html ,form ,*in-attribute-p*)))
                        (multiple-value-bind (name attributes)
                            (parse-html-tag (first form))
                          (typecase name
                            ((eql :attribute)
                             (let ((*in-attribute-p* t))
                               (process-html (rest form))))
                            ((eql :progn) (process-html (rest form)))
                            ((not keyword) (funcall collector form))
                            (t (unless (nth-value 1 (start-tag name attributes))
                                 (unless (and (endp (rest form)) (emptyp name))
                                   (process-html (rest form)) (end-tag name))))))))))))

(defun constant-fold-html (processor)
  (let ((collector (car processor)))
    (let ((string (get-output-stream-string *html-output*)))
      (when (zerop (length string)) (return-from constant-fold-html))
      (funcall collector `(write-string ,string *html-output*) nil))))

(defmacro html
    (&rest original-forms
     &environment environment)
  (collecting (lisp-forms)
    (let* ((collector
            #'(lambda (form &optional (foldp t))
                (when foldp ; Collect buffered output. 
                  (constant-fold-html *html-processor*))
                (collect form :into lisp-forms)))
           (*html-processor* (cons collector environment))
           (*html-output* (make-string-output-stream))
           (*in-attribute-p* nil))
      (process-html original-forms)
      (constant-fold-html *html-processor*)
      `(progn ,.lisp-forms))))

(defmacro grab (&rest html-output-forms)
  `(with-output-to-string (*html-output*)
     (html ,@html-output-forms)))

(defmacro %html-as-string (object)
  `(cond ((not (symbolp ,object))
          (%as-string ,object))
         ((let ((string (string ,object)))
            (if (uniform-case-p string)
                (string-downcase string)
                string)))))

(defun escape-html (object &optional in-attribute-p &key (start 0) end)
  "Equivalent to PRINC'ing an object to *HTML-OUTPUT* but escaping HTML."
  (prog1 object
    (typecase object
      ((or number local-time) (princ object *html-output*))
      (t (with-lexer ((%html-as-string object) start end)
           (loop with stream = *html-output* and safe-start
                 with output of-type simple-string = ""
                 do (when (< (setq safe-start (lexer-skip (not "<>&\""))) lexer-position)
                      (write-string lexer-string stream :start safe-start :end lexer-position))
                    (lexer-case (char (loop-finish))
                      (#\" (if in-attribute-p
                               (setq output "&quot;")
                               (setq output "\"")))
                      (#\< (setq output "&lt;"))
                      (#\> (setq output "&gt;"))
                      (#\& (setq output "&amp;")))
                    (write-string output stream)))))))

(defmacro define-html-macro (name lambda-list &body body)
  (let* ((lambda-list (normalize-lambda-list lambda-list))
         (attributes-tail (member '&attributes lambda-list))
         (attributes (if attributes-tail
                         (prog1 (require-type (second attributes-tail) 'symbol)
                           (let ((preceding-lambda-list (ldiff lambda-list attributes-tail)))
                             (setq lambda-list (nconc preceding-lambda-list (cddr attributes-tail)))))
                         (gensym #"attributes"))))
    (multiple-value-bind (lambda-expression documentation) (parse-macro-1 name lambda-list body)
      (let ((lambda-expression-body `((declare (ignorable ,attributes)) ,@(cddr lambda-expression))))
        (setq lambda-expression `(lambda (,attributes ,@(cadr lambda-expression)) ,@lambda-expression-body)))
      `(eval-always (progn (setf (documentation ',name 'html-macro) ,documentation)
                           (setf (get ',name 'html-macro-function) #',lambda-expression)
                           ',name)))))

(defun html-macro-function (name)
  (get name 'html-macro-function))

(defun html-macroexpand-1 (form &optional environment)
  (multiple-value-bind (element-name attributes) (when (consp form) (parse-html-tag (car form)))
    (whereas ((macro-function (and element-name (symbolp element-name) (html-macro-function element-name))))
      (return-from html-macroexpand-1 (values (funcall macro-function attributes form environment) t)))
    (values form nil)))

(defun html-macroexpand (form &optional environment)
  (loop with expanded-p and any-expanded-p = nil
        do (multiple-value-setq (form expanded-p)
             (html-macroexpand-1 form environment))
        while expanded-p do (setq any-expanded-p t)
        finally (return (values form any-expanded-p))))

(define-html-macro
    html (&rest html-forms)
  `(:progn ,@html-forms))

(define-html-macro :string (string &key (start 0) end &environment environment)
  (multiple-value-bind (string string-evaluated-p) (macro-eval string environment)
    (multiple-value-bind (start start-evaluated-p) (macro-eval start environment)
      (multiple-value-bind (end end-evaluated-p) (macro-eval end environment)
        (cond ((and string-evaluated-p start-evaluated-p end-evaluated-p)
               (subseq string start end))
              ((and (eql start 0) (not end)) `(write-string ,string *html-output*))
              (t `(write-string ,string *html-output* :start ,start :end ,end)))))))

(define-html-macro :princ (object)
  `(escape-html ,object ,*in-attribute-p*))

(define-html-macro :prin1 (object)
  `(:princ (prin1-to-string ,object)))

(define-html-macro uri-to (object . parameters)
  `(escape-html (uri-to ,object ,@parameters)
                ,*in-attribute-p*))

(define-html-macro absolute-uri-to (object . parameters)
  `(escape-html (absolute-uri-to ,object ,@parameters)
                ,*in-attribute-p*))

(define-html-macro link (designator &rest attributes &environment environment)
  (let* ((uri (uri (macro-eval designator environment))) (path (uri-path uri)))
    `((:a :href ,uri ,@attributes) ,(uri path))))

(define-html-macro :pl
    (&attributes attributes . content)
  `((:table :class "plist" ,@attributes)
    ,@content))

(define-html-macro
    :pr (&attributes attributes key &rest value)
  `((:tr ,@attributes) (:th ,key) (:td ,@value)))

(defstatic +ip-lookup-service+
  #u"https://ipgeolocation.io/ip-location/")

(define-html-macro :ip-address (&attributes attributes ip-address)
  (unless (getf attributes :class) (setq attributes `(:class "ip" ,@attributes)))
  (rebinding (ip-address) (let ((href `(:progn ,+ip-lookup-service+ ,ip-address)))
                            `(html ((:a :href ,href ,@attributes) ,ip-address)))))

(define-html-macro :format (control &rest arguments)
  `(escape-html (format nil ,control ,@arguments)
                ,*in-attribute-p*))

(define-html-macro :when (test &body body &environment test-environment)
  (multiple-value-bind (test test-evaluated-p) (macro-eval test test-environment)
    (if test-evaluated-p (if test `(:progn ,@body) "") `(when ,test (html ,@body)))))

(define-html-macro :unless (test &body body &environment test-environment)
  (multiple-value-bind (test test-evaluated-p) (macro-eval test test-environment)
    (if test-evaluated-p (if test "" `(:progn ,@body)) `(unless ,test (html ,@body)))))

(define-html-macro :let ((&rest bindings) &body body &environment environment)
  (multiple-value-bind (body declarations) (parse-body body environment nil)
    `(let (,@bindings) ,.declarations (html ,@body))))

(define-html-macro :let* ((&rest bindings) &body body &environment environment)
  (multiple-value-bind (body declarations) (parse-body body environment nil)
    `(let* (,@bindings) ,.declarations (html ,@body))))

(define-html-macro :whereas
    ((&rest bindings) &body body)
  `(whereas ,bindings (html ,@body)))

(define-html-macro :if (test then &optional (else "") &environment test-environment)
  (multiple-value-bind (test test-evaluated-p) (macro-eval test test-environment)
    (if test-evaluated-p (if test then else) `(if ,test (html ,then) (html ,else)))))

(defun get-action-uri (designator &aux (uri (uri designator)))
  (when (assoc 'timestamp (uri-alist uri) :test #'string-equal)
    (return-from get-action-uri designator))
  (prog1 (if (uri-p designator) (setq uri (copy-uri uri)) uri)
    (appendf (%uri-alist uri) `(("timestamp" . ,(make-timestamp))))
    (setf (values (%uri-string uri) (%uri-query uri)) nil)))

(define-html-macro :standard-form (&attributes attributes &rest form-fields)
  (let ((standard-form-submission-p (null (get-properties attributes '(:method :enctype)))))
    `((:form :action (:princ (get-action-uri ,(getf attributes :action '(request-uri *request*))))
             ,@(when standard-form-submission-p '(:method :post :enctype "multipart/form-data"))
             ,@(sans attributes :action)) ,@form-fields)))

(define-html-macro :table-form (&attributes attributes . form-fields)
  `((:standard-form ,@attributes) ((:table :class "form") ,@form-fields)))

(define-html-macro :submit (&attributes attributes &rest value)
  `((:input :type :submit ,@attributes :value (:progn ,@value))))

(define-html-macro :html-page (&attributes attributes (&rest head) body)
  `(:progn "<!doctype html>" ((:html ,@attributes) (:head ,@head) ,body)))

(define-symbol-macro +canonical-xml-declaration+
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")

(define-html-macro :cdata (&rest content)
  `(:progn "<![CDATA[" ,@content "]]>"))

(define-html-macro :rss-channel (&attributes attributes . channel-elements)
  `((:rss :version "2.0" ,@attributes) (:channel ,@channel-elements)))