;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL

(ns backend-tests.rpc-audit-test
  (:require
   [app.common.pprint :as pp]
   [app.common.time :as ct]
   [app.common.uuid :as uuid]
   [app.config :as cf]
   [app.db :as db]
   [app.loggers.audit :as audit]
   [app.rpc :as-alias rpc]
   [app.util.services :as sv]
   [backend-tests.helpers :as th]
   [clojure.test :as t]
   [yetti.request]))

(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)

(defn decode-row
  [{:keys [props context] :as row}]
  (cond-> row
    (db/pgobject? props) (assoc :props (db/decode-transit-pgobject props))
    (db/pgobject? context) (assoc :context (db/decode-transit-pgobject context))))

(def http-request
  (reify
    yetti.request/IRequest
    (get-header [_ name]
      (case name
        "x-forwarded-for" "127.0.0.44"
        "x-real-ip" "127.0.0.43"))))

(t/deftest push-events-1
  (with-redefs [app.config/flags #{:audit-log}]
    (let [prof    (th/create-profile* 1 {:is-active true})
          team-id (:default-team-id prof)
          proj-id (:default-project-id prof)

          params  {::th/type :push-audit-events
                   ::rpc/profile-id (:id prof)
                   :events [{:name "navigate"
                             :props {:project-id (str proj-id)
                                     :team-id (str team-id)
                                     :route "dashboard-files"}
                             :context {:engine "blink"}
                             :profile-id (:id prof)
                             :timestamp (ct/now)
                             :type "action"}]}

          params  (with-meta params
                    {:app.http/request http-request})

          out     (th/command! params)]
      ;; (th/print-result! out)
      (t/is (nil? (:error out)))
      (t/is (nil? (:result out)))

      (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
                                (mapv decode-row))]
        ;; (pp/pprint rows)
        (t/is (= 1 (count rows)))
        (t/is (= (:id prof) (:profile-id row)))
        (t/is (= "navigate" (:name row)))
        (t/is (= "frontend" (:source row)))))))

(t/deftest push-events-2
  (with-redefs [app.config/flags #{:audit-log}]
    (let [prof    (th/create-profile* 1 {:is-active true})
          team-id (:default-team-id prof)
          proj-id (:default-project-id prof)

          params  {::th/type :push-audit-events
                   ::rpc/profile-id (:id prof)
                   :events [{:name "navigate"
                             :props {:project-id (str proj-id)
                                     :team-id (str team-id)
                                     :route "dashboard-files"}
                             :context {:engine "blink"}
                             :profile-id uuid/zero
                             :timestamp (ct/now)
                             :type "action"}]}
          params  (with-meta params
                    {:app.http/request http-request})
          out     (th/command! params)]
      ;; (th/print-result! out)
      (t/is (nil? (:error out)))
      (t/is (nil? (:result out)))

      (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
                                (mapv decode-row))]
        ;; (pp/pprint rows)
        (t/is (= 1 (count rows)))
        (t/is (= (:id prof) (:profile-id row)))
        (t/is (= "navigate" (:name row)))
        (t/is (= "frontend" (:source row)))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TELEMETRY MODE (frontend ingest)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(t/deftest push-events-telemetry-mode-stores-anonymized-row
  ;; When telemetry is enabled and audit-log is NOT, frontend events
  ;; must be stored with source="telemetry", empty props, zeroed ip,
  ;; and context filtered to safe keys only.
  (with-redefs [cf/flags #{:telemetry}]
    (let [prof    (th/create-profile* 1 {:is-active true})
          team-id (:default-team-id prof)
          proj-id (:default-project-id prof)

          params  {::th/type :push-audit-events
                   ::rpc/profile-id (:id prof)
                   :events [{:name "navigate"
                             :props {:project-id (str proj-id)
                                     :team-id (str team-id)
                                     :route "dashboard-files"}
                             :context {:browser "Chrome"
                                       :browser-version "120.0"
                                       :os "Linux"
                                       :version "2.0.0"
                                       :session "should-be-stripped"
                                       :external-session-id "also-stripped"
                                       :initiator "app"}
                             :timestamp (ct/now)
                             :type "action"}]}

          params  (with-meta params
                    {:app.http/request http-request})
          out     (th/command! params)]

      (t/is (nil? (:error out)))
      (t/is (nil? (:result out)))

      (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
                                (mapv decode-row))]
        (t/is (= 1 (count rows)))
        ;; source is telemetry:frontend, not frontend
        (t/is (= "telemetry:frontend" (:source row)))
        ;; profile-id preserved
        (t/is (= (:id prof) (:profile-id row)))
        ;; event name preserved
        (t/is (= "navigate" (:name row)))
        ;; navigate events keep route and team-id; other keys stripped
        (t/is (= {:route "dashboard-files"
                  :team-id (str team-id)}
                 (:props row)))
        ;; ip zeroed
        (t/is (= "0.0.0.0" (str (:ip-addr row))))
        ;; timestamps truncated to day precision
        (let [day-now (ct/truncate (ct/now) :days)]
          (t/is (= day-now (:created-at row)))
          (t/is (= day-now (:tracked-at row))))
        ;; context only contains safe keys
        (let [ctx (:context row)]
          (t/is (contains? ctx :browser))
          (t/is (= "Chrome" (:browser ctx)))
          (t/is (contains? ctx :os))
          (t/is (= "Linux" (:os ctx)))
          ;; session-linking keys stripped
          (t/is (not (contains? ctx :session)))
          (t/is (not (contains? ctx :external-session-id))))))))

(t/deftest push-events-both-flags-creates-two-rows
  ;; When both :audit-log and :telemetry flags are active, two rows
  ;; should be stored: one full audit entry and one telemetry entry.
  (with-redefs [cf/flags #{:audit-log :telemetry}]
    (let [prof   (th/create-profile* 1 {:is-active true})
          params {::th/type :push-audit-events
                  ::rpc/profile-id (:id prof)
                  :events [{:name "navigate"
                            :props {:route "dashboard"}
                            :context {:browser "Chrome"
                                      :version "2.0.0"
                                      :initiator "app"}
                            :timestamp (ct/now)
                            :type "action"}]}
          params (with-meta params
                   {:app.http/request http-request})
          out    (th/command! params)]

      (t/is (nil? (:error out)))

      (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"])
                                      (mapv decode-row))]
        (t/is (= 2 (count rows)))
        ;; First row: full audit-log entry
        (t/is (= "frontend" (:source row1)))
        (t/is (contains? (:props row1) :route))
        (t/is (not= "0.0.0.0" (str (:ip-addr row1))))
        ;; Second row: telemetry entry
        (t/is (= "telemetry:frontend" (:source row2)))
        (t/is (= "0.0.0.0" (str (:ip-addr row2))))
        (let [day-now (ct/truncate (ct/now) :days)]
          (t/is (= day-now (:created-at row2)))
          (t/is (= day-now (:tracked-at row2))))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BACKEND PROCESS-EVENT PATH (RPC commands)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(t/deftest backend-process-event-only-audit-log
  (with-redefs [cf/flags #{:audit-log}]
    (let [prof  (th/create-profile* 1 {:is-active true})
          event {:id (uuid/next)
                 :type "action"
                 :name "test-cmd"
                 :profile-id (:id prof)
                 :props {:full-key "full-val"}
                 :context {:version "2.0.0" :initiator "app"}
                 :tracked-at (ct/now)
                 :created-at (ct/now)
                 :source "backend"}]
      (audit/submit* th/*system* event)
      (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
                                (mapv decode-row))]
        (t/is (= 1 (count rows)))
        (t/is (= "backend" (:source row)))
        (t/is (= "full-val" (get-in row [:props :full-key])))
        (t/is (not= "0.0.0.0" (str (:ip-addr row))))))))

(t/deftest backend-process-event-only-telemetry
  (with-redefs [cf/flags #{:telemetry}]
    (let [prof  (th/create-profile* 1 {:is-active true})
          event {:id (uuid/next)
                 :type "action"
                 :name "test-cmd"
                 :profile-id (:id prof)
                 :props {:full-key "full-val"}
                 :context {:version "2.0.0" :initiator "app"}
                 :tracked-at (ct/now)
                 :created-at (ct/now)
                 :source "backend"}]
      (audit/submit* th/*system* event)
      (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
                                (mapv decode-row))]
        (t/is (= 1 (count rows)))
        (t/is (= "telemetry:backend" (:source row)))
        (t/is (= "0.0.0.0" (str (:ip-addr row))))))))

(t/deftest backend-process-event-both-flags-creates-two-rows
  ;; When both :audit-log and :telemetry are active, the backend
  ;; process-event must store two rows: one full audit entry and one
  ;; telemetry entry.
  (with-redefs [cf/flags #{:audit-log :telemetry}]
    (let [prof  (th/create-profile* 1 {:is-active true})
          event {:id (uuid/next)
                 :type "action"
                 :name "test-cmd"
                 :profile-id (:id prof)
                 :props {:keep-me "important"}
                 :context {:version "2.0.0" :initiator "app"}
                 :tracked-at (ct/now)
                 :created-at (ct/now)
                 :source "backend"}]
      (audit/submit* th/*system* event)
      (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"])
                                      (mapv decode-row))]
        (t/is (= 2 (count rows)))
        ;; First row: full audit-log entry
        (t/is (= "backend" (:source row1)))
        (t/is (= "important" (get-in row1 [:props :keep-me])))
        (t/is (not= "0.0.0.0" (str (:ip-addr row1))))
        ;; Second row: telemetry entry
        (t/is (= "telemetry:backend" (:source row2)))
        (t/is (= "0.0.0.0" (str (:ip-addr row2))))
        (let [day-now (ct/truncate (ct/now) :days)]
          (t/is (= day-now (:created-at row2)))
          (t/is (= day-now (:tracked-at row2))))))))

(t/deftest push-events-disabled-when-no-flags-and-no-telemetry
  ;; When neither audit-log nor telemetry is enabled, no rows should
  ;; be stored.
  (with-redefs [cf/flags #{}]
    (let [prof   (th/create-profile* 1 {:is-active true})
          params {::th/type :push-audit-events
                  ::rpc/profile-id (:id prof)
                  :events [{:name "navigate"
                            :props {:route "dashboard"}
                            :timestamp (ct/now)
                            :type "action"}]}
          params (with-meta params
                   {:app.http/request http-request})
          out    (th/command! params)]

      (t/is (nil? (:error out)))
      (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PURE HELPER UNIT TESTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(t/deftest extract-utm-params-utm
  ;; UTM params are namespaced under :penpot
  (let [result (audit/extract-utm-params {:utm_source "google"
                                          :utm_medium "cpc"
                                          :utm_campaign "spring"
                                          :other "ignored"})]
    (t/is (= "google" (:penpot/utm-source result)))
    (t/is (= "cpc" (:penpot/utm-medium result)))
    (t/is (= "spring" (:penpot/utm-campaign result)))
    (t/is (not (contains? result :other)))))

(t/deftest extract-utm-params-mtm
  ;; MTM params are also namespaced under :penpot
  (let [result (audit/extract-utm-params {:mtm_source "newsletter"
                                          :mtm_medium "email"})]
    (t/is (= "newsletter" (:penpot/mtm-source result)))
    (t/is (= "email" (:penpot/mtm-medium result)))))

(t/deftest extract-utm-params-empty
  (t/is (= {} (audit/extract-utm-params {})))
  (t/is (= {} (audit/extract-utm-params {:foo "bar" :baz 42}))))

(t/deftest profile->props-selects-and-merges
  ;; Selects profile-props keys and merges with (:props profile)
  (let [profile {:id (uuid/next)
                 :fullname "John"
                 :email "john@example.com"
                 :is-active true
                 :lang "en"
                 :deleted-field "gone"
                 :props {:custom-key "custom-val"
                         :newsletter-updates true}}
        result  (audit/profile->props profile)]
    ;; Selected keys from profile
    (t/is (= "John" (:fullname result)))
    (t/is (= "john@example.com" (:email result)))
    (t/is (true? (:is-active result)))
    (t/is (= "en" (:lang result)))
    ;; Merged from (:props profile)
    (t/is (= "custom-val" (:custom-key result)))
    (t/is (true? (:newsletter-updates result)))
    ;; Keys not in profile-props are excluded
    (t/is (not (contains? result :deleted-field)))))

(t/deftest profile->props-removes-nils
  (let [profile {:id (uuid/next) :fullname nil :email "a@b.com"}
        result  (audit/profile->props profile)]
    (t/is (not (contains? result :fullname)))
    (t/is (= "a@b.com" (:email result)))))

(t/deftest clean-props-removes-reserved
  ;; Reserved props (:session-id, :password, :old-password, :token) are stripped
  (let [props {:name "test"
               :session-id "sess-123"
               :password "secret"
               :old-password "old-secret"
               :token "tok-456"
               :valid-key "kept"}
        result (audit/clean-props props)]
    (t/is (= "test" (:name result)))
    (t/is (= "kept" (:valid-key result)))
    (t/is (not (contains? result :session-id)))
    (t/is (not (contains? result :password)))
    (t/is (not (contains? result :old-password)))
    (t/is (not (contains? result :token)))))

(t/deftest clean-props-removes-qualified-keys
  ;; Qualified keywords (namespaced) are stripped
  (let [props {:simple "kept"
               ::namespaced "stripped"
               :app.rpc/also-stripped true}
        result (audit/clean-props props)]
    (t/is (= "kept" (:simple result)))
    (t/is (not (contains? result ::namespaced)))
    (t/is (not (contains? result :app.rpc/also-stripped)))))

(t/deftest clean-props-removes-nils
  (let [props {:a nil :b "val" :c nil}
        result (audit/clean-props props)]
    (t/is (= "val" (:b result)))
    (t/is (not (contains? result :a)))
    (t/is (not (contains? result :c)))))

(t/deftest get-external-session-id-valid
  (let [request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-external-session-id" "abc-123")))]
    (t/is (= "abc-123" (audit/get-external-session-id request)))))

(t/deftest get-external-session-id-nil-when-missing
  (let [request (reify yetti.request/IRequest
                  (get-header [_ _] nil))]
    (t/is (nil? (audit/get-external-session-id request)))))

(t/deftest get-external-session-id-nil-when-null-string
  (let [request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-external-session-id" "null")))]
    (t/is (nil? (audit/get-external-session-id request)))))

(t/deftest get-external-session-id-nil-when-blank
  (let [request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-external-session-id" "   ")))]
    (t/is (nil? (audit/get-external-session-id request)))))

(t/deftest get-external-session-id-nil-when-too-long
  (let [long-id (apply str (repeat 300 "x"))
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-external-session-id" long-id)))]
    (t/is (nil? (audit/get-external-session-id request)))))

(t/deftest get-client-user-agent-valid
  (let [request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "user-agent" "Mozilla/5.0 (Test)")))]
    (t/is (= "Mozilla/5.0 (Test)" (audit/get-client-user-agent request)))))

(t/deftest get-client-user-agent-nil-when-missing
  (let [request (reify yetti.request/IRequest
                  (get-header [_ _] nil))]
    (t/is (nil? (audit/get-client-user-agent request)))))

(t/deftest get-client-user-agent-truncates-long
  (let [long-ua (apply str (repeat 600 "x"))
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "user-agent" long-ua)))]
    (t/is (<= (count (audit/get-client-user-agent request)) 500))))

(t/deftest get-client-event-origin-valid
  (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-event-origin" "workspace")))]
    (t/is (= "workspace" (get-client-event-origin request)))))

(t/deftest get-client-event-origin-nil-when-null
  (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-event-origin" "null")))]
    (t/is (nil? (get-client-event-origin request)))))

(t/deftest get-client-event-origin-nil-when-blank
  (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-event-origin" "  ")))]
    (t/is (nil? (get-client-event-origin request)))))

(t/deftest get-client-event-origin-truncates-long
  (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
        long-origin (apply str (repeat 300 "a"))
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-event-origin" long-origin)))]
    (t/is (<= (count (get-client-event-origin request)) 200))))

(t/deftest get-client-version-valid
  (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-frontend-version" "2.0.0")))]
    (t/is (= "2.0.0" (get-client-version request)))))

(t/deftest get-client-version-nil-when-null
  (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-frontend-version" "null")))]
    (t/is (nil? (get-client-version request)))))

(t/deftest get-client-version-nil-when-blank
  (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
        request (reify yetti.request/IRequest
                  (get-header [_ name]
                    (case name "x-frontend-version" "  ")))]
    (t/is (nil? (get-client-version request)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INSERT DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(t/deftest insert-only-runs-with-audit-log-flag
  ;; insert must be a no-op when :audit-log flag is not set
  (with-redefs [app.config/flags #{:telemetry}]
    (audit/insert th/*system* {:name "test" :type "action"})
    (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))

(t/deftest insert-sets-defaults
  ;; insert must set defaults and persist when :audit-log is set
  (with-redefs [app.config/flags #{:audit-log}]
    (audit/insert th/*system* {:name "test-action" :type "action"})
    (let [[row] (->> (th/db-exec! ["select * from audit_log"])
                     (mapv decode-row))]
      (t/is (some? row))
      (t/is (= "test-action" (:name row)))
      (t/is (= "action" (:type row)))
      (t/is (= "backend" (:source row)))
      (t/is (some? (:id row)))
      (t/is (some? (:created-at row)))
      (t/is (some? (:tracked-at row)))
      (t/is (= {} (:props row)))
      (t/is (= {} (:context row))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID CONVERSION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(t/deftest prepare-rpc-event-converts-string-profile-id-to-uuid
  ;; When result contains a string :profile-id (e.g. from error reports),
  ;; prepare-rpc-event must convert it to a UUID for audit schema compliance.
  (let [prof       (th/create-profile* 1 {:is-active true})
        string-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
        expected   #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
        mdata      {::sv/name "test-cmd"}
        params     {::rpc/profile-id (:id prof)
                    ::rpc/request-id (uuid/next)
                    ::rpc/request-at (ct/now)}
        mock-req   (reify
                     yetti.request/IRequest
                     (get-header [_ _] nil)
                     (remote-addr [_] "127.0.0.1"))
        params     (with-meta params {:app.http/request mock-req})
        result     {:profile-id string-pid :some-data "value"}
        event      (audit/prepare-rpc-event th/*system* mdata params result)]
    ;; profile-id must be a UUID, not a string
    (t/is (uuid? (:profile-id event)))
    (t/is (= expected (:profile-id event)))))

(t/deftest prepare-rpc-event-handles-invalid-string-profile-id
  ;; When result contains an invalid string :profile-id, it should fall back
  ;; to the RPC params profile-id (which is always a valid UUID).
  (let [prof   (th/create-profile* 1 {:is-active true})
        mdata  {::sv/name "test-cmd"}
        params {::rpc/profile-id (:id prof)
                ::rpc/request-id (uuid/next)
                ::rpc/request-at (ct/now)}
        mock-req (reify
                   yetti.request/IRequest
                   (get-header [_ _] nil)
                   (remote-addr [_] "127.0.0.1"))
        params (with-meta params {:app.http/request mock-req})
        result {:profile-id "not-a-valid-uuid"}
        event  (audit/prepare-rpc-event th/*system* mdata params result)]
    ;; profile-id must fall back to the RPC params profile-id
    (t/is (uuid? (:profile-id event)))
    (t/is (= (:id prof) (:profile-id event)))))
