;; 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.workflow.merge-parent-branches-integration-test
  "Integration tests for the v2 `merge-parent-branches!` orchestrator
   helper. Each test sets up a real git repo in a temp directory, runs
   real `git` commands, and asserts on the resulting refs / anomalies.
   Slow-ish but high-fidelity — these test the actual git behavior the
   spec depends on (ort vs octopus, ancestor collapse, unrelated
   histories, conflict surfacing)."
  (:require
   [babashka.fs :as fs]
   [clojure.java.shell :as shell]
   [clojure.string :as str]
   [clojure.test :refer [deftest is testing use-fixtures]]
   [ai.miniforge.agent.interface :as agent]
   [ai.miniforge.dag-executor.interface :as dag]
   [ai.miniforge.response.interface :as response]
   [ai.miniforge.workflow.dag-orchestrator :as dag-orch]
   [ai.miniforge.workflow.messages :as messages]))

;------------------------------------------------------------------------------ Fixture: temp git repo

(def ^:dynamic *repo* nil)

(defn- run-git!
  "Run a git command in `cwd`. Throws on non-zero exit so test setup
   bugs surface immediately rather than as cryptic downstream failures.
   The throw is dev-internal — it never reaches a user — but the
   message is still routed through the workflow message catalog
   (system-locale entries) so we have one place to audit / change
   error wording."
  [cwd & args]
  (let [r (apply shell/sh "git" "-C" cwd args)]
    (when-not (zero? (:exit r))
      (throw (ex-info (messages/t :dag.merge.system/git-test-failure
                                  {:args (str/join " " args)
                                   :err  (:err r)})
                      {:cwd cwd :args args :result r})))
    r))

(defn- write-file! [cwd path content]
  (let [f (java.io.File. ^String cwd ^String path)]
    (.mkdirs (.getParentFile f))
    (spit f content)))

(defn- commit-file! [cwd path content message]
  (write-file! cwd path content)
  (run-git! cwd "add" path)
  (run-git! cwd "commit" "-m" message))

(defn temp-repo-fixture [f]
  (let [repo (str (fs/create-temp-dir {:prefix "mpb-test-"}))]
    (try
      (run-git! repo "init" "-b" "main")
      (run-git! repo "config" "user.email" "test@miniforge.ai")
      (run-git! repo "config" "user.name" "miniforge-test")
      ;; Defeat any global signing config (1Password, GPG agents, etc.)
      ;; that would otherwise prompt or fail during commits in this
      ;; ephemeral repo. Mirrors the orchestrator's --no-gpg-sign flag
      ;; per spec §3.1: tests run hermetically regardless of dev-machine
      ;; signing setup.
      (run-git! repo "config" "commit.gpgsign" "false")
      (run-git! repo "config" "tag.gpgsign" "false")
      (commit-file! repo "README.md" "initial\n" "init")
      (binding [*repo* repo]
        (f))
      (finally
        (try (fs/delete-tree repo) (catch Throwable _ nil))))))

(use-fixtures :each temp-repo-fixture)

;------------------------------------------------------------------------------ Helpers

(defn- ctx-with-registry
  "Build a context map shaped like the orchestrator's runtime context.
   Registers parents in the registry so resolve-multi-parent-base sees
   them, and points host-repo-path at *repo*."
  [parent-entries]
  {:execution/repo-path *repo*
   :execution/worktree-path *repo*
   :workflow-id "test-run"
   :dag/branch-registry (atom (reduce-kv dag/register-branch
                                         (dag/create-branch-registry)
                                         parent-entries))})

(defn- create-parent-branch!
  "Branch off main, commit a file, return to main. Used to set up
   parent branches with disjoint changes."
  [branch-name path content]
  (run-git! *repo* "checkout" "-b" branch-name "main")
  (commit-file! *repo* path content (str "edit on " branch-name))
  (run-git! *repo* "checkout" "main"))

;------------------------------------------------------------------------------ Tests: 2-parent happy path

(deftest two-parent-disjoint-files-happy-path-test
  (testing "Two parents touching disjoint files merge cleanly via -s ort.
            The result is a real merge commit; the namespaced ref points
            at it; the orchestrator returns dag/ok wrapping {:branch
            :commit-sha ...}."
    (create-parent-branch! "task-a" "src/a.txt" "from a\n")
    (create-parent-branch! "task-b" "src/b.txt" "from b\n")
    (let [task-id "task-c"
          task-def {:task/id task-id
                    :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)
          data (:data result)]
      (is (dag/ok? result))
      (is (str/starts-with? (:branch data) "refs/miniforge/dag-base/test-run/"))
      (is (string? (:commit-sha data)))
      (is (= 40 (count (:commit-sha data)))
          "commit SHA is full 40-char hex (rev-parse default)")
      ;; The merge commit should have two parents
      (let [parents-cmd (run-git! *repo* "rev-list" "--parents" "-n" "1"
                                  (:commit-sha data))
            parts (str/split (str/trim (:out parents-cmd)) #"\s+")]
        (is (= 3 (count parts))
            "merge commit + 2 parents = 3 SHAs"))
      ;; Both files should be present in the merged tree
      (let [tree (run-git! *repo* "ls-tree" "-r" "--name-only"
                           (:commit-sha data))
            files (set (str/split-lines (str/trim (:out tree))))]
        (is (contains? files "src/a.txt"))
        (is (contains? files "src/b.txt"))))))

;------------------------------------------------------------------------------ Tests: ancestor collapse

(deftest ancestor-collapses-to-single-parent-test
  (testing "When parent A is an ancestor of parent B, A's contributions
            are already in B. The collapse algorithm drops A and
            returns single-parent fast path against B — no merge commit
            needed, the existing branch is the right base."
    ;; task-a is an ancestor of task-b (b is built on top of a)
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/a.txt" "from a\n" "edit on a")
    (run-git! *repo* "checkout" "-b" "task-b" "task-a")
    (commit-file! *repo* "src/b.txt" "from b\n" "edit on b")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)
          data (:data result)]
      (is (dag/ok? result))
      (is (:single-parent? data)
          "after ancestor collapse, only task-b remains as effective parent")
      (is (= "task-b" (:branch data))
          "the surviving parent's branch is returned directly")
      (is (some #(= :a (:dropped %)) (:collapsed data))
          ":collapsed records what was absorbed")
      (is (some #(= :b (:absorbed-into %)) (:collapsed data))
          "and which surviving parent absorbed it"))))

;------------------------------------------------------------------------------ Tests: duplicate parent tips

(deftest duplicate-tips-collapse-test
  (testing "When two declared parents resolve to the same SHA (e.g.
            both branches advanced to the same commit), the duplicate
            collapses and the merge becomes single-parent."
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/x.txt" "shared\n" "shared edit")
    ;; task-a-alias points at exactly the same commit as task-a
    (run-git! *repo* "branch" "task-a-alias" "task-a")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :alias]}
          ctx (ctx-with-registry {:a     {:branch "task-a"}
                                  :alias {:branch "task-a-alias"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      (is (dag/ok? result))
      (is (:single-parent? (:data result))
          "duplicate tips collapse to one effective parent"))))

;------------------------------------------------------------------------------ Tests: unrelated histories

(deftest unrelated-histories-anomaly-test
  (testing "When parents share no common ancestor (e.g. one came from a
            separate `git init`), v2 surfaces a typed anomaly rather than
            using `--allow-unrelated-histories`. This protects against
            accidental cross-repo / stray-init situations."
    (create-parent-branch! "task-a" "src/a.txt" "from a\n")
    ;; Build task-b on a synthetic root commit unrelated to main
    (run-git! *repo* "checkout" "--orphan" "task-b")
    (run-git! *repo* "rm" "-rf" ".")
    (commit-file! *repo* "src/b.txt" "from b — unrelated\n" "unrelated root")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      (is (= :anomalies/dag-multi-parent-unrelated-histories
             (:anomaly/category result))))))

;------------------------------------------------------------------------------ Tests: conflict

(deftest conflict-surfaces-unresolvable-via-resolution-loop-test
  (testing "When two parents touch the same file with different content,
            the merge conflicts and Stage 2B routes to the resolution
            sub-workflow. With the default no-op stub agent (Stage 2C
            will replace it with a real LLM-driven implementer), the
            curator detects recurring-conflict on the second iteration
            and the loop terminates as :dag-multi-parent-unresolvable.
            Stage 1B's :dag-multi-parent-conflict terminal is now an
            internal step inside the resolution loop, not a public
            return value."
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/conflict.txt" "from a\n" "a edit")
    (run-git! *repo* "checkout" "-b" "task-b" "main")
    (commit-file! *repo* "src/conflict.txt" "from b\n" "b edit")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      (is (= :anomalies/dag-multi-parent-unresolvable
             (:anomaly/category result)))
      (is (= :curator/recurring-conflict (:resolution/reason result))
          "no-op stub agent → curator finds the same conflict path on
           iteration 2 → recurring-conflict early-out per spec §6.1.2"))))

(deftest conflict-resolves-end-to-end-via-injected-agent-test
  (testing "Full end-to-end success path through merge-parent-branches!:
            a conflicted merge whose resolution-overrides inject a mock
            agent that resolves the markers should write the resolution
            commit to the namespaced ref and return dag/ok with
            :resolved? true and :resolution-iterations populated. This
            is the success contract Stage 2C's real LLM agent will hit;
            until then, this test pins that the wiring works end-to-end
            with a mock."
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/conflict.txt" "from a\n" "a edit")
    (run-git! *repo* "checkout" "-b" "task-b" "main")
    (commit-file! *repo* "src/conflict.txt" "from b\n" "b edit")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          mock-edit (fn [worktree _conflict _iteration]
                      ;; Mock resolves the conflict by overwriting the
                      ;; file with a clean merged version. A real LLM
                      ;; agent would pick the actual content.
                      (spit (str worktree "/src/conflict.txt")
                            "from a\nfrom b\n")
                      ;; Stage the fix so the resolution commit picks it up.
                      (run-git! worktree "add" "src/conflict.txt")
                      {:edits/applied 1})
          ctx (-> (ctx-with-registry {:a {:branch "task-a"}
                                      :b {:branch "task-b"}})
                  (assoc :dag/resolution-overrides
                         {:agent-edit-fn mock-edit}))
          result (dag-orch/merge-parent-branches! ctx task-def)
          data (:data result)]
      (is (dag/ok? result)
          "successful resolution returns dag/ok, not an anomaly")
      (is (true? (:resolved? data))
          ":resolved? observability flag set when success came via the
           resolution loop (vs. direct merge)")
      (is (pos-int? (:resolution-iterations data))
          ":resolution-iterations carries through to the final result")
      (is (str/starts-with? (:branch data) "refs/miniforge/dag-base/")
          "the namespaced ref still names the merge base — downstream
           tasks fork off this ref the same way they would for a
           conflict-free merge")
      (is (string? (:commit-sha data))
          "the resolution commit's SHA is the new merge base"))))

(deftest auto-default-llm-backend-routes-through-agent-driven-edit-fn-test
  (testing "Stage 2C wiring: when context has :llm-backend and no
            explicit :dag/resolution-overrides, the orchestrator's
            derive-resolution-overrides helper auto-builds an
            agent-driven-edit-fn that delegates to the implementer
            agent. We mock agent/create-implementer + agent/invoke
            here so the test doesn't need a real LLM backend; the
            mock invoke writes the resolution as the real implementer
            would (via tool calls). End-to-end success proves the
            wiring fans out correctly: context :llm-backend ⇒
            agent-driven-edit-fn ⇒ agent/invoke ⇒ resolution commit."
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/conflict.txt" "from a\n" "a edit")
    (run-git! *repo* "checkout" "-b" "task-b" "main")
    (commit-file! *repo* "src/conflict.txt" "from b\n" "b edit")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          invoked? (atom false)
          mock-invoke (fn [_agent task ctx]
                        (when-not @invoked?
                          (reset! invoked? true)
                          (let [wt (:execution/worktree-path ctx)]
                            (spit (str wt "/src/conflict.txt")
                                  "from a\nfrom b\n")
                            ;; run-git! throws on non-zero exit; using
                            ;; shell/sh would silently swallow setup
                            ;; failures and surface as an opaque
                            ;; "agent didn't resolve" downstream.
                            (run-git! wt "add" "src/conflict.txt")))
                        (response/success {:edits/applied 1
                                           :task/type (:task/type task)}
                                          nil))]
      (with-redefs [agent/create-implementer (fn [& _] ::mock-impl)
                    agent/invoke mock-invoke]
        (let [ctx (-> (ctx-with-registry {:a {:branch "task-a"}
                                          :b {:branch "task-b"}})
                      (assoc :llm-backend ::mock-backend))
              result (dag-orch/merge-parent-branches! ctx task-def)]
          (is (dag/ok? result)
              "auto-default agent-driven-edit-fn resolved the conflict")
          (is (true? @invoked?)
              "agent/invoke was called via the auto-default closure")
          (is (true? (:resolved? (:data result))))
          (is (pos-int? (:resolution-iterations (:data result)))))))))

;------------------------------------------------------------------------------ Tests: branch unresolvable

(deftest unregistered-branch-falls-back-to-default-test
  (testing "When no parents are registered (orchestrator state shouldn't
            allow this in production but defensive code is correct
            anyway), merge-parent-branches! falls back to the spec
            default branch."
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ;; Empty registry context
          ctx {:execution/repo-path *repo*
               :execution/opts {:branch "main"}
               :workflow-id "test-run"
               :dag/branch-registry (atom (dag/create-branch-registry))}
          result (dag-orch/merge-parent-branches! ctx task-def)
          data (:data result)]
      (is (dag/ok? result))
      (is (:single-parent? data))
      (is (= :no-registered-parents (:fallback-reason data))))))

(deftest registered-branch-not-in-repo-anomaly-test
  (testing "When a registered branch doesn't exist in the host repo
            (the registry is out of sync with reality), v2 surfaces a
            typed branch-unresolvable anomaly instead of crashing."
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a-does-not-exist"}
                                  :b {:branch "task-b-does-not-exist"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      (is (= :anomalies/dag-multi-parent-branch-unresolvable
             (:anomaly/category result))))))

;------------------------------------------------------------------------------ Tests: idempotency cache

(deftest idempotency-second-call-reuses-merge-ref-test
  (testing "Spec §7.2 idempotency: a second call to merge-parent-branches!
            with the same effective inputs reuses the existing namespaced
            ref instead of producing a fresh merge commit. Without the
            cache check, merge commit timestamps would differ on every
            call — same tree, different SHA — defeating the spec's
            'replays of the same effective input reuse the same ref'
            guarantee."
    (create-parent-branch! "task-a" "src/a.txt" "from a\n")
    (create-parent-branch! "task-b" "src/b.txt" "from b\n")
    (let [task-def {:task/id "task-c" :task/deps [:a :b]}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          first-result  (dag-orch/merge-parent-branches! ctx task-def)
          second-result (dag-orch/merge-parent-branches! ctx task-def)
          first-data    (:data first-result)
          second-data   (:data second-result)]
      (is (dag/ok? first-result))
      (is (dag/ok? second-result))
      (is (= (:branch first-data) (:branch second-data))
          "same ref name (input-key is deterministic)")
      (is (= (:commit-sha first-data) (:commit-sha second-data))
          "same commit SHA — second call hit the cache, did NOT produce a
           fresh merge commit. This is the property that justifies the
           input-key derivation in spec §3.2 step 6.")
      (is (true? (:cache-hit? second-data))
          "cache-hit flag exposed for observability — dashboard can show
           how often replays are reusing existing merge work")
      (is (false? (:cache-hit? first-data))
          "first call sets up the cache; cache-hit? is false there"))))

;------------------------------------------------------------------------------ Tests: unsupported strategy

(deftest unsupported-strategy-anomaly-test
  (testing "Strategies not in supported-merge-strategies fail fast with
            a typed anomaly. As of Stage 4a, :git-merge and
            :sequential-merge are supported; any OTHER value still
            surfaces :anomalies/dag-multi-parent-strategy-unsupported."
    (create-parent-branch! "task-a" "src/a.txt" "from a\n")
    (create-parent-branch! "task-b" "src/b.txt" "from b\n")
    (let [task-def {:task/id "task-c"
                    :task/deps [:a :b]
                    ;; Bogus strategy keyword — never supported.
                    :task/merge-strategy :pile-on-merge}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      (is (= :anomalies/dag-multi-parent-strategy-unsupported
             (:anomaly/category result)))
      (is (= :pile-on-merge (:merge/strategy result))
          "the requested strategy is echoed back in the anomaly so the
           operator/dashboard knows what was rejected")
      (is (contains? (:merge/supported result) :git-merge)
          "the anomaly carries the supported-strategies set")
      (is (contains? (:merge/supported result) :sequential-merge)
          ":sequential-merge is now in the supported set"))))

;------------------------------------------------------------------------------ Tests: :sequential-merge

(deftest sequential-merge-three-parent-disjoint-test
  (testing "Three parents touching disjoint files merge cleanly via the
            :sequential-merge strategy: TWO pairwise `ort` merges
            (N parents → N-1 merge invocations), each one a two-parent
            merge against the running result, in plan-declaration order.
            Final HEAD is a chain of two merge commits (p1 merged into
            p0-base, then p2 merged into the result), each with two
            parents. All three files end up in the tree."
    (create-parent-branch! "task-a" "src/a.txt" "from a\n")
    (create-parent-branch! "task-b" "src/b.txt" "from b\n")
    (create-parent-branch! "task-c" "src/c.txt" "from c\n")
    (let [task-def {:task/id "task-d"
                    :task/deps [:a :b :c]
                    :task/merge-strategy :sequential-merge}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}
                                  :c {:branch "task-c"}})
          result (dag-orch/merge-parent-branches! ctx task-def)
          data (:data result)]
      (is (dag/ok? result))
      (is (= :sequential-merge (:strategy data)))
      (is (str/starts-with? (:branch data) "refs/miniforge/dag-base/"))
      (is (= 40 (count (:commit-sha data))))
      (let [tree (run-git! *repo* "ls-tree" "-r" "--name-only" (:commit-sha data))
            files (set (str/split-lines (str/trim (:out tree))))]
        (is (contains? files "src/a.txt"))
        (is (contains? files "src/b.txt"))
        (is (contains? files "src/c.txt")))
      ;; Final HEAD has two parents (the sequential-step before it +
      ;; the last parent merged in). The previous step's commit has two
      ;; parents too. Walking back two steps gets us to the original
      ;; checked-out p0 SHA, single-parent root.
      (let [parents-cmd (run-git! *repo* "rev-list" "--parents" "-n" "1"
                                  (:commit-sha data))
            parts (str/split (str/trim (:out parents-cmd)) #"\s+")]
        (is (= 3 (count parts))
            "final merge commit + 2 parents (sequential always 2-parent steps)")))))

(deftest sequential-merge-conflict-on-the-merge-step-test
  (testing "Two parents conflicting on the same file produce a conflict
            on the (single, since N=2 → N-1=1) merge step. The
            unresolvable anomaly's :merge/strategy preserves
            :sequential-merge so resolution-loop telemetry shows what
            was attempted."
    (run-git! *repo* "checkout" "-b" "task-a" "main")
    (commit-file! *repo* "src/conflict.txt" "from a\n" "a edit")
    (run-git! *repo* "checkout" "-b" "task-b" "main")
    (commit-file! *repo* "src/conflict.txt" "from b\n" "b edit")
    (run-git! *repo* "checkout" "main")
    (let [task-def {:task/id "task-c"
                    :task/deps [:a :b]
                    :task/merge-strategy :sequential-merge}
          ctx (ctx-with-registry {:a {:branch "task-a"}
                                  :b {:branch "task-b"}})
          result (dag-orch/merge-parent-branches! ctx task-def)]
      ;; With the no-op stub agent (Stage 2B's default), the resolution
      ;; loop hits recurring-conflict and terminates as unresolvable.
      ;; The :merge/strategy on the underlying conflict-anomaly carries
      ;; through into the unresolvable anomaly's :merge/strategy too.
      (is (= :anomalies/dag-multi-parent-unresolvable
             (:anomaly/category result)))
      (is (= :sequential-merge (:merge/strategy result))
          "strategy keyword preserved through resolution loop terminal"))))

;------------------------------------------------------------------------------ Rich Comment
(comment
  (clojure.test/run-tests 'ai.miniforge.workflow.merge-parent-branches-integration-test)

  :leave-this-here)
