(ns ajet.chat.auth-gw.proxy-integration-test
  "Integration tests for auth gateway proxy, routing, and auth checks.

   Starts a mock upstream server that echoes request details, then runs
   the auth-gw Ring handler against it to verify:
   - Session auth gates protected routes (401 / 302)
   - Authenticated requests are proxied with correct headers
   - Spoofed auth headers are stripped
   - Route resolution maps to correct upstream services
   - Rate limiting returns 429"
  (:require [clojure.test :refer [deftest testing is use-fixtures]]
            [clojure.data.json :as json]
            [clojure.string :as str]
            [ajet.chat.auth-gw.auth :as auth]
            [ajet.chat.auth-gw.proxy :as proxy]
            [ajet.chat.auth-gw.routes :as routes]
            [ajet.chat.auth-gw.rate-limit :as rl]
            [ajet.chat.auth-gw.middleware :as gw-mw]
            [ajet.chat.shared.db :as db]
            [ajet.chat.test-helpers :as th]
            [ring.middleware.cookies :as cookies])
  (:import [java.net ServerSocket]))

;;; ---------------------------------------------------------------------------
;;; Mock upstream server
;;; ---------------------------------------------------------------------------

(defn- find-free-port []
  (with-open [sock (ServerSocket. 0)]
    (.getLocalPort sock)))

(defn- echo-handler
  "Ring handler that returns the received request details as JSON.
   Used as a mock upstream to verify proxied headers."
  [request]
  {:status  200
   :headers {"Content-Type" "application/json"}
   :body    (json/write-str
              {:method      (name (:request-method request))
               :uri         (:uri request)
               :headers     (into {} (:headers request))
               :query       (:query-string request)
               :server-name (:server-name request)
               :server-port (:server-port request)})})

(defn- echo-handler-201
  "Like echo-handler but returns 201 for POST requests."
  [request]
  (let [status (if (= :post (:request-method request)) 201 200)]
    {:status  status
     :headers {"Content-Type" "application/json"}
     :body    (json/write-str
                {:method  (name (:request-method request))
                 :uri     (:uri request)
                 :headers (into {} (:headers request))})}))

(def ^:private ^:dynamic *api-port* nil)
(def ^:private ^:dynamic *web-port* nil)
(def ^:private ^:dynamic *tui-port* nil)
(def ^:private ^:dynamic *api-server* nil)
(def ^:private ^:dynamic *web-server* nil)
(def ^:private ^:dynamic *tui-server* nil)

(defn- start-mock-server!
  "Start a mock http-kit server on the given port. Returns the stop fn."
  [port handler]
  (let [stop-fn (org.httpkit.server/run-server handler {:port port :legacy-return-value? true})]
    stop-fn))

(defn with-mock-upstreams
  "Suite fixture: start mock upstream servers for API, Web SM, and TUI SM."
  [f]
  (let [api-port (find-free-port)
        web-port (find-free-port)
        tui-port (find-free-port)
        api-stop (start-mock-server! api-port echo-handler-201)
        web-stop (start-mock-server! web-port echo-handler)
        tui-stop (start-mock-server! tui-port echo-handler)]
    (try
      (binding [*api-port* api-port
                *web-port* web-port
                *tui-port* tui-port
                *api-server* api-stop
                *web-server* web-stop
                *tui-server* tui-stop]
        (f))
      (finally
        (api-stop)
        (web-stop)
        (tui-stop)))))

;;; ---------------------------------------------------------------------------
;;; Fixtures
;;; ---------------------------------------------------------------------------

(use-fixtures :once th/with-db-suite with-mock-upstreams)
(use-fixtures :each th/with-clean-db)

;;; ---------------------------------------------------------------------------
;;; Test config & system builder
;;; ---------------------------------------------------------------------------

(defn- test-config []
  {:services {:api    {:host "localhost" :port *api-port*}
              :web-sm {:host "localhost" :port *web-port*}
              :tui-sm {:host "localhost" :port *tui-port*}}
   :session  {:ttl-days 30
              :cookie-name "ajet_session"
              :cookie-secure false}
   :rate-limit {:enabled false}
   :cors {:allowed-origins ["http://localhost:3000"]
          :allowed-methods [:get :post :put :delete :options]
          :allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
          :max-age 86400}
   :oauth {:github {:enabled false}
           :gitea  {:enabled false}
           :oidc   {:enabled false}}})

(defn- make-handler
  "Build the auth-gw Ring handler with test config."
  ([] (make-handler {}))
  ([config-overrides]
   (let [config  (merge (test-config) config-overrides)
         limiter (rl/make-limiter)
         sys     {:ds                   th/*ds*
                  :config               config
                  :limiter              limiter
                  :oauth-providers-atom (atom [])
                  :setup-complete-atom  (atom :true)}]
     (routes/app sys))))

(defn- make-request
  "Build a Ring request map for testing through the auth-gw handler."
  [method uri & [{:keys [cookie headers body]}]]
  (cond-> {:request-method method
           :uri            uri
           :headers        (merge {} headers)
           :remote-addr    "127.0.0.1"
           :server-name    "localhost"
           :server-port    3000
           :scheme         :http}
    cookie (assoc-in [:headers "cookie"] cookie)
    body   (assoc :body body)))

(defn- parse-body [response]
  (when (and (:body response) (string? (:body response))
             (not (str/blank? (:body response))))
    (try
      (json/read-str (:body response) :key-fn keyword)
      (catch Exception _ nil))))

(defn- create-session-cookie!
  "Create a user and session, return the cookie string."
  []
  (let [user  (th/create-user!)
        token (auth/create-session! th/*ds* (:id user) 30)]
    {:user user :token token :cookie (str "ajet_session=" token)}))

;;; ---------------------------------------------------------------------------
;;; Auth check tests — unauthenticated requests
;;; ---------------------------------------------------------------------------

(deftest api-route-without-auth-returns-401
  (let [handler (make-handler)
        resp    (handler (make-request :get "/api/communities"))]
    (is (= 401 (:status resp)))
    (testing "response body is JSON error"
      (let [body (parse-body resp)]
        (is (= "unauthorized" (:error body)))))))

(deftest web-route-without-auth-redirects-302
  (let [handler (make-handler)
        resp    (handler (make-request :get "/app/dashboard"))]
    (is (= 302 (:status resp)))
    (is (= "/auth/login" (get-in resp [:headers "Location"])))))

(deftest root-route-without-auth-redirects-302
  (let [handler (make-handler)
        resp    (handler (make-request :get "/"))]
    (is (= 302 (:status resp)))
    (is (= "/auth/login" (get-in resp [:headers "Location"])))))

(deftest sse-route-without-auth-redirects-302
  (let [handler (make-handler)
        resp    (handler (make-request :get "/sse/events"))]
    (is (= 302 (:status resp)))
    (is (= "/auth/login" (get-in resp [:headers "Location"])))))

(deftest tui-route-without-auth-returns-401
  (let [handler (make-handler)
        resp    (handler (make-request :post "/tui/signal"))]
    (is (= 401 (:status resp)))))

;;; ---------------------------------------------------------------------------
;;; Auth check tests — authenticated requests are proxied
;;; ---------------------------------------------------------------------------

(deftest api-route-with-session-proxies-to-api
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities" {:cookie cookie}))
        body    (parse-body resp)]
    (is (= 200 (:status resp)))
    (testing "request reached the mock API server"
      (is (= "get" (:method body)))
      (is (= "/api/communities" (:uri body))))))

(deftest api-route-with-session-proxies-post
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :post "/api/communities"
                           {:cookie cookie
                            :headers {"content-type" "application/json"}
                            :body    "{\"name\":\"test\"}"}))]
    (is (= 201 (:status resp)))))

(deftest web-route-with-session-proxies-to-web-sm
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/app/home" {:cookie cookie}))
        body    (parse-body resp)]
    (is (= 200 (:status resp)))
    (is (= "/app/home" (:uri body)))))

;;; ---------------------------------------------------------------------------
;;; Header injection tests
;;; ---------------------------------------------------------------------------

(deftest proxied-request-has-user-id-header
  (let [{:keys [user cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities" {:cookie cookie}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (is (= (str (:id user)) (get proxied-headers :x-user-id)))))

(deftest proxied-request-has-trace-id-header
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities" {:cookie cookie}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (is (some? (get proxied-headers :x-trace-id)))
    (testing "trace-id is a UUID format"
      (is (re-matches #"[0-9a-f-]{36}" (get proxied-headers :x-trace-id))))))

(deftest proxied-request-has-forwarded-for-header
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities" {:cookie cookie}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (is (= "127.0.0.1" (get proxied-headers :x-forwarded-for)))))

;;; ---------------------------------------------------------------------------
;;; Header stripping tests — prevent spoofing
;;; ---------------------------------------------------------------------------

(deftest spoofed-user-id-header-is-stripped
  (let [{:keys [user cookie]} (create-session-cookie!)
        handler (make-handler)
        ;; Try to spoof a different user ID
        resp    (handler (make-request :get "/api/communities"
                           {:cookie  cookie
                            :headers {"x-user-id" "spoofed-evil-id"}}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (testing "spoofed user-id is replaced with actual session user-id"
      (is (= (str (:id user)) (get proxied-headers :x-user-id)))
      (is (not= "spoofed-evil-id" (get proxied-headers :x-user-id))))))

(deftest spoofed-community-id-header-is-stripped
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities"
                           {:cookie  cookie
                            :headers {"x-community-id" "spoofed-community"}}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (testing "spoofed community-id is not forwarded"
      (is (nil? (get proxied-headers :x-community-id))))))

(deftest spoofed-hop-by-hop-values-are-stripped
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/api/communities"
                           {:cookie  cookie
                            :headers {"transfer-encoding" "chunked"
                                      "te" "trailers"
                                      "trailer" "X-Foo"}}))
        body    (parse-body resp)
        proxied-headers (:headers body)]
    (testing "transfer-encoding from original request is stripped"
      (is (nil? (get proxied-headers :transfer-encoding))))
    (testing "te from original request is stripped"
      (is (nil? (get proxied-headers :te))))
    (testing "trailer from original request is stripped"
      (is (nil? (get proxied-headers :trailer))))))

;;; ---------------------------------------------------------------------------
;;; Route resolution tests
;;; ---------------------------------------------------------------------------

(deftest api-routes-proxy-to-api-service
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        paths   ["/api/communities" "/api/channels/123/messages"
                 "/api/users/me"]]
    (doseq [path paths]
      (let [resp (handler (make-request :get path {:cookie cookie}))
            body (parse-body resp)]
        (testing (str "path " path " reaches API")
          (is (= 200 (:status resp)))
          (is (= path (:uri body))))))))

(deftest web-routes-proxy-to-web-sm-service
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        paths   ["/app/home" "/app/settings" "/web/signal"]]
    (doseq [path paths]
      (let [method (if (str/starts-with? path "/web/") :post :get)
            resp   (handler (make-request method path {:cookie cookie}))
            body   (parse-body resp)]
        (testing (str "path " path " reaches Web SM")
          (is (= 200 (:status resp)))
          (is (= path (:uri body))))))))

(deftest root-route-proxies-to-web-sm
  (let [{:keys [cookie]} (create-session-cookie!)
        handler (make-handler)
        resp    (handler (make-request :get "/" {:cookie cookie}))
        body    (parse-body resp)]
    (is (= 200 (:status resp)))
    (is (= "/" (:uri body)))))

;;; ---------------------------------------------------------------------------
;;; Health check
;;; ---------------------------------------------------------------------------

(deftest health-endpoint-does-not-require-auth
  (let [handler (make-handler)
        resp    (handler (make-request :get "/health"))]
    (is (= 200 (:status resp)))
    (let [body (parse-body resp)]
      (is (= "ok" (:status body))))))

;;; ---------------------------------------------------------------------------
;;; Login page
;;; ---------------------------------------------------------------------------

(deftest login-page-does-not-require-auth
  (let [handler (make-handler)
        resp    (handler (make-request :get "/auth/login"))]
    (is (= 200 (:status resp)))
    (is (string? (:body resp)))))

;;; ---------------------------------------------------------------------------
;;; Invalid session
;;; ---------------------------------------------------------------------------

(deftest expired-session-returns-401-for-api
  (let [user  (th/create-user!)
        token (auth/create-session! th/*ds* (:id user) 30)]
    ;; Manually expire the session
    (db/execute! th/*ds*
      {:update :sessions
       :set    {:expires-at [:raw "NOW() - INTERVAL '1 day'"]}
       :where  [:= :user-id (:id user)]})
    (let [handler (make-handler)
          resp    (handler (make-request :get "/api/communities"
                             {:cookie (str "ajet_session=" token)}))]
      (is (= 401 (:status resp))))))

(deftest invalid-session-cookie-returns-401-for-api
  (let [handler (make-handler)
        resp    (handler (make-request :get "/api/communities"
                           {:cookie "ajet_session=totally-bogus-token"}))]
    (is (= 401 (:status resp)))))

;;; ---------------------------------------------------------------------------
;;; CORS
;;; ---------------------------------------------------------------------------

(deftest cors-preflight-returns-204
  (let [handler (make-handler)
        resp    (handler (make-request :options "/api/communities"
                           {:headers {"origin" "http://localhost:3000"
                                      "access-control-request-method" "POST"}}))]
    (is (= 204 (:status resp)))
    (is (= "http://localhost:3000"
           (get-in resp [:headers "Access-Control-Allow-Origin"])))))

(deftest cors-disallowed-origin-no-cors-headers
  (let [handler (make-handler {:cors {:allowed-origins ["https://allowed.example.com"]}
                               :session {:cookie-secure true}})
        resp    (handler (make-request :get "/health"
                           {:headers {"origin" "https://evil.example.com"}}))]
    (is (= 200 (:status resp)))
    (is (nil? (get-in resp [:headers "Access-Control-Allow-Origin"])))))

;;; ---------------------------------------------------------------------------
;;; Trace ID
;;; ---------------------------------------------------------------------------

(deftest trace-id-generated-when-missing
  (let [handler (make-handler)
        resp    (handler (make-request :get "/health"))]
    (is (some? (get-in resp [:headers "X-Trace-Id"])))))

(deftest trace-id-preserved-when-present
  (let [handler   (make-handler)
        custom-id "my-custom-trace-id"
        resp      (handler (make-request :get "/health"
                             {:headers {"x-trace-id" custom-id}}))]
    (is (= custom-id (get-in resp [:headers "X-Trace-Id"])))))

;;; ---------------------------------------------------------------------------
;;; Rate limiting
;;; ---------------------------------------------------------------------------

(deftest rate-limiting-returns-429-when-exhausted
  (testing "rate limiter denies requests after bucket is exhausted"
    (let [limiter (rl/make-limiter)
          ;; SSE has the smallest capacity (5/min) — easiest to exhaust
          results (doall
                    (for [i (range 8)]
                      (rl/check-rate-limit! limiter :sse "test-user")))]
      (is (every? :allowed? (take 5 results))
          "first 5 requests should be allowed")
      (is (some (complement :allowed?) (drop 5 results))
          "requests after capacity should be denied")))
  (testing "429 returned through middleware"
    (let [limiter   (rl/make-limiter)
          handler   (gw-mw/wrap-rate-limit
                      (fn [_] {:status 200 :body "ok"})
                      limiter
                      {:enabled true})
          make-req  (fn [] {:request-method :get
                            :uri "/sse/events"
                            :headers {}
                            :remote-addr "127.0.0.1"})
          results   (doall (for [_ (range 8)]
                             (:status (handler (make-req)))))]
      (is (= 200 (first results)))
      (is (some #{429} results)))))

;;; ---------------------------------------------------------------------------
;;; Query string forwarding
;;; ---------------------------------------------------------------------------

(deftest query-string-is-included-in-target-url
  (testing "resolve-target preserves path for URL construction"
    (let [[svc-key path] (proxy/resolve-target "/api/communities")]
      (is (= :api svc-key))
      (is (= "/api/communities" path)))))
