;;; hermes-dashboard-tests.el --- dashboard tests for hermes-el  -*- lexical-binding: t; -*-

;;; Code:

(require 'ert)
(require 'hermes-test-helpers)

;;; Instance selection

(ert-deftest hermes-instance-configured-falls-back-to-dashboard-url ()
  "An empty instance list preserves the configured dashboard URL."
  (let ((hermes-instances nil)
        (hermes-dashboard-transport-url "http://127.0.0.1:9119"))
    (should (equal (hermes-instance-configured)
                   '(("default" . "http://127.0.0.1:9119"))))))

(ert-deftest hermes-instance-resolve-uses-buffer-context ()
  "A buffer-owned instance wins without prompting."
  (let ((hermes-instances '(("local" . "http://127.0.0.1:9119")
                            ("remote" . "https://hermes.example.test")))
        (hermes-instance '("remote" . "https://hermes.example.test")))
    (cl-letf (((symbol-function 'completing-read)
               (lambda (&rest _) (ert-fail "Unexpected instance prompt"))))
      (should (equal (hermes-instance-resolve) hermes-instance)))))

(ert-deftest hermes-instance-resolve-uses-only-configured-instance ()
  "One configured instance is selected without prompting."
  (let ((hermes-instances '(("remote" . "https://hermes.example.test")))
        (hermes-instance nil))
    (cl-letf (((symbol-function 'completing-read)
               (lambda (&rest _) (ert-fail "Unexpected instance prompt"))))
      (should (equal (hermes-instance-resolve)
                     '("remote" . "https://hermes.example.test"))))))

(ert-deftest hermes-instance-resolve-prompts-when-context-is-ambiguous ()
  "Multiple configured instances prompt when the buffer owns none."
  (let ((hermes-instances '(("local" . "http://127.0.0.1:9119")
                            ("remote" . "https://hermes.example.test")))
        (hermes-instance nil)
        seen)
    (cl-letf (((symbol-function 'completing-read)
               (lambda (prompt collection &rest _)
                 (setq seen (list prompt collection))
                 "remote")))
      (should (equal (hermes-instance-resolve)
                     '("remote" . "https://hermes.example.test")))
      (should (equal seen
                     '("Hermes instance: " ("local" "remote")))))))

(ert-deftest hermes-instance-context-does-not-prompt-when-ambiguous ()
  "Passive context lookup returns nil when multiple instances need a choice."
  (let ((hermes-instances '(("local" . "http://127.0.0.1:9119")
                            ("remote" . "https://hermes.example.test")))
        (hermes-instance nil))
    (cl-letf (((symbol-function 'completing-read)
               (lambda (&rest _) (ert-fail "Unexpected instance prompt"))))
      (should-not (hermes-instance-context)))))

(ert-deftest hermes-instance-context-uses-the-only-instance ()
  "Passive context lookup returns the sole configured instance."
  (let ((hermes-instances '(("remote" . "https://hermes.example.test")))
        (hermes-instance nil))
    (should (equal (hermes-instance-context)
                   '("remote" . "https://hermes.example.test")))))

(ert-deftest hermes-dashboard-profile-cache-follows-buffer-instance ()
  "Profile completion reads the cache owned by the current buffer instance."
  (let ((hermes-instances '(("local" . "http://127.0.0.1:9119")
                            ("remote" . "https://hermes.example.test")))
        (hermes-instance '("remote" . "https://hermes.example.test"))
        (hermes-dashboard-transport-url "http://127.0.0.1:9119")
        (hermes-dashboard-transport--profile-cache
         '(("https://hermes.example.test" . ((profiles . (((name . "work"))))))
           ("http://127.0.0.1:9119" . ((profiles . (((name . "default")))))))))
    (should (equal (hermes-dashboard-transport-cached-profile-list)
                   '((profiles . (((name . "work")))))))))

(ert-deftest hermes-dashboard-http-json-async-forwards-request-timeout ()
  "The JSON request seam receives a caller-specific timeout."
  (let (arguments)
    (let ((hermes-dashboard-transport-http-request-async-function
           (lambda (_url &rest args)
             (setq arguments args)
             (hermes--promise-resolved '(:status 200 :body ((ok . t)))))))
      (hermes-dashboard-transport--http-json-async
       "http://example.test/slow" :method "POST" :timeout 300)
      (should (equal (plist-get arguments :timeout) 300)))))

(ert-deftest hermes-dashboard-http-json-async-omits-nil-request-timeout ()
  "Existing request seams receive no new keyword without an override."
  (let ((hermes-dashboard-transport-http-request-async-function
         (cl-function
          (lambda (_url &key method headers data secrets)
            (ignore method headers data secrets)
            (hermes--promise-resolved '(:status 200 :body ((ok . t))))))))
    (should (hermes--promise-p
             (hermes-dashboard-transport--http-json-async
              "http://example.test/fast")))))

(ert-deftest hermes-dashboard-api-request-async-forwards-request-timeout ()
  "Authenticated async REST requests retain a caller-specific timeout."
  (let ((hermes-dashboard-transport-url "http://example.test")
        (hermes-dashboard-transport--api-auth
         '(:base-url "http://example.test" :headers nil :secrets nil))
        request)
    (cl-letf (((symbol-function
                'hermes-dashboard-transport--http-json-request-async)
               (lambda (value)
                 (setq request value)
                 (hermes--promise-resolved '(:status 200 :body ((ok . t)))))))
      (hermes-dashboard-transport-api-request-async
       "POST" "/slow" :timeout 300)
      (should (equal (plist-get request :timeout) 300)))))

(ert-deftest hermes-dashboard-api-request-async-uses-client-url-without-token ()
  "A tokenless client pins the URL while REST auth supplies credentials."
  (let ((hermes-dashboard-transport-url "http://127.0.0.1:9119")
        (client (make-hermes-dashboard-transport-client
                 :base-url "https://hermes.example.test"))
        auth-url request)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-api-auth-async)
               (lambda (&optional _refresh)
                 (setq auth-url hermes-dashboard-transport-url)
                 (hermes--promise-resolved
                  `(:base-url ,hermes-dashboard-transport-url
                    :headers (("Authorization" . "Bearer bearer"))))))
              ((symbol-function
                'hermes-dashboard-transport--http-json-request-async)
               (lambda (value)
                 (setq request value)
                 (hermes--promise-resolved '(:status 200 :body ((ok . t)))))))
      (hermes-dashboard-transport-api-request-async
       "GET" "/api/status" :client client)
      (should (equal auth-url "https://hermes.example.test"))
      (should (equal (plist-get request :url)
                     "https://hermes.example.test/api/status"))
      (should (equal (alist-get "Authorization" (plist-get request :headers)
                                nil nil #'equal)
                     "Bearer bearer")))))

(ert-deftest hermes-dashboard-api-request-async-retry-keeps-client-url ()
  "A tokenless client's URL survives REST re-authentication after HTTP 401."
  (let ((hermes-dashboard-transport-url "http://127.0.0.1:9119")
        (client (make-hermes-dashboard-transport-client
                 :base-url "https://hermes.example.test"))
        auth-urls request-urls)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-api-auth-async)
               (lambda (&optional _refresh)
                 (push hermes-dashboard-transport-url auth-urls)
                 (hermes--promise-resolved
                  `(:base-url ,hermes-dashboard-transport-url))))
              ((symbol-function
                'hermes-dashboard-transport--http-json-request-async)
               (lambda (request)
                 (push (plist-get request :url) request-urls)
                 (if (cdr request-urls)
                     (hermes--promise-resolved
                      '(:status 200 :body ((ok . t))))
                   (hermes--promise-rejected "Request failed (HTTP 401)")))))
      (hermes-dashboard-transport-api-request-async
       "GET" "/api/status" :client client)
      (should (equal (reverse auth-urls)
                     '("https://hermes.example.test"
                       "https://hermes.example.test")))
      (should (equal (reverse request-urls)
                     '("https://hermes.example.test/api/status"
                       "https://hermes.example.test/api/status"))))))

(ert-deftest hermes-dashboard-api-request-async-retry-keeps-selected-url ()
  "A selected URL survives async REST re-authentication after HTTP 401."
  (let ((hermes-dashboard-transport-url "http://legacy.example.test")
        (first-request (hermes--promise-make))
        auth-urls request-urls)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-api-auth-async)
               (lambda (&optional _refresh)
                 (push hermes-dashboard-transport-url auth-urls)
                 (hermes--promise-resolved
                  `(:base-url ,hermes-dashboard-transport-url))))
              ((symbol-function
                'hermes-dashboard-transport--http-json-request-async)
               (lambda (request)
                 (push (plist-get request :url) request-urls)
                 (if (cdr request-urls)
                     (hermes--promise-resolved
                      '(:status 200 :body ((ok . t))))
                   first-request))))
      (let ((hermes-dashboard-transport-url
             "https://selected.example.test"))
        (hermes-dashboard-transport-api-request-async
         "GET" "/api/status"))
      (hermes--promise-reject first-request "Request failed (HTTP 401)")
      (should (equal (reverse auth-urls)
                     '("https://selected.example.test"
                       "https://selected.example.test")))
      (should (equal (reverse request-urls)
                     '("https://selected.example.test/api/status"
                       "https://selected.example.test/api/status"))))))

(ert-deftest hermes-dashboard-transport-call-resolves-on-result ()
  "A call resolves its promise with the JSON-RPC result for the matching id."
  (let* ((client (hermes-test--dashboard-client))
         last-frame resolved
         (hermes-dashboard-transport-websocket-send-function
          (lambda (_ws text)
            (setq last-frame (hermes-dashboard-transport--decode-frame text)))))
    (hermes--promise-then
     (hermes-dashboard-transport-call client "session.list" '((cols . 80)))
     (lambda (result) (setq resolved result)))
    (should (equal (alist-get 'method last-frame) "session.list"))
    (hermes-dashboard-transport--handle-frame
     client
     `((jsonrpc . "2.0") (id . ,(alist-get 'id last-frame)) (result . "ok")))
    (should (equal resolved "ok"))))

(ert-deftest hermes-dashboard-transport-call-rejects-on-error ()
  "A call rejects its promise with the error message for the matching id."
  (let* ((client (hermes-test--dashboard-client))
         last-frame rejected
         (hermes-dashboard-transport-websocket-send-function
          (lambda (_ws text)
            (setq last-frame (hermes-dashboard-transport--decode-frame text)))))
    (hermes--promise-catch
     (hermes-dashboard-transport-call client "session.create" nil)
     (lambda (reason) (setq rejected reason)))
    (hermes-dashboard-transport--handle-frame
     client
     `((jsonrpc . "2.0") (id . ,(alist-get 'id last-frame))
       (error . ((message . "boom")))))
    (should (string-match-p "boom" rejected))))

(ert-deftest hermes-dashboard-transport-call-rejects-on-timeout ()
  "A call rejects its promise when the request times out."
  (let* ((client (hermes-test--dashboard-client))
         last-frame rejected
         (hermes-dashboard-transport-websocket-send-function
          (lambda (_ws text)
            (setq last-frame (hermes-dashboard-transport--decode-frame text)))))
    (hermes--promise-catch
     (hermes-dashboard-transport-call client "session.list" nil)
     (lambda (reason) (setq rejected reason)))
    (hermes-dashboard-transport--on-request-timeout
     client (alist-get 'id last-frame))
    (should (string-match-p "timed out" rejected))))

(ert-deftest hermes-dashboard-transport-tools-configure-sends-action-payload ()
  "The transport wrapper sends `tools.configure' names/action/session_id."
  :tags '(shared-socket-isolation)
  (let (method params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((ok . t))))))
      (let ((client (hermes-test--dashboard-client)))
        (hermes-dashboard-transport-tools-configure
         client '("terminal") "disable"
         :session-id "sid-1" :resolve #'ignore :reject #'ignore))
      (should (equal method "tools.configure"))
      (should (equal (cdr (assq 'names params)) '("terminal")))
      (should (equal (cdr (assq 'action params)) "disable"))
      (should (equal (cdr (assq 'session_id params)) "sid-1")))))

(ert-deftest hermes-dashboard-transport-skills-reload-sends-rpc ()
  "The transport wrapper sends `skills.reload' without shelling out."
  (let (method params resolved)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((output . "ok"))))))
      (hermes-dashboard-transport-skills-reload
       'fake-client
       :resolve (lambda (result) (setq resolved result))
       :reject #'ignore)
      (should (equal method "skills.reload"))
      (should-not params)
      (should (equal resolved '((output . "ok")))))))

(ert-deftest hermes-dashboard-transport-handoff-request-sends-platform ()
  "The transport wrapper sends `handoff.request' with platform and session_id."
  :tags '(shared-socket-isolation)
  (let (method params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((queued . t))))))
      (let ((client (hermes-test--dashboard-client)))
        (hermes-dashboard-transport-handoff-request
         client "telegram"
         :session-id "sid-1" :resolve #'ignore :reject #'ignore))
      (should (equal method "handoff.request"))
      (should (equal (cdr (assq 'platform params)) "telegram"))
      (should (equal (cdr (assq 'session_id params)) "sid-1")))))

(ert-deftest hermes-dashboard-transport-handoff-state-sends-session ()
  "The transport wrapper sends `handoff.state' scoped to the session."
  :tags '(shared-socket-isolation)
  (let (method params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((state . "pending"))))))
      (let ((client (hermes-test--dashboard-client)))
        (hermes-dashboard-transport-handoff-state
         client :session-id "sid-2" :resolve #'ignore :reject #'ignore))
      (should (equal method "handoff.state"))
      (should (equal (cdr (assq 'session_id params)) "sid-2")))))

(ert-deftest hermes-dashboard-transport-handoff-fail-sends-error ()
  "The transport wrapper sends `handoff.fail' with the error reason."
  :tags '(shared-socket-isolation)
  (let (method params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((failed . t))))))
      (let ((client (hermes-test--dashboard-client)))
        (hermes-dashboard-transport-handoff-fail
         client :error "poll timed out"
         :session-id "sid-3" :resolve #'ignore :reject #'ignore))
      (should (equal method "handoff.fail"))
      (should (equal (cdr (assq 'error params)) "poll timed out"))
      (should (equal (cdr (assq 'session_id params)) "sid-3")))))

(ert-deftest hermes-dashboard-transport-complete-slash-sends-text ()
  "The transport wrapper sends `complete.slash' with the partial command text."
  (let (method params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client m p resolve _reject)
                 (setq method m params p)
                 (funcall resolve '((items . []))))))
      (hermes-dashboard-transport-complete-slash
       'fake-client "/handoff " :resolve #'ignore :reject #'ignore)
      (should (equal method "complete.slash"))
      (should (equal (cdr (assq 'text params)) "/handoff ")))))

(ert-deftest hermes-dashboard-rpc-wrapper-contracts-cover-current-features ()
  "Less common feature wrappers preserve upstream methods and parameter names."
  (let (requests)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client method params &rest _)
                 (push (cons method params) requests))))
      (hermes-dashboard-transport-setup-status 'client)
      (hermes-dashboard-transport-rollback-restore
       'client "checkpoint" :session-id "sid" :file-path "notes.org")
      (hermes-dashboard-transport-subagent-interrupt 'client "child")
      (hermes-dashboard-transport-cron-manage
       'client :action "pause" :name "nightly")
      (hermes-dashboard-transport-prompt-background
       'client "audit" :session-id "sid")
      (hermes-dashboard-transport-session-status 'client :session-id "sid"))
    (should
     (equal (nreverse requests)
            '(("setup.status")
              ("rollback.restore" (hash . "checkpoint")
               (session_id . "sid") (file_path . "notes.org"))
              ("subagent.interrupt" (subagent_id . "child"))
              ("cron.manage" (action . "pause") (name . "nightly"))
              ("prompt.background" (session_id . "sid") (text . "audit"))
              ("session.status" (session_id . "sid")))))))

(ert-deftest hermes-dashboard-active-profile-uses-authenticated-rest-client ()
  "Active-profile lookup uses the exact upstream route and supplied client."
  (let (request)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-api-request)
               (lambda (method path &rest args)
                 (setq request (list method path (plist-get args :client)))
                 '((active . "default") (current . "work")))))
      (should
       (equal (hermes-dashboard-transport-active-profile 'client)
              '((active . "default") (current . "work")))))
    (should (equal request '("GET" "/api/profiles/active" client)))))

;;; Group: kanban events WS-URL plumbing

(ert-deftest hermes-dashboard-websocket-endpoint-for-parameterizes-path ()
  "The path-parameterized endpoint builds ws/wss URLs; the legacy one is /api/ws."
  (should (equal "ws://127.0.0.1:8765/api/plugins/kanban/events"
                 (hermes-dashboard-transport--websocket-endpoint-for
                  "127.0.0.1" 8765 "/api/plugins/kanban/events")))
  (should (string-prefix-p
           "wss://example.com"
           (hermes-dashboard-transport--websocket-endpoint-for
            "example.com" nil "/api/plugins/kanban/events" "https://example.com")))
  (should (equal "ws://127.0.0.1:8765/api/ws"
                 (hermes-dashboard-transport--websocket-endpoint
                  "127.0.0.1" 8765))))

(ert-deftest hermes-dashboard-swap-websocket-path-preserves-query ()
  "Swapping the endpoint path leaves the credential query untouched."
  (should (equal "ws://h:1/api/plugins/kanban/events?token=ABC"
                 (hermes-dashboard-transport--swap-websocket-path
                  "ws://h:1/api/ws?token=ABC" "/api/plugins/kanban/events")))
  (should (equal "ws://h:1/api/plugins/kanban/events?ticket=XYZ"
                 (hermes-dashboard-transport--swap-websocket-path
                  "ws://h:1/api/ws?ticket=XYZ" "/api/plugins/kanban/events"))))

(ert-deftest hermes-dashboard-append-url-query-drops-nil-and-escapes ()
  "Nil-valued params are dropped; values are percent-encoded."
  (should (equal "u&since=5&board=emacs%20lisp"
                 (hermes-dashboard-transport--append-url-query
                  "u" '((since . 5) (board . "emacs lisp")))))
  (should (equal "u&since=0"
                 (hermes-dashboard-transport--append-url-query
                  "u" '((since . 0) (board . nil))))))

(ert-deftest hermes-dashboard-kanban-events-plist-swaps-and-redacts ()
  "The events plist swaps the path, appends since/board, and never leaks a secret."
  (let ((plist (hermes-dashboard-transport--kanban-events-plist
                '(:url "ws://h:1/api/ws?token=SEKRIT"
                  :redacted-url "ws://h:1/api/ws?token=<redacted>"
                  :secrets ("SEKRIT"))
                5 "emacs-lisp")))
    (should (equal (concat "ws://h:1/api/plugins/kanban/events?token=SEKRIT"
                           "&since=5&board=emacs-lisp")
                   (plist-get plist :url)))
    (should-not (string-match-p "SEKRIT" (plist-get plist :redacted-url)))
    (should (string-search "kanban/events?token=<redacted>"
                           (plist-get plist :redacted-url)))
    (should (equal '("SEKRIT") (plist-get plist :secrets)))))

(ert-deftest hermes-dashboard-kanban-events-url-async-reuses-client ()
  "A live client's resolved URL is reused without a fresh auth round-trip."
  (let ((client (make-hermes-dashboard-transport-client
                 :credential-kind 'legacy-token :credential-reusable-p t
                 :websocket-url "ws://127.0.0.1:8765/api/ws?token=SEKRIT"
                 :redacted-websocket-url "ws://127.0.0.1:8765/api/ws?token=<redacted>"
                 :secrets '("SEKRIT")))
        result)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
               (lambda (&rest _) (error "must not resolve fresh auth"))))
      (hermes--promise-then
       (hermes-dashboard-transport-kanban-events-url-async
        :since 9 :board "emacs-lisp" :client client)
       (lambda (v) (setq result v))))
    (should (equal (concat "ws://127.0.0.1:8765/api/plugins/kanban/events"
                           "?token=SEKRIT&since=9&board=emacs-lisp")
                   (plist-get result :url)))
    (should (equal '("SEKRIT") (plist-get result :secrets)))))

(ert-deftest hermes-dashboard-credential-reuse-is-explicit-and-fail-closed ()
  "Only explicitly reusable credential kinds expose active socket auth."
  :tags '(candidate-4a)
  (dolist (case '((nil t nil) (ticket t nil) (spawn nil nil)
                  (spawn t t) (internal t t) (legacy-token t t)))
    (let ((client (make-hermes-dashboard-transport-client
                   :credential-kind (nth 0 case)
                   :credential-reusable-p (nth 1 case)
                   :websocket-url "ws://h/api/ws?token=active"
                   :redacted-websocket-url "ws://h/api/ws?token=<redacted>"
                   :secrets '("active"))))
      (should (eq (and (hermes-dashboard-transport--client-auth-plist client) t)
                  (nth 2 case))))))

(ert-deftest hermes-dashboard-nonreusable-auxiliary-auth-resolves-fresh ()
  "Nil-kind and ticket clients resolve from stored inputs, never active auth."
  :tags '(candidate-4a)
  (dolist (kind '(nil ticket))
    (let ((client (make-hermes-dashboard-transport-client
                   :host "dash.example" :port 443
                   :base-url "https://dash.example"
                   :auth-method 'token :auth-token "resolver"
                   :credential-kind kind :credential-reusable-p t
                   :websocket-url "wss://dash.example/api/ws?ticket=consumed"
                   :secrets '("consumed")))
          call result)
      (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
                 (lambda (&rest args)
                   (setq call args)
                   (hermes--promise-resolved
                    '(:kind legacy-token :reusable-p t
                      :url "wss://dash.example/api/ws?token=fresh"
                      :redacted-url "wss://dash.example/api/ws?token=<redacted>"
                      :secrets ("fresh"))))))
        (hermes--promise-then
         (hermes-dashboard-transport-capability-url-async :client client)
         (lambda (value) (setq result value))))
      (should (equal call
                     '("dash.example" 443 "https://dash.example"
                       token "resolver" nil)))
      (should (string-suffix-p "token=fresh" (plist-get result :url)))
      (should-not (string-match-p "consumed" (format "%S" result))))))

(ert-deftest hermes-dashboard-auth-results-classify-credential-lifetime ()
  "Token auth is reusable while basic/native tickets are explicitly one-shot."
  :tags '(candidate-4a)
  (let ((token (hermes-dashboard-transport--remote-token-auth
                "h" 1 "http://h:1" "legacy"))
        (basic (hermes-dashboard-transport--basic-ticket-auth
                "h" 1 "http://h:1" "password" "cookie"
                '(:body ((ticket . "basic-ticket")))))
        (native (hermes-dashboard-transport--native-ticket-auth
                 "h" 1 "http://h:1"
                 '(:access-token "access" :refresh-token "refresh")
                 '(:body ((ticket . "native-ticket"))))))
    (should (equal (list (plist-get token :kind) (plist-get token :reusable-p))
                   '(legacy-token t)))
    (dolist (auth (list basic native))
      (should (equal (list (plist-get auth :kind) (plist-get auth :reusable-p))
                     '(ticket nil))))))

(ert-deftest hermes-dashboard-native-auth-requires-explicit-browser-authority ()
  "Automatic native auth stays headless; one interactive call may browse once."
  :tags '(candidate-4a)
  (let ((browsed 0) automatic-reason interactive-result)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--native-token-load)
               (lambda (_base) nil))
              ((symbol-function 'hermes-dashboard-transport--native-token-store)
               (lambda (_base tokens &optional _owner-current-p) tokens))
              ((symbol-function 'hermes-dashboard-transport--native-login-async)
               (lambda (_base _provider &optional _cancel-setter _owner-current-p)
                 (hermes-dashboard-transport--browse-url "https://login.example")
                 (hermes--promise-resolved
                  '(:access-token "access" :refresh-token "refresh")))))
      (let ((hermes-dashboard-transport-browse-url-function
             (lambda (_url) (cl-incf browsed))))
        (hermes--promise-then
         (hermes-dashboard-transport--native-ensure-tokens-async
          "https://dash.example" nil nil nil)
         #'ignore (lambda (reason) (setq automatic-reason reason)))
        (hermes--promise-then
         (hermes-dashboard-transport--native-ensure-tokens-async
          "https://dash.example" nil nil t)
         (lambda (tokens) (setq interactive-result tokens)))))
    (should automatic-reason)
    (should interactive-result)
    (should (= browsed 1))))

(ert-deftest hermes-dashboard-kanban-events-url-async-resolves-fresh ()
  "Without a client, auth resolves against the configured URL and is path-swapped."
  (let ((hermes-dashboard-transport-url "http://127.0.0.1:8765")
        result)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
               (lambda (&rest _)
                 (hermes--promise-resolved
                  '(:url "ws://127.0.0.1:8765/api/ws?token=SEKRIT"
                    :redacted-url "ws://127.0.0.1:8765/api/ws?token=<redacted>"
                    :secrets ("SEKRIT"))))))
      (hermes--promise-then
       (hermes-dashboard-transport-kanban-events-url-async :board "emacs-lisp")
       (lambda (v) (setq result v))))
    (should (string-search "/api/plugins/kanban/events?token=SEKRIT"
                           (plist-get result :url)))
    (should (string-match-p "board=emacs-lisp" (plist-get result :url)))
    (should-not (string-match-p "SEKRIT" (plist-get result :redacted-url)))))

(ert-deftest hermes-dashboard-open-websocket-delivers-text-and-redacts-errors ()
  "The opener hands frame text to :on-message and scrubs secrets from errors."
  (let (msg err)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--require-websocket)
               #'ignore)
              ((symbol-function 'websocket-frame-text) (lambda (f) f))
              ((symbol-function 'websocket-open)
               (lambda (_url &rest args)
                 (funcall (plist-get args :on-message) nil "{\"events\":[]}")
                 (funcall (plist-get args :on-error) nil 'on-error "boom SEKRIT")
                 'fake-socket)))
      (let ((socket (hermes-dashboard-transport-open-websocket
                     "ws://h/api/plugins/kanban/events?token=SEKRIT"
                     "ws://h/api/plugins/kanban/events?token=<redacted>"
                     '("SEKRIT")
                     :on-message (lambda (text) (setq msg text))
                     :on-error (lambda (m) (setq err m)))))
        (should (eq socket 'fake-socket))
        (should (equal msg "{\"events\":[]}"))
        (should (stringp err))
        (should-not (string-match-p "SEKRIT" err))))))

;;; Group: startup viability

(ert-deftest hermes-dashboard-transport-spawn-return-is-stale-after-starting-stop ()
  "A synchronous starting callback cannot attach a returned process."
  (let* ((dispatch (symbol-function
                    'hermes-dashboard-transport--dispatch-event))
         (hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-ready-timeout 15)
         (hermes-dashboard-transport-make-process-function
          (lambda (&rest _) 'returned-process))
         (hermes-dashboard-transport-websocket-open-function
          (lambda (&rest _) (ert-fail "Terminal spawn opened a socket")))
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (ert-fail "Terminal spawn armed a timeout")))
         client ready returned deleted)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--dispatch-event)
               (lambda (current event)
                 (setq client current
                       ready (hermes-dashboard-transport-client-ready-promise
                              current))
                 (funcall dispatch current event)))
              ((symbol-function 'delete-process)
               (lambda (process)
                 (push process deleted)
                 (signal 'quit nil))))
      (setq returned
            (hermes-dashboard-transport-start
             :start-mode 'spawn :host "127.0.0.1" :port 9119
             :token "token"
             :callback
             (lambda (_event)
               (hermes-dashboard-transport-stop client)))))
    (should (eq returned client))
    (should (hermes-dashboard-transport-client-stopping-p client))
    (should (eq (hermes--promise-state ready) 'rejected))
    (should (equal deleted '(returned-process)))
    (should-not (hermes-dashboard-transport-client-process client))
    (should-not (hermes-dashboard-transport-client-websocket client))
    (should-not (hermes-dashboard-transport-client-idle-timer client))
    (should-not (hermes-dashboard-transport-client-heartbeat-timer client))
    (should-not (gethash '(spawn "127.0.0.1" 9119)
                         hermes-dashboard-transport--clients))))

(ert-deftest hermes-dashboard-transport-socket-return-is-stale-after-open-stop ()
  "A synchronous socket opener cannot attach its return after stopping."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-ready-timeout 15)
         scheduled client ready returned closed
         (hermes-dashboard-transport-schedule-function
          (lambda (delay &rest _) (push delay scheduled) 'timer))
         (hermes-dashboard-transport-websocket-open-function
          (lambda (_url current)
            (setq client current
                  ready (hermes-dashboard-transport-client-ready-promise current))
            (hermes-dashboard-transport-stop current)
            'returned-socket)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
               (lambda (&rest _)
                 (hermes--promise-resolved
                  '(:token "ticket" :url "wss://dash.example/ws?token=ticket"
                    :redacted-url "wss://dash.example/ws?token=<redacted>"
                    :secrets ("ticket") :kind ticket :reusable-p nil))))
              ((symbol-function 'websocket-close)
               (lambda (socket)
                 (push socket closed)
                 (error "close failed"))))
      (setq returned
            (hermes-dashboard-transport-start
             :start-mode 'remote :remote-url "https://dash.example")))
    (should (eq returned client))
    (should (hermes-dashboard-transport-client-stopping-p client))
    (should (eq (hermes--promise-state ready) 'rejected))
    (should (equal closed '(returned-socket)))
    (should-not scheduled)
    (should-not (hermes-dashboard-transport-client-process client))
    (should-not (hermes-dashboard-transport-client-websocket client))
    (should-not (hermes-dashboard-transport-client-idle-timer client))
    (should-not (hermes-dashboard-transport-client-heartbeat-timer client))
    (should-not (gethash "https://dash.example"
                         hermes-dashboard-transport--clients))))

(ert-deftest hermes-dashboard-transport-socket-stop-then-error-does-not-retry ()
  "A socket callback that stops before an error cannot schedule stale retry."
  (let* ((hermes-dashboard-transport-connect-retries 3)
         (hermes-dashboard-transport-ready-timeout nil)
         scheduled client)
    (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
               (lambda (&rest _)
                 (hermes--promise-resolved
                  '(:token "ticket" :url "wss://dash.example/ws?token=ticket"
                    :redacted-url "wss://dash.example/ws?token=<redacted>"
                    :secrets ("ticket") :kind ticket :reusable-p nil))))
              ((symbol-function 'hermes-dashboard-transport--open-websocket-once)
               (lambda (current _url)
                 (setq client current)
                 (hermes-dashboard-transport-stop current)
                 (error "open failed")))
              ((symbol-function 'hermes-dashboard-transport--schedule)
               (lambda (&rest args) (push args scheduled))))
      (should (eq (hermes-dashboard-transport-start
                   :start-mode 'remote :remote-url "https://dash.example")
                  client)))
    (should (hermes-dashboard-transport-client-stopping-p client))
    (should-not scheduled)))

(ert-deftest hermes-dashboard-transport-acquire-rejects-synchronous-auth-stop ()
  "Shared acquire never publishes a client stopped by synchronous auth."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (stop (symbol-function 'hermes-dashboard-transport-stop))
         clients messages auth-tokens
         (auth-calls 0))
    (cl-letf (((symbol-function 'hermes-dashboard-transport--remote-auth-async)
               (lambda (_host _port _base _method token &rest _)
                 (push token auth-tokens)
                 (hermes--promise-rejected
                  (format "remote auth SECRET-%d rejected"
                          (cl-incf auth-calls)))))
              ((symbol-function 'hermes-dashboard-transport-stop)
               (lambda (client &rest args)
                 (push client clients)
                 (apply stop client args))))
      (dotimes (_ 2)
        (push (cadr (should-error
                     (hermes-dashboard-transport-acquire
                      :start-mode 'remote
                      :remote-url "https://dash.example")
                     :type 'user-error))
              messages)))
    (setq clients (nreverse clients)
          messages (nreverse messages)
          auth-tokens (nreverse auth-tokens))
    (should (= auth-calls 2))
    (should (equal auth-tokens '(nil nil)))
    (should (= (length clients) 2))
    (should-not (eq (car clients) (cadr clients)))
    (dolist (client clients)
      (should (hermes-dashboard-transport-client-stopping-p client))
      (should (eq (hermes--promise-state
                   (hermes-dashboard-transport-client-ready-promise client))
                  'rejected))
      (should (= (hermes-dashboard-transport-client-refcount client) 0))
      (should-not (hermes-dashboard-transport-client-endpoint-key client))
      (should-not (hermes-dashboard-transport-client-token client))
      (should-not (hermes-dashboard-transport-client-auth-token client))
      (should-not (hermes-dashboard-transport-client-secrets client)))
    (dolist (message messages)
      (should (equal message "Hermes dashboard transport failed during startup"))
      (should-not (string-match-p "SECRET" message)))
    (should-not (gethash "https://dash.example"
                         hermes-dashboard-transport--clients))))

;;; Group: terminal stop

(ert-deftest hermes-dashboard-transport-terminal-routes-detach-before-callbacks ()
  "Stop, readiness failure, and reconnect finalization detach before callbacks."
  (dolist (route '(stop fail-ready finalize))
    (dolist (callback-kind '(reject malformed-reject subscriber fallback))
      (let* ((hermes-dashboard-transport--clients
              (make-hash-table :test #'equal))
             (key '(spawn "127.0.0.1" 9119))
             (pending (make-hash-table :test #'equal))
             closed deleted callback-event facts replacement
             (old (make-hermes-dashboard-transport-client
                   :endpoint-key key :port 9119 :websocket 'old-ws
                   :process 'old-process :token "SECRET" :auth-token "SECRET"
                   :secrets '("SECRET") :websocket-url "ws://h?token=SECRET"
                   :credential-kind 'legacy-token :credential-reusable-p t
                   :ready-p t :reconnecting-p t :refcount 1 :pending pending)))
        (puthash key old hermes-dashboard-transport--clients)
        (let ((callback
               (lambda (&optional value)
                 (setq callback-event value
                       facts (list
                              (hermes-dashboard-transport-client-stopping-p old)
                              (hermes-dashboard-transport-client-ready-p old)
                              (hermes-dashboard-transport-client-reconnecting-p old)
                              (hermes-dashboard-transport-client-refcount old)
                              (eq (gethash key hermes-dashboard-transport--clients) old)
                              (hermes-dashboard-transport--client-auth-plist old)
                              (hermes-dashboard-transport-client-websocket-url old)
                              (hermes-dashboard-transport-client-credential-kind old)
                              (hermes-dashboard-transport-client-credential-reusable-p old)
                              (hermes-dashboard-transport-client-token old)
                              (hermes-dashboard-transport-client-auth-token old)
                              (hermes-dashboard-transport-client-secrets old)
                              (hermes-dashboard-transport-client-websocket old)
                              (hermes-dashboard-transport-client-process old)
                              (copy-sequence closed) (copy-sequence deleted))
                       replacement
                       (hermes-dashboard-transport-acquire :start-mode 'spawn)))))
          (pcase callback-kind
            ('reject
             (puthash "request" (list :method "test.method" :reject callback)
                      pending))
            ('malformed-reject
             (puthash "request" (list :method "test.method" :reject "bad") pending)
             (setf (hermes-dashboard-transport-client-callback old) callback))
            ('subscriber (hermes-dashboard-transport-subscribe old callback))
            ('fallback
             (setf (hermes-dashboard-transport-client-callback old) callback)))
          (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
                     (lambda (&rest _)
                       (make-hermes-dashboard-transport-client
                        :websocket 'replacement-ws)))
                    ((symbol-function 'websocket-close)
                     (lambda (resource) (push resource closed)))
                    ((symbol-function 'delete-process)
                     (lambda (resource) (push resource deleted))))
            (pcase route
              ('stop
               (hermes-dashboard-transport-stop
                old "terminal SECRET"
                '(:type status :status "closed" :content "SECRET")))
              ('fail-ready
               (hermes-dashboard-transport--fail-ready old "terminal SECRET"))
              ('finalize
               (hermes-dashboard-transport--finalize-reconnect
                old "terminal SECRET"))))
          (should (equal facts '(t nil nil 0 nil nil nil nil nil nil nil nil nil nil
                                  (old-ws) (old-process))))
          (should-not (eq replacement old))
          (should (eq (gethash key hermes-dashboard-transport--clients)
                      replacement))
          (should callback-event)
          (when (eq callback-kind 'malformed-reject)
            (should (eq (plist-get callback-event :type) 'error))
            (should (equal (plist-get callback-event :method) "test.method")))
          (should-not
           (string-match-p "SECRET"
                           (if (stringp callback-event) callback-event
                             (plist-get callback-event :content)))))))))

(ert-deftest hermes-dashboard-transport-stop-effects-survive-error-and-quit ()
  "Each terminal effect runs even when any earlier effect errors or quits."
  (dolist (condition '(error quit))
    (dolist (fail-at '(startup idle heartbeat request websocket process
                              event reject readiness))
      (let* ((pending (make-hash-table :test #'equal))
             (subscribers (make-hash-table :test #'eq))
             log escaped
             (effect (lambda (name)
                       (push name log)
                       (when (eq name fail-at)
                         (signal condition (list name)))))
             (client (make-hermes-dashboard-transport-client
                      :startup-cancel (lambda () (funcall effect 'startup))
                      :idle-timer 'idle :heartbeat-timer 'heartbeat
                      :websocket 'websocket :process 'process
                      :pending pending :subscribers subscribers
                      :ready-promise 'readiness)))
        (puthash "request"
                 (list :timer 'request
                       :reject (lambda (_) (funcall effect 'reject)))
                 pending)
        (puthash 'subscriber
                 (list :fn (lambda (_) (funcall effect 'event))) subscribers)
        (cl-letf (((symbol-function 'cancel-timer)
                   (lambda (timer) (funcall effect timer)))
                  ((symbol-function 'websocket-close)
                   (lambda (_) (funcall effect 'websocket)))
                  ((symbol-function 'delete-process)
                   (lambda (_) (funcall effect 'process)))
                  ((symbol-function 'hermes--promise-reject)
                   (lambda (&rest _) (funcall effect 'readiness))))
          (condition-case nil
              (hermes-dashboard-transport-stop
               client "terminal" '(:type status :content "terminal"))
            ((error quit) (setq escaped t))))
        (should-not escaped)
        (should (equal (nreverse log)
                       '(startup idle heartbeat request websocket process
                                 event reject readiness)))))))

(ert-deftest hermes-dashboard-transport-stop-tolerates-malformed-state ()
  "Malformed ownership tables cannot prevent exact cleanup or readiness rejection."
  (let* ((ready (hermes--promise-make))
         (client (make-hermes-dashboard-transport-client
                  :pending 'malformed :subscribers 'malformed
                  :session-index 'malformed :ready-promise ready
                  :websocket 'old-ws :process 'old-process))
         closed deleted (rejected 0))
    (hermes--promise-catch ready (lambda (_reason) (cl-incf rejected)))
    (cl-letf (((symbol-function 'websocket-close)
               (lambda (resource) (push resource closed)))
              ((symbol-function 'delete-process)
               (lambda (resource) (push resource deleted))))
      (should (eq (hermes-dashboard-transport-stop client "terminal") client)))
    (should-not (hermes-dashboard-transport-client-pending client))
    (should-not (hermes-dashboard-transport-client-subscribers client))
    (should-not (hermes-dashboard-transport-client-session-index client))
    (should (equal closed '(old-ws)))
    (should (equal deleted '(old-process)))
    (should (= rejected 1))))

;;; Group: reconnect

(ert-deftest hermes-dashboard-transport-socket-down-without-reconnect-stops-before-callbacks ()
  "Terminal socket loss detaches and closes before callback reentry."
  (dolist (callback-kind '(reject subscriber fallback))
    (let* ((hermes-dashboard-transport--clients
            (make-hash-table :test #'equal))
           (hermes-dashboard-transport-reconnect-max-attempts nil)
           (key '(spawn "127.0.0.1" 9119))
           (pending (make-hash-table :test #'equal))
           closed deleted order facts replacement
           (old (make-hermes-dashboard-transport-client
                 :endpoint-key key :websocket 'old-ws :process 'old-process
                 :refcount 1 :pending pending)))
      (puthash key old hermes-dashboard-transport--clients)
      (let ((reenter
             (lambda (&rest _)
               (push callback-kind order)
               (setq facts
                     (list (hermes-dashboard-transport-client-stopping-p old)
                           (eq (gethash key hermes-dashboard-transport--clients)
                               old)
                           (hermes-dashboard-transport-client-websocket old)
                           (hermes-dashboard-transport-client-process old)
                           (copy-sequence closed) (copy-sequence deleted))
                     replacement
                     (hermes-dashboard-transport-acquire :start-mode 'spawn)))))
        (puthash "request"
                 (list :method "test.method"
                       :reject (if (eq callback-kind 'reject)
                                   reenter
                                 (lambda (&rest _) (push 'pending order))))
                 pending)
        (pcase callback-kind
          ('subscriber (hermes-dashboard-transport-subscribe old reenter))
          ('fallback
           (setf (hermes-dashboard-transport-client-callback old) reenter)))
        (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
                   (lambda (&rest _)
                     (make-hermes-dashboard-transport-client
                      :websocket 'replacement-ws)))
                  ((symbol-function 'websocket-close)
                   (lambda (resource) (push resource closed)))
                  ((symbol-function 'delete-process)
                   (lambda (resource) (push resource deleted))))
          (hermes-dashboard-transport--handle-socket-down
           old "dropped" 'old-ws)
          (hermes-dashboard-transport--handle-socket-down
           old "late old close" 'old-ws))
        (should (equal facts '(t nil nil nil (old-ws) (old-process))))
        (should-not (eq replacement old))
        (should (eq (gethash key hermes-dashboard-transport--clients)
                    replacement))
        (should (eq (hermes-dashboard-transport-client-websocket replacement)
                    'replacement-ws))
        (when (memq callback-kind '(subscriber fallback))
          (should (equal (nreverse order) (list callback-kind 'pending))))))))

(ert-deftest hermes-dashboard-transport-socket-down-stops-after-last-release ()
  "A reconnect loses viability after rejection and stops without idle delay."
  (let* ((hermes-dashboard-transport--clients
          (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (hermes-dashboard-transport-idle-close-delay 30)
         (key '(spawn "127.0.0.1" 9119))
         (pending (make-hash-table :test #'equal))
         scheduled cancelled deleted replacement
         (hermes-dashboard-transport-schedule-function
          (lambda (delay _fn &rest _args)
            (push delay scheduled)
            'idle-timer))
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'old-ws :process 'old-process
               :refcount 1 :pending pending)))
    (puthash key old hermes-dashboard-transport--clients)
    (puthash "request"
             (list :method "test.method"
                   :reject (lambda (&rest _)
                             (hermes-dashboard-transport-release old)))
             pending)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'replacement-ws)))
              ((symbol-function 'cancel-timer)
               (lambda (timer) (push timer cancelled)))
              ((symbol-function 'delete-process)
               (lambda (process) (push process deleted))))
      (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws)
      (setq replacement
            (hermes-dashboard-transport-acquire :start-mode 'spawn)))
    (should (hermes-dashboard-transport-client-stopping-p old))
    (should-not (hermes-dashboard-transport-client-idle-timer old))
    (should-not (hermes-dashboard-transport-client-process old))
    (should (equal scheduled '(30)))
    (should (equal cancelled '(idle-timer)))
    (should (equal deleted '(old-process)))
    (should-not (eq replacement old))
    (should (eq (gethash key hermes-dashboard-transport--clients) replacement))))

(ert-deftest hermes-dashboard-transport-socket-down-is-inert-after-callback-replacement ()
  "A reject callback may stop and replace the old client without later effects."
  (let* ((hermes-dashboard-transport--clients
          (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (key '(spawn "127.0.0.1" 9119))
         (pending (make-hash-table :test #'equal))
         closed deleted scheduled replacement generation-after-stop
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq scheduled t) 'timer))
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'old-ws :process 'old-process
               :refcount 1 :pending pending)))
    (puthash key old hermes-dashboard-transport--clients)
    (puthash "request"
             (list :method "test.method"
                   :reject
                   (lambda (&rest _)
                     (hermes-dashboard-transport-stop old "callback stop")
                     (setq generation-after-stop
                           (hermes-dashboard-transport-client-generation old)
                           replacement
                           (hermes-dashboard-transport-acquire
                            :start-mode 'spawn))))
             pending)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'replacement-ws)))
              ((symbol-function 'websocket-close)
               (lambda (websocket) (push websocket closed)))
              ((symbol-function 'delete-process)
               (lambda (process) (push process deleted))))
      (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws))
    (should (= (hermes-dashboard-transport-client-generation old)
               generation-after-stop))
    (should-not scheduled)
    (should (equal deleted '(old-process)))
    (should-not (memq 'replacement-ws closed))
    (should (eq (gethash key hermes-dashboard-transport--clients) replacement))
    (should (eq (hermes-dashboard-transport-client-websocket replacement)
                'replacement-ws))
    (should-not (hermes-dashboard-transport-client-stopping-p replacement))))

(ert-deftest hermes-dashboard-transport-socket-down-is-inert-after-registry-replacement ()
  "A callback replacing the exact registry identity makes old continuation inert."
  (let* ((hermes-dashboard-transport--clients
          (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (key '(spawn "127.0.0.1" 9119))
         (pending (make-hash-table :test #'equal))
         scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq scheduled t) 'timer))
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'old-ws :refcount 1
               :pending pending))
         (replacement (make-hermes-dashboard-transport-client
                       :endpoint-key key :websocket 'replacement-ws :refcount 1)))
    (puthash key old hermes-dashboard-transport--clients)
    (puthash "request"
             (list :method "test.method"
                   :reject (lambda (&rest _)
                             (puthash key replacement
                                      hermes-dashboard-transport--clients)))
             pending)
    (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws)
    (should-not scheduled)
    (should-not (hermes-dashboard-transport-client-reconnecting-p old))
    (should (eq (gethash key hermes-dashboard-transport--clients) replacement))
    (should (eq (hermes-dashboard-transport-client-websocket replacement)
                'replacement-ws))))

(ert-deftest hermes-dashboard-transport-socket-down-is-inert-after-registry-removal ()
  "A callback removing the old registry identity makes its continuation inert."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (key '(spawn "127.0.0.1" 9119))
         (pending (make-hash-table :test #'equal)) scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq scheduled t) 'timer))
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'old-ws :refcount 1 :pending pending)))
    (puthash key old hermes-dashboard-transport--clients)
    (puthash "request"
             (list :method "test.method"
                   :reject (lambda (&rest _)
                             (remhash key hermes-dashboard-transport--clients)))
             pending)
    (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws)
    (should-not scheduled)
    (should-not (hermes-dashboard-transport-client-reconnecting-p old))
    (should-not (gethash key hermes-dashboard-transport--clients))))

(ert-deftest hermes-dashboard-transport-closed-status-revalidates-before-reconnect ()
  "Closed-status callbacks cannot continue a stale or unreferenced reconnect."
  (dolist (callback-kind '(subscriber fallback))
    (dolist (action '(remove replace release))
      (let* ((hermes-dashboard-transport--clients
              (make-hash-table :test #'equal))
             (hermes-dashboard-transport-reconnect-max-attempts 3)
             (hermes-dashboard-transport-idle-close-delay 30)
             (key '(spawn "127.0.0.1" 9119))
             scheduled cancelled opened
             (old (make-hermes-dashboard-transport-client
                   :endpoint-key key :websocket 'old-ws :refcount 1
                   :pending (make-hash-table :test #'equal)))
             (replacement
              (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'replacement-ws :refcount 1))
             (hermes-dashboard-transport-schedule-function
              (lambda (delay fn &rest args)
                (let ((timer (list delay fn args)))
                  (push timer scheduled)
                  timer)))
             (callback
              (lambda (event)
                (when (equal (plist-get event :status) "closed")
                  (pcase action
                    ('remove (remhash key hermes-dashboard-transport--clients))
                    ('replace (puthash key replacement
                                       hermes-dashboard-transport--clients))
                    ('release (hermes-dashboard-transport-release old)))))))
        (puthash key old hermes-dashboard-transport--clients)
        (if (eq callback-kind 'subscriber)
            (hermes-dashboard-transport-subscribe old callback)
          (setf (hermes-dashboard-transport-client-callback old) callback))
        (cl-letf (((symbol-function 'cancel-timer)
                   (lambda (timer) (push timer cancelled)))
                  ((symbol-function 'hermes-dashboard-transport--open-websocket-once)
                   (lambda (&rest _) (setq opened t) 'reopened-old)))
          (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws))
        (should-not (cl-find 1 scheduled :key #'car :test #'=))
        (should-not opened)
        (pcase action
          ('remove (should-not (gethash key hermes-dashboard-transport--clients)))
          ('replace
           (should (eq (gethash key hermes-dashboard-transport--clients)
                       replacement)))
          ('release
           (should (hermes-dashboard-transport-client-stopping-p old))
           (should-not (hermes-dashboard-transport-client-idle-timer old))
           (should cancelled)))))))

(ert-deftest hermes-dashboard-transport-reconnect-backoff-doubles-and-caps ()
  "Reconnect backoff doubles per attempt and caps at the max delay."
  (let ((hermes-dashboard-transport-reconnect-base-delay 1)
        (hermes-dashboard-transport-reconnect-max-delay 8))
    (should (= (hermes-dashboard-transport--reconnect-backoff 0) 1))
    (should (= (hermes-dashboard-transport--reconnect-backoff 2) 4))
    (should (= (hermes-dashboard-transport--reconnect-backoff 5) 8))))

(ert-deftest hermes-dashboard-transport-unexpected-close-schedules-reconnect ()
  "An unexpected close on a referenced client schedules a reconnect, staying registered."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (hermes-dashboard-transport-reconnect-base-delay 1)
         events scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (delay _fn &rest _args) (setq scheduled delay) 'timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client :websocket 'ws))))
      (let ((c (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-subscribe c (lambda (e) (push e events)))
        (hermes-dashboard-transport--handle-socket-down c "dropped")
        (should (hermes-dashboard-transport-client-reconnecting-p c))
        (should (eq (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients)
                    c))
        (should (equal scheduled 1))
        (should (cl-find "closed" events
                         :key (lambda (e) (plist-get e :status)) :test #'equal))))))

(ert-deftest hermes-dashboard-transport-unreferenced-close-finalizes ()
  "An unexpected close on an unreferenced client finalizes instead of reconnecting."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq scheduled t) 'timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client :websocket 'ws))))
      (let ((c (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (setf (hermes-dashboard-transport-client-refcount c) 0)
        (hermes-dashboard-transport--handle-socket-down c "dropped")
        (should-not scheduled)
        (should-not (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients))))))

(ert-deftest hermes-dashboard-transport-unreferenced-close-stops-before-reject ()
  "A configured but unreferenced client terminalizes before pending callbacks."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (key '(spawn "127.0.0.1" 9119))
         (pending (make-hash-table :test #'equal)) observed-stopping replacement
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :websocket 'old-ws :refcount 0
               :pending pending)))
    (puthash key old hermes-dashboard-transport--clients)
    (puthash "request"
             (list :method "test.method"
                   :reject (lambda (&rest _)
                             (setq observed-stopping
                                   (hermes-dashboard-transport-client-stopping-p old)
                                   replacement
                                   (hermes-dashboard-transport-acquire
                                    :start-mode 'spawn))))
             pending)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'replacement-ws))))
      (hermes-dashboard-transport--handle-socket-down old "dropped" 'old-ws))
    (should observed-stopping)
    (should replacement)
    (should-not (eq replacement old))
    (should (eq (gethash key hermes-dashboard-transport--clients) replacement))))

(ert-deftest hermes-dashboard-transport-stop-suppresses-reconnect ()
  "An intentional stop marks the socket closed without scheduling a reconnect."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq scheduled t) 'timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client :websocket 'ws)))
              ((symbol-function 'websocket-close) #'ignore))
      (let ((c (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-stop c "bye")
        (hermes-dashboard-transport--handle-socket-down c "dropped")
        (should-not scheduled)
        (should-not (hermes-dashboard-transport-client-reconnecting-p c))))))

(ert-deftest hermes-dashboard-transport-stale-scheduled-reconnect-is-dropped ()
  "A reconnect attempt scheduled before a stop does not fire afterwards."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 3)
         (hermes-dashboard-transport-reconnect-base-delay 1)
         fired
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args) (push (cons fn args) fired) 'timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client :websocket 'ws)))
              ((symbol-function 'websocket-close) #'ignore))
      (let ((c (hermes-dashboard-transport-acquire :start-mode 'spawn))
            attempted)
        (cl-letf (((symbol-function
                    'hermes-dashboard-transport--reconnect-attempt)
                   (lambda (&rest args) (setq attempted args))))
          (hermes-dashboard-transport--handle-socket-down c "dropped")
          (should fired)
          (hermes-dashboard-transport-stop c "bye")
          (dolist (entry fired) (apply (car entry) (cdr entry)))
          (should-not attempted))))))

(ert-deftest hermes-dashboard-transport-stale-ready-timeout-is-dropped ()
  "A ready timeout armed for an earlier generation does not fail the client."
  (let* (fired failed
         (hermes-dashboard-transport-ready-timeout 15)
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args) (push (cons fn args) fired) 'timer))
         (c (make-hermes-dashboard-transport-client)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport--fail-ready)
               (lambda (&rest _) (setq failed t))))
      (hermes-dashboard-transport--arm-ready-timeout c)
      (cl-incf (hermes-dashboard-transport-client-generation c))
      (dolist (entry fired) (apply (car entry) (cdr entry)))
      (should-not failed))))

(ert-deftest hermes-dashboard-transport-reconnect-reopens-socket ()
  "A reconnect attempt reopens the socket through the open function."
  (let* ((hermes-dashboard-transport-reconnect-max-attempts 3)
         opened
         (hermes-dashboard-transport-websocket-open-function
          (lambda (_url _client) (setq opened t) 'new-ws))
         (c (make-hermes-dashboard-transport-client
             :reconnecting-p t :refcount 1 :websocket-url "ws://x")))
    (hermes-dashboard-transport--reconnect-attempt c 0)
    (should opened)
    (should (eq (hermes-dashboard-transport-client-websocket c) 'new-ws))))

(ert-deftest hermes-dashboard-transport-manual-reconnect-restarts-in-place ()
  "Manual reconnect preserves the shared client while replacing its socket."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-ready-timeout nil)
         closed opened rejected events
         (hermes-dashboard-transport-websocket-open-function
          (lambda (_url _client) (setq opened t) 'new-ws))
         (pending (make-hash-table :test #'equal))
         (c (make-hermes-dashboard-transport-client
             :endpoint-key '(spawn "127.0.0.1" 8765)
             :websocket 'old-ws
             :websocket-url "ws://x"
             :ready-p t
             :ready-promise (hermes--promise-resolved 'ready)
             :refcount 1
             :pending pending)))
    (puthash '(spawn "127.0.0.1" 8765)
             c hermes-dashboard-transport--clients)
    (puthash "req-1"
             (list :method "prompt.submit"
                   :reject (lambda (message) (setq rejected message)))
             pending)
    (hermes-dashboard-transport-subscribe c (lambda (event) (push event events)))
    (cl-letf (((symbol-function 'websocket-close)
               (lambda (websocket) (setq closed websocket))))
      (hermes-dashboard-transport-reconnect c "manual reconnect"))
    (should (eq closed 'old-ws))
    (should opened)
    (should (eq (hermes-dashboard-transport-client-websocket c) 'new-ws))
    (should (eq (gethash '(spawn "127.0.0.1" 8765)
                         hermes-dashboard-transport--clients)
                c))
    (should (hermes-dashboard-transport-client-reconnecting-p c))
    (should-not (hermes-dashboard-transport-client-ready-p c))
    (should (= (hash-table-count pending) 0))
    (should (equal rejected "manual reconnect"))
    (should (cl-find "reconnecting" events
                     :key (lambda (event) (plist-get event :status))
                     :test #'equal))))

(ert-deftest hermes-dashboard-transport-manual-reconnect-works-when-auto-off ()
  "Manual reconnect still opens once when proactive reconnect is disabled."
  (let* ((hermes-dashboard-transport-reconnect-max-attempts nil)
         (hermes-dashboard-transport-ready-timeout nil)
         opened
         (hermes-dashboard-transport-websocket-open-function
          (lambda (_url _client) (setq opened t) 'new-ws))
         (c (make-hermes-dashboard-transport-client
             :websocket 'old-ws
             :websocket-url "ws://x"
             :refcount 1
             :pending (make-hash-table :test #'equal))))
    (cl-letf (((symbol-function 'websocket-close) #'ignore))
      (hermes-dashboard-transport-reconnect c))
    (should opened)
    (should (eq (hermes-dashboard-transport-client-websocket c) 'new-ws))))

(ert-deftest hermes-dashboard-transport-stale-websocket-close-is-ignored ()
  "A delayed close from an old socket must not tear down the replacement socket."
  (let* ((hermes-dashboard-transport-reconnect-max-attempts 3)
         scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args)
            (setq scheduled (cons fn args))
            'fake-timer))
         (c (make-hermes-dashboard-transport-client
             :websocket 'new-ws
             :websocket-url "ws://x"
             :reconnecting-p t
             :reconnect-attempts 0
             :refcount 1
             :pending (make-hash-table :test #'equal))))
    (hermes-dashboard-transport--handle-socket-down c "old closed" 'old-ws)
    (should (eq (hermes-dashboard-transport-client-websocket c) 'new-ws))
    (should (hermes-dashboard-transport-client-reconnecting-p c))
    (should (= (hermes-dashboard-transport-client-reconnect-attempts c) 0))
    (should-not scheduled)
    (hermes-dashboard-transport--handle-socket-down c "new closed" 'new-ws)
    (should-not (hermes-dashboard-transport-client-websocket c))
    (should (= (hermes-dashboard-transport-client-reconnect-attempts c) 1))
    (should scheduled)))

(ert-deftest hermes-dashboard-transport-reconnect-gives-up-after-max ()
  "Exhausting reconnect attempts finalizes the client and reports closed."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-reconnect-max-attempts 2)
         events)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client :websocket 'ws))))
      (let ((c (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-subscribe c (lambda (e) (push e events)))
        (setf (hermes-dashboard-transport-client-reconnecting-p c) t)
        (hermes-dashboard-transport--reconnect-attempt c 2)
        (should-not (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients))
        (should-not (hermes-dashboard-transport-client-reconnecting-p c))
        (should (cl-find "closed" events
                         :key (lambda (e) (plist-get e :status)) :test #'equal))))))

(ert-deftest hermes-dashboard-transport-reconnect-ready-emits-reconnected ()
  "After reconnect, `gateway.ready' clears reconnect state and broadcasts reconnected."
  (let* (events
         (c (make-hermes-dashboard-transport-client
             :reconnecting-p t :reconnect-attempts 2 :websocket 'ws)))
    (hermes-dashboard-transport-subscribe c (lambda (e) (push e events)))
    (hermes-dashboard-transport--handle-frame
     c '((jsonrpc . "2.0") (method . "event")
         (params . ((type . "gateway.ready")))))
    (should-not (hermes-dashboard-transport-client-reconnecting-p c))
    (should (= (hermes-dashboard-transport-client-reconnect-attempts c) 0))
    (should (cl-find "reconnected" events
                     :key (lambda (e) (plist-get e :status)) :test #'equal))))

(ert-deftest hermes-dashboard-transport-reconnect-defers-requests-until-new-gateway-ready ()
  "A request issued during reconnect sends no frame until the new `gateway.ready'."
  (let* ((hermes-dashboard-transport-reconnect-max-attempts 3)
         (hermes-dashboard-transport-reconnect-base-delay 1)
         (hermes-dashboard-transport-ready-timeout nil)
         (sent-frames nil)
         (reconnect-timer nil)
         (hermes-dashboard-transport-websocket-open-function
          (lambda (_url _client) 'new-ws))
         (hermes-dashboard-transport-websocket-send-function
          (lambda (_ws text) (push text sent-frames)))
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args)
            (setq reconnect-timer (lambda () (apply fn args)))
            'fake-timer))
         (old-ready (hermes--promise-resolved 'first-ready))
         (c (make-hermes-dashboard-transport-client
             :ready-p t :ready-promise old-ready
             :websocket 'old-ws :refcount 1
             :websocket-url "ws://x"
             :pending (make-hash-table :test #'equal))))
    ;; Socket drops unexpectedly: reconnect begins, readiness resets.
    (hermes-dashboard-transport--handle-socket-down c "dropped")
    (should (hermes-dashboard-transport-client-reconnecting-p c))
    (should-not (hermes-dashboard-transport-client-ready-p c))
    (let ((promise (hermes-dashboard-transport-client-ready-promise c)))
      (should (hermes--promise-p promise))
      (should (eq (hermes--promise-state promise) 'pending))
      (should-not (eq promise old-ready)))
    ;; Request before gateway.ready: must not send.
    (hermes-dashboard-transport-request c "ping" nil #'ignore #'ignore)
    (should-not sent-frames)
    ;; Reconnect attempt installs the replacement socket; still no frame.
    (when (functionp reconnect-timer) (funcall reconnect-timer))
    (should (eq (hermes-dashboard-transport-client-websocket c) 'new-ws))
    (should-not sent-frames)
    ;; New gateway.ready resolves the fresh promise; the deferred frame sends.
    (hermes-dashboard-transport--handle-frame
     c '((jsonrpc . "2.0") (method . "event")
         (params . ((type . "gateway.ready")))))
    (should (hermes-dashboard-transport-client-ready-p c))
    (should (= (length sent-frames) 1))))

;;; Group: heartbeat keepalive

(ert-deftest hermes-dashboard-transport-heartbeat-arms-on-ready-and-pings ()
  "With an interval set, `gateway.ready' arms a heartbeat that sends pings."
  (let* ((hermes-dashboard-transport-heartbeat-interval 30)
         (schedules 0)
         pings captured
         (hermes-dashboard-transport-schedule-function
          (lambda (delay fn &rest args)
            (setq schedules (1+ schedules)
                  captured (list delay fn args))
            'fake-timer))
         (hermes-dashboard-transport-ping-function
          (lambda (ws) (push ws pings)))
         (client (make-hermes-dashboard-transport-client
                  :websocket 'fake-websocket)))
    (hermes-dashboard-transport--handle-frame
     client '((jsonrpc . "2.0") (method . "event")
              (params . ((type . "gateway.ready")))))
    (should (hermes-dashboard-transport-client-ready-p client))
    (should (equal (nth 0 captured) 30))
    (should (= schedules 1))
    (should (eq (hermes-dashboard-transport-client-heartbeat-timer client)
                'fake-timer))
    (apply (nth 1 captured) (nth 2 captured))
    (should (equal pings '(fake-websocket)))
    (should (= schedules 2))))

(ert-deftest hermes-dashboard-transport-heartbeat-disabled-when-nil ()
  "A nil heartbeat interval arms no timer."
  (let* ((hermes-dashboard-transport-heartbeat-interval nil)
         armed
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq armed t) 'fake-timer))
         (client (make-hermes-dashboard-transport-client
                  :websocket 'fake-websocket)))
    (hermes-dashboard-transport--arm-heartbeat client)
    (should-not armed)
    (should-not (hermes-dashboard-transport-client-heartbeat-timer client))))

(ert-deftest hermes-dashboard-transport-heartbeat-cleared-on-close ()
  "Marking the socket closed clears the heartbeat timer."
  (let* ((hermes-dashboard-transport-heartbeat-interval 30)
         (hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) 'fake-timer))
         (client (make-hermes-dashboard-transport-client
                  :websocket 'fake-websocket)))
    (hermes-dashboard-transport--arm-heartbeat client)
    (should (hermes-dashboard-transport-client-heartbeat-timer client))
    (hermes-dashboard-transport--mark-websocket-closed client)
    (should-not (hermes-dashboard-transport-client-heartbeat-timer client))))

(ert-deftest hermes-dashboard-transport-heartbeat-tick-stops-without-socket ()
  "A heartbeat tick on a closed socket sends nothing and does not re-arm."
  (let* ((hermes-dashboard-transport-heartbeat-interval 30)
         armed pings
         (hermes-dashboard-transport-schedule-function
          (lambda (&rest _) (setq armed t) 'fake-timer))
         (hermes-dashboard-transport-ping-function
          (lambda (ws) (push ws pings)))
         (client (make-hermes-dashboard-transport-client :websocket nil)))
    (hermes-dashboard-transport--heartbeat-tick client)
    (should-not pings)
    (should-not armed)))

;;; Group: subscriber registry + session demux

(ert-deftest hermes-dashboard-transport-subscribe-routes-tagged-event-to-owner ()
  "A tagged event reaches only the subscriber owning its session id."
  (let* ((client (hermes-test--dashboard-client))
         a-events b-events
         (a (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e a-events))))
         (b (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e b-events)))))
    (hermes-dashboard-transport-subscribe-session client a "sid-a")
    (hermes-dashboard-transport-subscribe-session client b "sid-b")
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'delta :session-id "sid-a" :content "x"))
    (should (equal (length a-events) 1))
    (should-not b-events)))

(ert-deftest hermes-dashboard-transport-untagged-event-broadcasts ()
  "An untagged connection-level event reaches every subscriber."
  (let* ((client (hermes-test--dashboard-client))
         a-events b-events
         (a (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e a-events))))
         (b (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e b-events)))))
    (hermes-dashboard-transport-subscribe-session client a "sid-a")
    (hermes-dashboard-transport-subscribe-session client b "sid-b")
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'status :status "closed"))
    (should (equal (length a-events) 1))
    (should (equal (length b-events) 1))))

(ert-deftest hermes-dashboard-transport-unowned-tagged-event-is-ignored ()
  "A tagged event with no registered owner reaches no subscriber."
  (let* ((client (hermes-test--dashboard-client))
         events
         (token (hermes-dashboard-transport-subscribe
                 client (lambda (e) (push e events)))))
    (hermes-dashboard-transport-subscribe-session client token "sid-a")
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'delta :session-id "other" :content "x"))
    (should-not events)))

(ert-deftest hermes-dashboard-transport-dispatch-falls-back-to-callback ()
  "With no subscribers, dispatch uses the legacy single callback."
  (let* (received
         (client (make-hermes-dashboard-transport-client
                  :callback (lambda (e) (setq received e)))))
    (hermes-dashboard-transport--dispatch-event client (list :type 'done))
    (should (equal received (list :type 'done)))))

(ert-deftest hermes-dashboard-transport-unsubscribe-stops-delivery ()
  "Unsubscribing a token stops delivery and clears the session index."
  (let* ((client (hermes-test--dashboard-client))
         events
         (token (hermes-dashboard-transport-subscribe
                 client (lambda (e) (push e events)))))
    (hermes-dashboard-transport-subscribe-session client token "sid-a")
    (hermes-dashboard-transport-unsubscribe client token)
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'delta :session-id "sid-a"))
    (hermes-dashboard-transport--dispatch-event client (list :type 'status))
    (should-not events)
    (should-not (hermes-dashboard-transport--session-subscriber-fns
                 client "sid-a"))))

(ert-deftest hermes-dashboard-transport-subscribe-session-rebinds-index ()
  "Re-binding a token to a new session id moves it off the old id."
  (let* ((client (hermes-test--dashboard-client))
         events
         (token (hermes-dashboard-transport-subscribe
                 client (lambda (e) (push e events)))))
    (hermes-dashboard-transport-subscribe-session client token "old")
    (hermes-dashboard-transport-subscribe-session client token "new")
    (should-not (hermes-dashboard-transport--session-subscriber-fns client "old"))
    (should (hermes-dashboard-transport--session-subscriber-fns client "new"))
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'delta :session-id "new"))
    (should (equal (length events) 1))))

(ert-deftest hermes-dashboard-transport-unsubscribe-preserves-other-owner ()
  "Unsubscribing a stale token does not evict another token's session ownership."
  (let* ((client (hermes-test--dashboard-client))
         a-events b-events
         (a (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e a-events))))
         (b (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e b-events)))))
    (hermes-dashboard-transport-subscribe-session client a "sid")
    (hermes-dashboard-transport-subscribe-session client b "sid")
    (hermes-dashboard-transport-unsubscribe client a)
    (hermes-dashboard-transport--dispatch-event
     client (list :type 'delta :session-id "sid"))
    (should-not a-events)
    (should (equal (length b-events) 1))))

(ert-deftest hermes-dashboard-transport-handle-frame-demuxes-by-session ()
  "An inbound event frame routes to the subscriber owning its session id."
  (let* ((client (hermes-test--dashboard-client))
         a-events b-events
         (a (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e a-events))))
         (b (hermes-dashboard-transport-subscribe
             client (lambda (e) (push e b-events)))))
    (hermes-dashboard-transport-subscribe-session client a "sid-a")
    (hermes-dashboard-transport-subscribe-session client b "sid-b")
    (hermes-dashboard-transport--handle-frame
     client
     '((jsonrpc . "2.0") (method . "event")
       (params . ((type . "message.delta") (session_id . "sid-a")
                  (payload . ((delta . "hi")))))))
    (should (equal (length a-events) 1))
    (should (equal (plist-get (car a-events) :session-id) "sid-a"))
    (should-not b-events)))

;;; Group: shared client registry

(ert-deftest hermes-dashboard-transport-endpoint-key-spawn-is-local ()
  "A spawn-mode target keys on its resolved host and port."
  (should (equal (hermes-dashboard-transport--endpoint-key
                  :host "127.0.0.1" :port 8765 :start-mode 'spawn)
                 '(spawn "127.0.0.1" 8765))))

(ert-deftest hermes-dashboard-transport-endpoint-key-remote-is-base-url ()
  "A remote target keys on its normalized base URL."
  (should (equal (hermes-dashboard-transport--endpoint-key
                  :start-mode 'remote :remote-url "https://h.example/")
                 "https://h.example")))

(ert-deftest hermes-dashboard-transport-acquire-shares-client-by-endpoint ()
  "Acquiring the same endpoint twice reuses one client and counts references."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
        (made 0))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (cl-incf made)
                 (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn))
            (c2 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (should (eq c1 c2))
        (should (= made 1))
        (should (= (hermes-dashboard-transport-client-refcount c1) 2))))))

(ert-deftest hermes-dashboard-transport-acquire-replaces-stopping-registry-entry ()
  "Acquire identity-removes a stopping entry and starts a fresh client."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (key '(spawn "127.0.0.1" 9119))
         (old (make-hermes-dashboard-transport-client
               :endpoint-key key :stopping-p t :refcount 7))
         fresh)
    (puthash key old hermes-dashboard-transport--clients)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (setq fresh (make-hermes-dashboard-transport-client)))))
      (let ((client (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (should (eq client fresh))
        (should-not (eq client old))
        (should (= (hermes-dashboard-transport-client-refcount old) 7))
        (should (= (hermes-dashboard-transport-client-refcount fresh) 1))
        (should (eq (gethash key hermes-dashboard-transport--clients) fresh))))))

(ert-deftest hermes-dashboard-transport-acquire-distinct-endpoints ()
  "Different remote endpoints get distinct clients."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire
                 :start-mode 'remote :remote-url "https://a.example"))
            (c2 (hermes-dashboard-transport-acquire
                 :start-mode 'remote :remote-url "https://b.example")))
        (should-not (eq c1 c2))))))

(ert-deftest hermes-dashboard-transport-acquire-distinct-spawn-ports ()
  "Different local spawn ports get distinct clients."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire
                 :start-mode 'spawn :host "127.0.0.1" :port 8765))
            (c2 (hermes-dashboard-transport-acquire
                 :start-mode 'spawn :host "127.0.0.1" :port 8766)))
        (should-not (eq c1 c2))))))

(ert-deftest hermes-dashboard-transport-stop-all-stops-registered-clients ()
  "Stop-all snapshots initial clients and preserves a reentrant replacement."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (pending (make-hash-table :test #'equal))
         replacement
         (first (make-hermes-dashboard-transport-client
                 :endpoint-key 'first :pending pending))
         (second (make-hermes-dashboard-transport-client :endpoint-key 'second)))
    (puthash 'first first hermes-dashboard-transport--clients)
    (puthash 'second second hermes-dashboard-transport--clients)
    (puthash "request"
             (list :reject
                   (lambda (_reason)
                     (setq replacement
                           (hermes-dashboard-transport-acquire
                            :start-mode 'spawn :host "127.0.0.1" :port 9120))))
             pending)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'replacement-ws))))
      (should (= 2 (hermes-dashboard-transport-stop-all "Restarting Hermes"))))
    (should (eq (gethash '(spawn "127.0.0.1" 9120)
                         hermes-dashboard-transport--clients)
                replacement))
    (should (hermes-dashboard-transport-client-stopping-p first))
    (should (hermes-dashboard-transport-client-stopping-p second))))

(ert-deftest hermes-dashboard-transport-release-stops-and-unregisters-at-zero ()
  "Release decrements references and tears the client down only at zero."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-acquire :start-mode 'spawn)
        (should (= (hermes-dashboard-transport-release c1) 1))
        (should (eq (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients)
                    c1))
        (should (= (hermes-dashboard-transport-release c1) 0))
        (should-not (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients))
        (should-not (eq c1 (hermes-dashboard-transport-acquire
                            :start-mode 'spawn)))))))

(ert-deftest hermes-dashboard-transport-release-stopping-client-is-inert ()
  "Releasing a stopped client returns zero without repeating terminal effects."
  (let ((client (make-hermes-dashboard-transport-client
                 :stopping-p t :refcount 0 :generation 4)))
    (should (= (hermes-dashboard-transport-release client) 0))
    (should (= (hermes-dashboard-transport-client-generation client) 4))))

(ert-deftest hermes-dashboard-transport-release-schedules-idle-close ()
  "With an idle delay, the last release keeps the client warm and re-acquire reuses it."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-idle-close-delay 30)
         scheduled
         (hermes-dashboard-transport-schedule-function
          (lambda (delay _fn &rest _args) (setq scheduled delay) 'fake-timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-release c1)
        (should (equal scheduled 30))
        (should (eq (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients)
                    c1))
        (should (eq c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (should (= (hermes-dashboard-transport-client-refcount c1) 1))
        (should-not (hermes-dashboard-transport-client-idle-timer c1))))))

(ert-deftest hermes-dashboard-transport-idle-close-stops-when-still-idle ()
  "Firing the idle-close timer tears down a client that stayed idle."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-idle-close-delay 30)
         captured
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args) (setq captured (cons fn args)) 'fake-timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-release c1)
        (apply (car captured) (cdr captured))
        (should-not (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients))))))

(ert-deftest hermes-dashboard-transport-idle-timer-after-rebuild-is-harmless ()
  "A stale idle timer firing after a drop and rebuild leaves the fresh client."
  (let* ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
         (hermes-dashboard-transport-idle-close-delay 30)
         captured
         (hermes-dashboard-transport-schedule-function
          (lambda (_delay fn &rest args) (setq captured (cons fn args)) 'fake-timer)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'fake-websocket))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-release c1)
        (hermes-dashboard-transport--handle-socket-down c1 "dropped")
        (let ((c2 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
          (apply (car captured) (cdr captured))
          (should-not (eq c1 c2))
          (should (eq (gethash '(spawn "127.0.0.1" 9119)
                               hermes-dashboard-transport--clients)
                      c2)))))))

(ert-deftest hermes-dashboard-transport-acquire-rebuilds-after-close ()
  "With reconnect off, a dropped socket finalizes so the next acquire rebuilds."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal))
        (hermes-dashboard-transport-reconnect-max-attempts nil))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _)
                 (make-hermes-dashboard-transport-client
                  :websocket 'fake-websocket))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-acquire :start-mode 'spawn)
        (hermes-dashboard-transport--handle-socket-down c1 "dropped")
        (should-not (gethash '(spawn "127.0.0.1" 9119)
                             hermes-dashboard-transport--clients))
        (should-not (eq c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))))))

(ert-deftest hermes-dashboard-transport-unregister-keeps-replacement ()
  "Stopping a stale client does not evict a replacement under the same key."
  (let ((hermes-dashboard-transport--clients (make-hash-table :test #'equal)))
    (cl-letf (((symbol-function 'hermes-dashboard-transport-start)
               (lambda (&rest _) (make-hermes-dashboard-transport-client))))
      (let ((c1 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
        (hermes-dashboard-transport-release c1)
        (let ((c2 (hermes-dashboard-transport-acquire :start-mode 'spawn)))
          (should-not (eq c1 c2))
          (hermes-dashboard-transport-stop c1)
          (should (eq (gethash '(spawn "127.0.0.1" 9119)
                               hermes-dashboard-transport--clients)
                      c2)))))))


;;; Shared-socket session param isolation

(ert-deftest hermes-dashboard-transport-session-param-is-explicit-only ()
  "`--session-param' returns the explicit id and never reads the client."
  :tags '(shared-socket-isolation)
  (let ((client (make-hermes-dashboard-transport-client
                 :session-id "stale-sid")))
    (should (equal (hermes-dashboard-transport--session-param client "live-sid")
                   "live-sid"))
    (should-not (hermes-dashboard-transport--session-param client nil))))

(ert-deftest hermes-dashboard-transport-session-response-does-not-store-client-session ()
  "A `session.create' response must not mutate the shared client."
  :tags '(shared-socket-isolation)
  (let ((client (hermes-test--dashboard-client)))
    (hermes-dashboard-transport--store-session-result
     client "session.create"
     '((session_id . "sid-live") (stored_session_id . "sid-stored")))
    (should-not (hermes-dashboard-transport-client-session-id client))
    (should-not (hermes-dashboard-transport-client-stored-session-id client))))

(ert-deftest hermes-dashboard-transport-session-rpc-omits-session-when-not-explicit ()
  "A `:session t' RPC that omits `:session-id' sends no session_id param."
  :tags '(shared-socket-isolation)
  (let (params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client _method p resolve _reject)
                 (setq params p)
                 (funcall resolve '((ok . t))))))
      (let ((client (make-hermes-dashboard-transport-client
                     :session-id "ambient-sid")))
        (hermes-dashboard-transport-tools-configure
         client '("terminal") "disable" :resolve #'ignore :reject #'ignore))
      (should-not (assq 'session_id params)))))

(ert-deftest hermes-dashboard-transport-session-rpc-forwards-explicit-session ()
  "A `:session t' RPC with explicit `:session-id' forwards it."
  :tags '(shared-socket-isolation)
  (let (params)
    (cl-letf (((symbol-function 'hermes-dashboard-transport-request)
               (lambda (_client _method p resolve _reject)
                 (setq params p)
                 (funcall resolve '((ok . t))))))
      (let ((client (hermes-test--dashboard-client)))
        (hermes-dashboard-transport-tools-configure
         client '("terminal") "disable"
         :session-id "explicit-sid" :resolve #'ignore :reject #'ignore))
      (should (equal (cdr (assq 'session_id params)) "explicit-sid")))))

(provide 'hermes-dashboard-tests)
;;; hermes-dashboard-tests.el ends here
