;;;; -*- Mode: Lisp; Base: 10; Syntax: ANSI-Common-Lisp; Package: kira; Coding: utf-8 -*-
;;;; Copyright © 2018 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 prefix = '(:progn) until (endp attributes)
        do (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
                        (setq prefix `(:when ,value) key actual-key value actual-value)
                        (setq 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 `((,@prefix #\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*)
  (princ name *html-output*)
  (process-attributes attributes)
  (write-char #\> *html-output*)
  name)

(define-symbol-macro +empty-html-elements+
  '(:br :col :hr :img :input :link :meta))

(defun end-tag (name)
  (unless (member name +empty-html-elements+)
    (format *html-output* "</~A>" name)
    name))

(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 (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 :process-with)
                           (or (whereas ((function (cadr form)))
                                 (funcall function (cddr form)))
                               (error "~S needs function." :process-with)))
                          ((eql :progn) (process-html (rest form)))
                          ((not keyword) (funcall collector form))
                          (t (start-tag name attributes)
                             (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 ; Empty the buffer by default.
                  (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)))

(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 ((%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 (macro-name lambda-list &body body)
  (multiple-value-bind (lambda-list whole-element environment)
      (normalize-lambda-list lambda-list t t)
    (orf environment (gensym (string 'environment)))
    (orf whole-element (gensym (string 'whole-element)))
    (with-gensyms (tag content)
      (let ((attributes-tail (member '&attributes lambda-list)))
        (multiple-value-bind (body declarations documentation) (parse-body body nil t)
          (let ((attributes (require-type (if attributes-tail (second attributes-tail)) 'symbol))
                (documenting-form `(setf (documentation ',macro-name 'html-macro) ',documentation)))
            (when (prog1 attributes-tail (orf attributes (gensym (string 'attributes))))
              (let ((preceding-lambda-list (ldiff lambda-list attributes-tail)))
                (setq lambda-list (nconc preceding-lambda-list (cddr attributes-tail)))))
            `(eval-always
               (prog1 (prog1 ',macro-name ,documenting-form)
                 (setf (get ',macro-name 'html-macro-function)
                       #'(lambda (,whole-element ,environment)
                           (declare (ignorable ,environment))
                           (destructuring-bind (,tag &rest ,content) ,whole-element
                             (let ((,attributes (nth-value 1 (parse-html-tag ,tag))))
                               (declare (ignorable ,attributes))
                               (destructuring-bind ,lambda-list ,content
                                 ,.declarations (block ,macro-name ,@body))))))))))))))

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

(defun html-macroexpand-1 (form &optional environment)
  (let ((element-name (when (consp form) (parse-html-tag (car form)))))
    (whereas ((macro-function (when (symbolp element-name) (html-macro-function element-name))))
      (return-from html-macroexpand-1 (values (funcall macro-function 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 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 :format (control &rest arguments)
  `(escape-html (format nil ,control ,@arguments)
                ,*in-attribute-p*))

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

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

(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-form then &optional (else "") &environment environment)
  (multiple-value-bind (test-form test-evaluated-p) (macro-eval test-form environment)
    (when test-evaluated-p (return-from :if (if test-form then else)))
    `(if ,test-form (html ,then) (html ,else))))

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

(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\"?>")

(defmacro with-xml-element (tag &body content)
  `(html (:process-with process-xml-element
                        ,tag ,@content)))

(defun process-xml-element (element)
  (multiple-value-bind (name attributes)
      (parse-html-tag (first element))
    (write-char #\< *html-output*)
    (princ name *html-output*)
    (process-attributes attributes)
    (if (endp (rest element))
        (write-string "/>" *html-output*)
        (progn (write-char #\> *html-output*)
               (process-html (rest element))
               (write-string "</" *html-output*)
               (princ name *html-output*)
               (write-char #\> *html-output*)))))

(define-html-macro with-xml-element (tag &body content)
  `(:process-with process-xml-element ,tag ,@content))

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