;; Title: Miniforge.ai
;; Subtitle: An agentic SDLC / fleet-control platform
;; Author: Christopher Lester
;; Line: Founder, Miniforge.ai (project)
;; Copyright 2025-2026 Christopher Lester (christopher@miniforge.ai)
;;
;; Licensed under the Apache License, Version 2.0 (the "License");
;; you may not use this file except in compliance with the License.
;; You may obtain a copy of the License at
;;
;;     http://www.apache.org/licenses/LICENSE-2.0
;;
;; Unless required by applicable law or agreed to in writing, software
;; distributed under the License is distributed on an "AS IS" BASIS,
;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
;; See the License for the specific language governing permissions and
;; limitations under the License.

(ns ai.miniforge.progress-detector.detectors.repair-loop-test
  "Tests for the repair-loop detector.

   Pure-fingerprint cases ported verbatim from agent.reviewer-test
   (PR #762's regression suite). Detector-shaped cases exercise the
   protocol surface — observe, prior-state threading, anomaly
   emission, non-review event passthrough."
  (:require
   [clojure.test :refer [deftest is testing]]
   [ai.miniforge.progress-detector.detectors.repair-loop :as sut]
   [ai.miniforge.progress-detector.protocol :as proto]))

;------------------------------------------------------------------------------ Layer 0
;; Fixtures (lifted from PR #762's regression suite)

(def ^:private blocking-issue-a
  {:severity :blocking :file "src/foo.clj" :line 12 :description "Null pointer access"})

(def ^:private blocking-issue-b
  {:severity :blocking :file "src/bar.clj" :line 7  :description "Bad arity"})

(def ^:private warning-issue
  {:severity :warning :file "src/foo.clj" :line 90 :description "Inefficient loop"})

(def ^:private nit-issue
  {:severity :nit :file "src/foo.clj" :line 4 :description "Stale comment"})

(defn- review-event
  "Wrap a review map in the observation envelope the detector consumes."
  ([review]      (review-event review 1))
  ([review seq#] {:seq seq# :review/artifact review}))

(defn- count-anomalies [state]
  (count (:anomalies state)))

(defn- first-anomaly [state]
  (first (:anomalies state)))

;------------------------------------------------------------------------------ Layer 1
;; review-fingerprint — ported verbatim

(deftest review-fingerprint-includes-blocking-and-warning-test
  (testing "fingerprint covers actionable severities (:blocking + :warning)"
    (let [fp (sut/review-fingerprint
              {:review/issues [blocking-issue-a warning-issue]})]
      (is (= 2 (count fp)) ":blocking and :warning both flow through")
      (is (every? vector? fp) "each entry is a tuple"))))

(deftest review-fingerprint-excludes-nits-test
  (testing ":nit issues are excluded — long-tail polish, not stagnation"
    (let [fp (sut/review-fingerprint
              {:review/issues [blocking-issue-a nit-issue]})]
      (is (= 1 (count fp)) "only the blocking issue counted")
      (is (= :blocking (-> fp first first))))))

(deftest review-fingerprint-is-order-independent-test
  (testing "issue order does not affect the fingerprint"
    (let [fp1 (sut/review-fingerprint
               {:review/issues [blocking-issue-a blocking-issue-b]})
          fp2 (sut/review-fingerprint
               {:review/issues [blocking-issue-b blocking-issue-a]})]
      (is (= fp1 fp2)
          "fingerprint is sorted before comparison"))))

(deftest review-fingerprint-empty-when-no-actionable-issues-test
  (testing "approved review with only nits has empty fingerprint"
    (is (= [] (sut/review-fingerprint
               {:review/issues [nit-issue]})))
    (is (= [] (sut/review-fingerprint
               {:review/issues []})))))

(deftest review-fingerprint-discriminates-on-description-change-test
  (testing "fingerprint changes when an issue description changes"
    (let [original (sut/review-fingerprint
                    {:review/issues [blocking-issue-a]})
          edited   (sut/review-fingerprint
                    {:review/issues [(assoc blocking-issue-a
                                            :description "Different bug")]})]
      (is (not= original edited)
          "different description ⇒ different fingerprint"))))

(deftest review-fingerprint-survives-whitespace-reformatting-test
  (testing "trivial whitespace differences don't read as progress"
    (let [base     (sut/review-fingerprint
                    {:review/issues [(assoc blocking-issue-a
                                            :description "Null pointer access")]})
          reflowed (sut/review-fingerprint
                    {:review/issues [(assoc blocking-issue-a
                                            :description "  Null   pointer\n access  ")]})]
      (is (= base reflowed)
          "whitespace normalization keeps the fingerprint stable across reformats"))))

(deftest review-fingerprint-includes-failed-gates-test
  (testing "gate-only mode: failed gate-results contribute to the fingerprint"
    (let [fp (sut/review-fingerprint
              {:review/gate-results [{:gate-id :syntax
                                      :passed? false
                                      :errors [{:message "Unbalanced paren at line 8"}]}
                                     {:gate-id :lint
                                      :passed? true
                                      :errors []}]})]
      (is (= 1 (count fp)) "only the failed gate contributes")
      (is (= :blocking (-> fp first first))
          "failed gates fingerprint as :blocking severity")
      (is (= ":gate/syntax" (-> fp first second))
          "gate id is the file slot for sortable identification"))))

;------------------------------------------------------------------------------ Layer 1
;; stagnated? — ported verbatim

(deftest stagnated?-true-on-identical-fingerprints-test
  (testing "two consecutive identical fingerprints with non-empty issues ⇒ stagnated"
    (let [fp (sut/review-fingerprint
              {:review/issues [blocking-issue-a]})]
      (is (true? (sut/stagnated? fp fp))))))

(deftest stagnated?-false-on-first-iteration-test
  (testing "no prior fingerprint ⇒ not stagnated (first review never short-circuits)"
    (let [fp (sut/review-fingerprint
              {:review/issues [blocking-issue-a]})]
      (is (false? (boolean (sut/stagnated? nil fp)))))))

(deftest stagnated?-false-on-empty-fingerprint-test
  (testing "no actionable issues ⇒ not stagnated regardless of prior"
    (is (false? (boolean (sut/stagnated? [] []))))
    (is (false? (boolean (sut/stagnated?
                          (sut/review-fingerprint
                           {:review/issues [blocking-issue-a]})
                          []))))))

(deftest stagnated?-false-on-progress-test
  (testing "fingerprint changed between iterations ⇒ progress, not stagnated"
    (let [fp1 (sut/review-fingerprint
               {:review/issues [blocking-issue-a blocking-issue-b]})
          fp2 (sut/review-fingerprint
               {:review/issues [blocking-issue-b]})]
      (is (false? (boolean (sut/stagnated? fp1 fp2)))
          "one issue resolved between iterations is real progress"))))

;------------------------------------------------------------------------------ Layer 2
;; Detector protocol surface

(deftest first-review-no-anomaly-test
  (testing "first review never fires (no prior fingerprint)"
    (let [det   (sut/make-repair-loop-detector)
          state (-> (proto/init det {})
                    (#(proto/observe det %
                                     (review-event
                                      {:review/issues [blocking-issue-a]}))))]
      (is (zero? (count-anomalies state))))))

(deftest two-identical-reviews-fires-mechanical-test
  (testing "second review with same fingerprint as prior ⇒ mechanical anomaly"
    (let [det    (sut/make-repair-loop-detector)
          review {:review/issues [blocking-issue-a blocking-issue-b]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review 1)))
                     (#(proto/observe det % (review-event review 2))))
          a      (first-anomaly state)]
      (is (= 1 (count-anomalies state)))
      (is (= :mechanical (get-in a [:anomaly/data :anomaly/class])))
      (is (= :error      (get-in a [:anomaly/data :anomaly/severity])))
      (is (= :anomalies.review/stagnation
             (get-in a [:anomaly/data :anomaly/category])))
      (is (= :detector/repair-loop
             (get-in a [:anomaly/data :detector/kind]))))))

(deftest review-progress-suppresses-anomaly-test
  (testing "second review with different fingerprint ⇒ no anomaly"
    (let [det     (sut/make-repair-loop-detector)
          rev1    {:review/issues [blocking-issue-a blocking-issue-b]}
          rev2    {:review/issues [blocking-issue-b]}
          state   (-> (proto/init det {})
                      (#(proto/observe det % (review-event rev1 1)))
                      (#(proto/observe det % (review-event rev2 2))))]
      (is (zero? (count-anomalies state))
          "fingerprint changed ⇒ progress, not stagnation"))))

(deftest empty-current-fingerprint-suppresses-anomaly-test
  (testing "second review with no actionable issues ⇒ no anomaly"
    (let [det    (sut/make-repair-loop-detector)
          rev1   {:review/issues [blocking-issue-a]}
          rev2   {:review/issues [nit-issue]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event rev1 1)))
                     (#(proto/observe det % (review-event rev2 2))))]
      (is (zero? (count-anomalies state))
          ":approved-equivalent (only nits) is progress"))))

(deftest non-review-event-passthrough-test
  (testing "events without :review/artifact are passed through unchanged"
    (let [det    (sut/make-repair-loop-detector)
          state0 (proto/init det {})
          tool-event {:tool/id :tool/Read :seq 1 :tool/input "x"}]
      (is (= state0 (proto/observe det state0 tool-event))
          "tool-event observation is a pure passthrough"))))

(deftest gate-only-mode-stagnation-test
  (testing "two consecutive failed gate-only reviews fire stagnation"
    (let [det    (sut/make-repair-loop-detector)
          review {:review/gate-results [{:gate-id :syntax
                                          :passed? false
                                          :errors [{:message "bad paren"}]}]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review 1)))
                     (#(proto/observe det % (review-event review 2))))]
      (is (= 1 (count-anomalies state))
          "gate-only mode also surfaces stagnation"))))

(deftest anomaly-evidence-bounded-test
  (testing "anomaly evidence carries summary + event-ids + fingerprint"
    (let [det    (sut/make-repair-loop-detector)
          review {:review/issues [blocking-issue-a]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review 1)))
                     (#(proto/observe det % (review-event review 2))))
          ev     (get-in (first-anomaly state) [:anomaly/data :anomaly/evidence])]
      (is (string? (:summary ev)))
      (is (vector? (:event-ids ev)))
      (is (string? (:fingerprint ev)))
      (is (true? (:redacted? ev))))))

;------------------------------------------------------------------------------ Layer 4
;; Fuzzy stagnation — the real-world loop catcher
;;
;; The 2026-05-19 dogfood (workflow 55eb2c77) burned 3 hours in a
;; review→implement loop where every iteration's strict fingerprint
;; was different (the LLM rephrased issue descriptions and line numbers
;; shifted after edits) but the SAME files kept getting flagged at
;; :blocking severity. The strict signal correctly stayed quiet; the
;; fuzzy signal exists to catch exactly this case.

(def ^:private blocking-issue-a-rephrased
  {:severity :blocking :file "src/foo.clj" :line 14
   :description "Null pointer dereference on input arg"})

(def ^:private blocking-issue-a-moved-line
  {:severity :blocking :file "src/foo.clj" :line 42
   :description "Null pointer access"})

(deftest review-fingerprint-fuzzy-returns-file-severity-set-test
  (testing "fuzzy fingerprint reduces to a set of [severity file] pairs"
    (let [fp (sut/review-fingerprint-fuzzy
              {:review/issues [blocking-issue-a warning-issue]})]
      (is (set? fp))
      (is (= #{[:blocking "src/foo.clj"]
               [:warning  "src/foo.clj"]}
             fp)))))

(deftest review-fingerprint-fuzzy-deduplicates-same-pair-test
  (testing "two issues at the same severity in the same file collapse to one pair"
    ;; Mock-shape bugs from the 55eb2c77 loop produced multiple
    ;; :blocking-severity issues in the same test file each iteration —
    ;; the fuzzy fingerprint must collapse them so the SIGNAL is
    ;; \"this file keeps producing blocking issues\" not
    ;; \"there are exactly N issues this round.\"
    (let [fp (sut/review-fingerprint-fuzzy
              {:review/issues [blocking-issue-a blocking-issue-a-rephrased
                               blocking-issue-a-moved-line]})]
      (is (= #{[:blocking "src/foo.clj"]} fp)))))

(deftest review-fingerprint-fuzzy-excludes-nits-test
  (testing "nits are still excluded from the fuzzy fingerprint"
    (is (= #{} (sut/review-fingerprint-fuzzy
                {:review/issues [nit-issue]})))))

(deftest review-fingerprint-fuzzy-includes-failed-gates-test
  (testing "failed gate-results contribute virtual [:blocking :gate/X] pairs"
    (let [fp (sut/review-fingerprint-fuzzy
              {:review/issues       []
               :review/gate-results [{:gate-id :format :passed? false
                                      :errors [{:message "fmt"}]}]})]
      (is (= #{[:blocking ":gate/format"]} fp)))))

(deftest fuzzy-stagnated?-true-when-pair-sets-match-test
  (testing "fuzzy stagnation fires when (file × severity) sets repeat"
    ;; Same pair set, different lines, different descriptions — the
    ;; strict detector would NOT fire here, the fuzzy detector MUST.
    (let [prior   #{[:blocking "src/foo.clj"]}
          current #{[:blocking "src/foo.clj"]}]
      (is (true? (sut/fuzzy-stagnated? prior current))))))

(deftest fuzzy-stagnated?-false-on-first-iteration-test
  (is (false? (boolean (sut/fuzzy-stagnated? nil #{[:blocking "src/foo.clj"]})))))

(deftest fuzzy-stagnated?-false-on-empty-current-test
  (is (false? (boolean (sut/fuzzy-stagnated? #{[:blocking "src/foo.clj"]} #{})))))

(deftest fuzzy-stagnated?-false-when-pair-sets-differ-test
  (testing "swapping in a different file or severity counts as progress"
    (is (false? (boolean (sut/fuzzy-stagnated?
                          #{[:blocking "src/foo.clj"]}
                          #{[:blocking "src/bar.clj"]}))))
    (is (false? (boolean (sut/fuzzy-stagnated?
                          #{[:blocking "src/foo.clj"]}
                          #{[:warning  "src/foo.clj"]}))))))

;------------------------------------------------------------------------------ Layer 5
;; Detector wiring — fuzzy anomaly emission

(deftest detector-emits-fuzzy-anomaly-when-strict-misses-test
  (testing "two reviews with same (file × severity) but drifting descriptions fire a fuzzy anomaly, NOT a strict one"
    ;; This is the regression pin for the 55eb2c77 loop pattern.
    (let [det    (sut/make-repair-loop-detector)
          review-1 {:review/issues [blocking-issue-a]}
          review-2 {:review/issues [blocking-issue-a-moved-line]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review-1 1)))
                     (#(proto/observe det % (review-event review-2 2))))
          anoms  (:anomalies state)]
      (is (= 1 (count anoms))
          "exactly one anomaly — strict-stagnation must NOT fire on drift")
      (is (= :anomalies.review/fuzzy-stagnation
             (get-in (first anoms) [:anomaly/data :anomaly/category]))
          "the emitted anomaly must be the fuzzy category")
      (is (= :heuristic
             (get-in (first anoms) [:anomaly/data :anomaly/class]))
          "fuzzy is a heuristic detector signal")
      (is (= :warn
             (get-in (first anoms) [:anomaly/data :anomaly/severity]))
          "fuzzy fires at :warn severity (meta-agent intervention signal, not terminator)")
      (is (= "[[:blocking \"src/foo.clj\"]]"
             (get-in (first anoms) [:anomaly/data :anomaly/evidence :fingerprint]))
          "fingerprint evidence is serialized in deterministic order"))))

(deftest detector-emits-only-strict-when-both-would-match-test
  (testing "verbatim repeat fires strict ONLY — no double-emission to confuse downstream consumers"
    (let [det    (sut/make-repair-loop-detector)
          review {:review/issues [blocking-issue-a]}
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review 1)))
                     (#(proto/observe det % (review-event review 2))))
          anoms  (:anomalies state)]
      (is (= 1 (count anoms)))
      (is (= :anomalies.review/stagnation
             (get-in (first anoms) [:anomaly/data :anomaly/category]))
          "strict wins; fuzzy is suppressed to avoid double-signalling"))))

(deftest detector-progress-clears-both-signals-test
  (testing "implementer changing the set of flagged files clears both strict and fuzzy"
    (let [det    (sut/make-repair-loop-detector)
          review-1 {:review/issues [blocking-issue-a]}
          review-2 {:review/issues [blocking-issue-b]}  ; different file
          state  (-> (proto/init det {})
                     (#(proto/observe det % (review-event review-1 1)))
                     (#(proto/observe det % (review-event review-2 2))))]
      (is (zero? (count-anomalies state))
          "real progress (file set changed) must not fire either signal"))))
