;;;; tests/mock-server.lisp -- Hunchentoot-based mock MCIAS server

(in-package #:mcias-client-tests)

;;;; -----------------------------------------------------------------------
;;;; Global state
;;;; -----------------------------------------------------------------------

(defvar *mock-server*   nil "The running Hunchentoot acceptor.")
(defvar *mock-accounts* nil "Hash table: id -> account plist.")
(defvar *mock-by-name*  nil "Hash table: username -> id.")
(defvar *mock-tokens*   nil "Hash table: token-string -> account-id.")
(defvar *mock-revoked*  nil "Hash table: token-string -> t (revoked tokens).")
(defvar *mock-pgcreds*  nil "Hash table: account-id -> pgcreds plist.")

(defun reset-mock-state! ()
  "Reset all mock server state to empty."
  (setf *mock-accounts* (make-hash-table :test 'equal)
        *mock-by-name*  (make-hash-table :test 'equal)
        *mock-tokens*   (make-hash-table :test 'equal)
        *mock-revoked*  (make-hash-table :test 'equal)
        *mock-pgcreds*  (make-hash-table :test 'equal)))

;; Initialise state immediately so the vars are never NIL.
(reset-mock-state!)

;;;; -----------------------------------------------------------------------
;;;; Mock data helpers
;;;; -----------------------------------------------------------------------

(let ((id-counter 0))
  (defun %next-id ()
    (incf id-counter)
    (format nil "acct-~4,'0D" id-counter)))

(defun add-mock-account (username password account-type &rest roles)
  "Add a mock account and return its ID string."
  (let ((id (format nil "acct-~A" (gensym ""))))
    (setf (gethash id *mock-accounts*)
          (list :id           id
                :username     username
                :password     password
                :account-type account-type
                :status       "active"
                :roles        (or roles '())
                :totp-enabled nil
                :created-at   "2024-01-01T00:00:00Z"
                :updated-at   "2024-01-01T00:00:00Z"))
    (setf (gethash username *mock-by-name*) id)
    id))

(defun %issue-mock-token (account-id)
  "Create and store a mock token for ACCOUNT-ID. Returns the token string."
  (let ((token (format nil "mock-token-~A-~A" account-id (gensym ""))))
    (setf (gethash token *mock-tokens*) account-id)
    token))

;;;; -----------------------------------------------------------------------
;;;; Response helpers (used inside Hunchentoot handlers)
;;;; -----------------------------------------------------------------------

(defun %yason-encode (obj)
  "Encode OBJ to a JSON string."
  (with-output-to-string (s)
    (yason:encode obj s)))

(defun %send-json (status body-string)
  "Set the HTTP status code and content-type, then return BODY-STRING."
  (setf (hunchentoot:return-code*)    status
        (hunchentoot:content-type*)   "application/json")
  body-string)

(defun %send-ok (ht)
  "Send a 200 response with HT (hash-table) encoded as JSON."
  (%send-json 200 (%yason-encode ht)))

(defun %send-no-content ()
  "Send a 204 No Content response."
  (setf (hunchentoot:return-code*)  204
        (hunchentoot:content-type*) "application/json")
  "")

(defun %send-error (code message)
  "Send an error response with CODE and MESSAGE."
  (let ((ht (make-hash-table :test 'equal)))
    (setf (gethash "error" ht) message)
    (%send-json code (%yason-encode ht))))

(defun %read-json-body ()
  "Read and parse the raw POST body as JSON. Returns NIL on failure."
  (let ((raw (hunchentoot:raw-post-data :force-binary t)))
    (when raw
      (ignore-errors
        (yason:parse (babel:octets-to-string raw :encoding :utf-8))))))

(defun %bearer-token ()
  "Extract the Bearer token from the Authorization header, or NIL."
  (let ((auth (hunchentoot:header-in* :authorization)))
    (when (and auth (> (length auth) 7)
               (string-equal (subseq auth 0 7) "Bearer "))
      (subseq auth 7))))

(defun %authenticated-account ()
  "Return the account plist for the current Bearer token, or NIL."
  (let ((token (%bearer-token)))
    (when token
      (unless (gethash token *mock-revoked*)
        (let ((account-id (gethash token *mock-tokens*)))
          (when account-id
            (gethash account-id *mock-accounts*)))))))

(defun %require-admin ()
  "Return the authenticated account if it has the 'admin' role.
Sends 401 or 403 and returns NIL otherwise."
  (let ((acct (%authenticated-account)))
    (cond
      ((null acct)
       (%send-error 401 "unauthorized")
       nil)
      ((not (member "admin" (getf acct :roles) :test #'string=))
       (%send-error 403 "forbidden")
       nil)
      (t acct))))

(defun %account->hash (acct)
  "Convert internal account plist ACCT to a yason-encodable hash-table."
  (let ((ht (make-hash-table :test 'equal)))
    (setf (gethash "id"           ht) (getf acct :id)
          (gethash "username"     ht) (getf acct :username)
          (gethash "account_type" ht) (getf acct :account-type)
          (gethash "status"       ht) (getf acct :status)
          (gethash "created_at"   ht) (getf acct :created-at)
          (gethash "updated_at"   ht) (getf acct :updated-at)
          ;; yason: nil -> JSON false, t -> JSON true
          (gethash "totp_enabled" ht) (if (getf acct :totp-enabled) t nil))
    ht))

;;;; -----------------------------------------------------------------------
;;;; Dispatcher
;;;; -----------------------------------------------------------------------

(defclass mock-dispatcher (hunchentoot:acceptor) ()
  (:documentation "Custom Hunchentoot acceptor that dispatches mock MCIAS routes."))

(defun %path= (path expected)
  "Check if PATH equals EXPECTED (case-insensitive)."
  (string-equal path expected))

(defun %path-prefix-p (path prefix)
  "Check if PATH starts with PREFIX."
  (and (>= (length path) (length prefix))
       (string-equal (subseq path 0 (length prefix)) prefix)))

(defun %path-segment (path n)
  "Return the Nth segment of PATH (0-indexed), split by /."
  (let ((parts (remove "" (cl-ppcre:split "/" path) :test #'string=)))
    (when (< n (length parts))
      (nth n parts))))

(defmethod hunchentoot:handle-request ((acceptor mock-dispatcher) request)
  "Dispatch requests to mock MCIAS handlers."
  (let ((method (hunchentoot:request-method request))
        (path   (hunchentoot:script-name    request)))
    (cond
      ;; GET /v1/health
      ((and (eq method :get) (%path= path "/v1/health"))
       (let ((ht (make-hash-table :test 'equal)))
         (setf (gethash "status" ht) "ok")
         (%send-ok ht)))

      ;; GET /v1/keys/public
      ((and (eq method :get) (%path= path "/v1/keys/public"))
       (let ((ht (make-hash-table :test 'equal)))
         (setf (gethash "kty" ht) "OKP"
               (gethash "crv" ht) "Ed25519"
               (gethash "x"   ht) "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
         (%send-ok ht)))

      ;; POST /v1/auth/login
      ((and (eq method :post) (%path= path "/v1/auth/login"))
       (let* ((body     (%read-json-body))
              (uname    (and body (gethash "username" body)))
              (pass     (and body (gethash "password" body)))
              (acct-id  (and uname (gethash uname *mock-by-name*)))
              (acct     (and acct-id (gethash acct-id *mock-accounts*))))
         (if (and acct (string= pass (getf acct :password)))
             (let* ((token (format nil "mock-token-~A" (gensym "")))
                    (ht    (make-hash-table :test 'equal)))
               (setf (gethash token *mock-tokens*) acct-id)
               (setf (gethash "token"      ht) token
                     (gethash "expires_at" ht) "2099-01-01T00:00:00Z")
               (%send-ok ht))
             (%send-error 401 "invalid credentials"))))

      ;; POST /v1/auth/logout
      ((and (eq method :post) (%path= path "/v1/auth/logout"))
       (let ((token (%bearer-token)))
         (when token
           (remhash token *mock-tokens*)
           (setf (gethash token *mock-revoked*) t)))
       (%send-no-content))

      ;; POST /v1/auth/renew
      ((and (eq method :post) (%path= path "/v1/auth/renew"))
       (let ((acct (%authenticated-account)))
         (if acct
             (let* ((old-token (%bearer-token))
                    (acct-id   (getf acct :id))
                    (new-token (format nil "mock-token-~A" (gensym "")))
                    (ht        (make-hash-table :test 'equal)))
               ;; Revoke old token
               (when old-token
                 (remhash old-token *mock-tokens*)
                 (setf (gethash old-token *mock-revoked*) t))
               (setf (gethash new-token *mock-tokens*) acct-id)
               (setf (gethash "token"      ht) new-token
                     (gethash "expires_at" ht) "2099-01-01T00:00:00Z")
               (%send-ok ht))
             (%send-error 401 "unauthorized"))))

      ;; POST /v1/token/validate
      ((and (eq method :post) (%path= path "/v1/token/validate"))
       (let* ((body    (%read-json-body))
              (tok-str (and body (gethash "token" body)))
              (ht      (make-hash-table :test 'equal)))
         (cond
           ;; Token is present, not revoked, and known
           ((and tok-str
                 (not (gethash tok-str *mock-revoked*))
                 (gethash tok-str *mock-tokens*))
            (let* ((acct-id (gethash tok-str *mock-tokens*))
                   (acct    (gethash acct-id *mock-accounts*)))
              ;; valid = t -> yason encodes as JSON true
              (setf (gethash "valid"      ht) t
                    (gethash "sub"        ht) acct-id
                    (gethash "roles"      ht) (getf acct :roles)
                    (gethash "expires_at" ht) "2099-01-01T00:00:00Z")))
           (t
            ;; valid = nil -> yason encodes as JSON false
            (setf (gethash "valid" ht) nil)))
         (%send-ok ht)))

      ;; GET /v1/accounts
      ((and (eq method :get) (%path= path "/v1/accounts"))
       (when (%require-admin)
         (let ((accts '()))
           (maphash (lambda (k v)
                      (declare (ignore k))
                      (push (%account->hash v) accts))
                    *mock-accounts*)
           (%send-json 200 (%yason-encode accts)))))

      ;; POST /v1/accounts
      ((and (eq method :post) (%path= path "/v1/accounts"))
       (when (%require-admin)
         (let* ((body    (%read-json-body))
                (uname   (and body (gethash "username"     body)))
                (atype   (and body (gethash "account_type" body)))
                (pass    (and body (gethash "password"     body))))
           (if (gethash uname *mock-by-name*)
               (%send-error 409 "username already exists")
               (let ((id (add-mock-account uname (or pass "nopass") (or atype "user"))))
                 (%send-json 201 (%yason-encode (%account->hash (gethash id *mock-accounts*)))))))))

      ;; DELETE /v1/token/:jti
      ((and (eq method :delete) (%path-prefix-p path "/v1/token/"))
       (let ((jti (subseq path (length "/v1/token/"))))
         (remhash jti *mock-tokens*)
         (setf (gethash jti *mock-revoked*) t))
       (%send-no-content))

      ;; GET /v1/accounts/:id
      ((and (eq method :get)
            (%path-prefix-p path "/v1/accounts/")
            ;; Make sure it's not /v1/accounts/:id/roles or /pgcreds
            (not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
       (when (%require-admin)
         (let* ((id   (subseq path (length "/v1/accounts/")))
                (acct (gethash id *mock-accounts*)))
           (if acct
               (%send-ok (%account->hash acct))
               (%send-error 404 "account not found")))))

      ;; GET /v1/accounts/:id/roles
      ((and (eq method :get)
            (cl-ppcre:scan "^/v1/accounts/[^/]+/roles$" path))
       (when (%require-admin)
         (let* ((parts   (cl-ppcre:split "/" path))
                (id      (nth 3 parts))
                (acct    (gethash id *mock-accounts*))
                (ht      (make-hash-table :test 'equal)))
           (if acct
               (progn
                 (setf (gethash "roles" ht) (getf acct :roles))
                 (%send-ok ht))
               (%send-error 404 "account not found")))))

      ;; PUT /v1/accounts/:id/roles
      ((and (eq method :put)
            (cl-ppcre:scan "^/v1/accounts/[^/]+/roles$" path))
       (when (%require-admin)
         (let* ((parts  (cl-ppcre:split "/" path))
                (id     (nth 3 parts))
                (acct   (gethash id *mock-accounts*))
                (body   (%read-json-body))
                (roles  (and body (gethash "roles" body))))
           (if acct
               (progn
                 (setf (getf (gethash id *mock-accounts*) :roles) roles)
                 (%send-no-content))
               (%send-error 404 "account not found")))))

      ;; PUT /v1/accounts/:id/pgcreds
      ((and (eq method :put)
            (cl-ppcre:scan "^/v1/accounts/[^/]+/pgcreds$" path))
       (when (%require-admin)
         (let* ((parts  (cl-ppcre:split "/" path))
                (id     (nth 3 parts))
                (body   (%read-json-body)))
           (if (gethash id *mock-accounts*)
               (progn
                 (setf (gethash id *mock-pgcreds*) body)
                 (%send-no-content))
               (%send-error 404 "account not found")))))

      ;; GET /v1/accounts/:id/pgcreds
      ((and (eq method :get)
            (cl-ppcre:scan "^/v1/accounts/[^/]+/pgcreds$" path))
       (when (%require-admin)
         (let* ((parts  (cl-ppcre:split "/" path))
                (id     (nth 3 parts))
                (creds  (gethash id *mock-pgcreds*)))
           (if creds
               (%send-ok creds)
               (%send-error 404 "no pgcreds for account")))))

      ;; PATCH /v1/accounts/:id
      ((and (eq method :patch)
            (%path-prefix-p path "/v1/accounts/")
            (not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
       (when (%require-admin)
         (let* ((id    (subseq path (length "/v1/accounts/")))
                (acct  (gethash id *mock-accounts*))
                (body  (%read-json-body)))
           (if acct
               (progn
                 (when (and body (gethash "status" body))
                   (setf (getf (gethash id *mock-accounts*) :status)
                         (gethash "status" body)))
                 (%send-ok (%account->hash (gethash id *mock-accounts*))))
               (%send-error 404 "account not found")))))

      ;; DELETE /v1/accounts/:id
      ((and (eq method :delete)
            (%path-prefix-p path "/v1/accounts/")
            (not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
       (when (%require-admin)
         (let* ((id   (subseq path (length "/v1/accounts/")))
                (acct (gethash id *mock-accounts*)))
           (if acct
               (progn
                 (remhash id *mock-accounts*)
                 (maphash (lambda (k v)
                            (when (string= v id)
                              (remhash k *mock-by-name*)))
                          *mock-by-name*)
                 (%send-no-content))
               (%send-error 404 "account not found")))))

      ;; POST /v1/token/issue
      ((and (eq method :post) (%path= path "/v1/token/issue"))
       (when (%require-admin)
         (let* ((body    (%read-json-body))
                (acct-id (and body (gethash "account_id" body))))
           (if (and acct-id (gethash acct-id *mock-accounts*))
               (let* ((token (%issue-mock-token acct-id))
                      (ht    (make-hash-table :test 'equal)))
                 (setf (gethash "token"      ht) token
                       (gethash "expires_at" ht) "2099-01-01T00:00:00Z")
                 (%send-ok ht))
               (%send-error 404 "account not found")))))

      ;; Catch-all
      (t
       (%send-error 404 (format nil "not found: ~A ~A" method path))))))

;;;; -----------------------------------------------------------------------
;;;; Start/stop
;;;; -----------------------------------------------------------------------

(defun start-mock-server (&key (port 0))
  "Start the mock MCIAS server on PORT (0 = OS-assigned).
Returns the actual port bound."
  (reset-mock-state!)
  ;; Seed an admin account.
  (add-mock-account "admin" "adminpass" "admin" "admin")
  (let ((acceptor (make-instance 'mock-dispatcher
                                 :port port
                                 :access-log-destination nil
                                 :message-log-destination nil)))
    (hunchentoot:start acceptor)
    (setf *mock-server* acceptor)
    (hunchentoot:acceptor-port acceptor)))

(defun stop-mock-server ()
  "Stop the running mock server."
  (when *mock-server*
    (hunchentoot:stop *mock-server*)
    (setf *mock-server* nil)))
